Diff -- /var/www/vhosts/3dshawn.com/repricer.3dshawn.com/dashboard.cgi

O Operator
Diff

/var/www/vhosts/3dshawn.com/repricer.3dshawn.com/dashboard.cgi

added on local at 2026-07-01 21:47:06

Added
+471
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 98f2569ef831
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1#!/usr/bin/perl
2#======================================================================
3# Repricer -- dashboard for signed-in sellers.
4#
5# URL: /dashboard.cgi (the post-login landing for every signed-in
6# seller; renamed from the legacy /dashboard.cgi 2026-06-20 to match
7# the URL convention used across the other Shawn-stack sites).
8#
9# Top-of-funnel KPIs + recent activity. Every query is guarded by an
10# information_schema check so a half-migrated install still renders.
11#======================================================================
12use strict;
13use warnings;
14
15use lib '/var/www/vhosts/3dshawn.com/repricer.3dshawn.com';
16use CGI;
17use MODS::Template;
18use MODS::DBConnect;
19use MODS::Login;
20use MODS::RePricer::Config;
21use MODS::RePricer::Wrapper;
22
23my $q = CGI->new;
24my $auth = MODS::Login->new;
25my $wrap = MODS::RePricer::Wrapper->new;
26my $db = MODS::DBConnect->new;
27my $cfg = MODS::RePricer::Config->new;
28my $DB = $cfg->settings('database_name');
29
30$|=1;
31my $userinfo = $auth->login_verify();
32if (!$userinfo) {
33 print "Status: 302 Found\nLocation: /login.cgi\n\n";
34 exit;
35}
36my $uid = $userinfo->{user_id};
37$uid =~ s/[^0-9]//g;
38
39my $dbh = $db->db_connect();
40
41# POST: pause toggles. Supports either the master kill-switch
42# (repricing_paused) or per-marketplace toggles (pause_amazon/ebay/walmart).
43# Each is the panic button for that scope.
44if (($ENV{REQUEST_METHOD} || '') eq 'POST') {
45 my $form = $q->Vars;
46 my $act = $form->{action} || '';
47 if ($act eq 'toggle_pause') {
48 my $new = $form->{paused} && $form->{paused} eq '1' ? 1 : 0;
49 $db->db_readwrite($dbh, qq~
50 UPDATE `${DB}`.users SET repricing_paused='$new' WHERE id='$uid'
51 ~, $ENV{SCRIPT_NAME}, __LINE__);
52 print "Status: 302 Found\nLocation: /dashboard.cgi?paused=$new\n\n";
53 exit;
54 } elsif ($act eq 'toggle_mp_pause') {
55 my $mp = ($form->{platform} || '') =~ /^(amazon|ebay|walmart)$/ ? $1 : '';
56 if ($mp) {
57 my $col = "pause_$mp";
58 my $new = $form->{paused} && $form->{paused} eq '1' ? 1 : 0;
59 $db->db_readwrite($dbh, qq~
60 UPDATE `${DB}`.users SET $col='$new' WHERE id='$uid'
61 ~, $ENV{SCRIPT_NAME}, __LINE__);
62 }
63 print "Status: 302 Found\nLocation: /dashboard.cgi\n\n";
64 exit;
65 }
66}
67
68sub _has_table {
69 my ($name) = @_;
70 my $r = $db->db_readwrite($dbh, qq~
71 SELECT COUNT(*) AS n FROM information_schema.tables
72 WHERE table_schema='$DB' AND table_name='$name'
73 ~, $ENV{SCRIPT_NAME}, __LINE__);
74 return ($r && $r->{n}) ? 1 : 0;
75}
76sub _scalar {
77 my ($sql) = @_;
78 my $r = $db->db_readwrite($dbh, $sql, $ENV{SCRIPT_NAME}, __LINE__);
79 return ($r && defined $r->{n}) ? $r->{n} : 0;
80}
81sub _h { my $s = shift // ''; $s =~ s/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/g; $s =~ s/"/&quot;/g; $s }
82
83# ---- KPIs ----------------------------------------------------------
84my $products_total = _has_table('products') ? _scalar(qq~SELECT COUNT(*) n FROM `${DB}`.products WHERE user_id='$uid'~) : 0;
85my $products_repricing= _has_table('products') ? _scalar(qq~SELECT COUNT(*) n FROM `${DB}`.products WHERE user_id='$uid' AND reprice_enabled=1 AND status='active'~) : 0;
86my $products_amazon = _has_table('products') ? _scalar(qq~SELECT COUNT(*) n FROM `${DB}`.products WHERE user_id='$uid' AND marketplace='amazon'~) : 0;
87my $products_walmart = _has_table('products') ? _scalar(qq~SELECT COUNT(*) n FROM `${DB}`.products WHERE user_id='$uid' AND marketplace='walmart'~) : 0;
88my $rules_active = _has_table('repricing_rules') ? _scalar(qq~SELECT COUNT(*) n FROM `${DB}`.repricing_rules WHERE user_id='$uid' AND is_active=1~) : 0;
89my $accounts_connected= _has_table('marketplace_accounts') ? _scalar(qq~SELECT COUNT(*) n FROM `${DB}`.marketplace_accounts WHERE user_id='$uid' AND status='connected' AND platform IN ('amazon','ebay','walmart')~) : 0;
90
91# Onboarding step status. Step 1 = connect marketplace, 2 = import SKUs,
92# 3 = set floors on at least one product, 4 = create at least one rule,
93# 5 = enable repricing on at least one product. We compute these once
94# and render the checklist conditionally.
95my $step1_accounts = $accounts_connected;
96my $step2_products = $products_total;
97my $step3_floors = _has_table('products')
98 ? _scalar(qq~SELECT COUNT(*) n FROM `${DB}`.products
99 WHERE user_id='$uid' AND min_price_cents IS NOT NULL AND min_price_cents>0~)
100 : 0;
101my $step4_rules = $rules_active;
102my $step5_enabled = $products_repricing;
103my $steps_done = ($step1_accounts ? 1 : 0)
104 + ($step2_products ? 1 : 0)
105 + ($step3_floors ? 1 : 0)
106 + ($step4_rules ? 1 : 0)
107 + ($step5_enabled ? 1 : 0);
108my $show_checklist = $steps_done < 5 ? 1 : 0;
109
110# Pause state for the master + per-marketplace toggles.
111my $paused_row = $db->db_readwrite($dbh, qq~
112 SELECT repricing_paused, pause_amazon, pause_ebay, pause_walmart
113 FROM `${DB}`.users WHERE id='$uid'
114~, $ENV{SCRIPT_NAME}, __LINE__);
115my $is_paused = ($paused_row && $paused_row->{repricing_paused}) ? 1 : 0;
116my $pause_amazon = ($paused_row && $paused_row->{pause_amazon}) ? 1 : 0;
117my $pause_ebay = ($paused_row && $paused_row->{pause_ebay}) ? 1 : 0;
118my $pause_walmart= ($paused_row && $paused_row->{pause_walmart}) ? 1 : 0;
119
120# Anomaly detection: SKUs that changed > 25% in the last 24h. Surface
121# the count as a chip so the user can click in and review.
122my $anomalies = _has_table('price_changes')
123 ? _scalar(qq~
124 SELECT COUNT(*) AS n FROM `${DB}`.price_changes
125 WHERE user_id='$uid' AND created_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR)
126 AND old_price_cents > 0
127 AND ABS(new_price_cents - old_price_cents) / old_price_cents > 0.25
128 ~)
129 : 0;
130
131# Last-7-days sparkline data: count of price_changes per day for chart.
132my @spark_days;
133if (_has_table('price_changes')) {
134 my $rows = $db->db_readwrite_multiple($dbh, qq~
135 SELECT DATE(created_at) AS d, COUNT(*) AS n
136 FROM `${DB}`.price_changes
137 WHERE user_id='$uid' AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
138 GROUP BY DATE(created_at) ORDER BY d
139 ~, $ENV{SCRIPT_NAME}, __LINE__);
140 my %by_day;
141 foreach my $r (@$rows) { $by_day{$r->{d}} = $r->{n}; }
142 for my $offset (reverse 0..6) {
143 my @t = localtime(time - $offset * 86400);
144 my $d = sprintf('%04d-%02d-%02d', $t[5]+1900, $t[4]+1, $t[3]);
145 push @spark_days, ($by_day{$d} || 0);
146 }
147}
148my $changes_7d = _has_table('price_changes') ? _scalar(qq~SELECT COUNT(*) n FROM `${DB}`.price_changes WHERE user_id='$uid' AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)~) : 0;
149my $changes_24h = _has_table('price_changes') ? _scalar(qq~SELECT COUNT(*) n FROM `${DB}`.price_changes WHERE user_id='$uid' AND created_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR)~) : 0;
150my $changes_failed_24h= _has_table('price_changes') ? _scalar(qq~SELECT COUNT(*) n FROM `${DB}`.price_changes WHERE user_id='$uid' AND sync_status='failed' AND created_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR)~) : 0;
151
152# Last reprice run (global, not per-user -- the worker is platform-wide for now)
153my $last_run;
154if (_has_table('reprice_runs')) {
155 $last_run = $db->db_readwrite($dbh, qq~
156 SELECT id,
157 DATE_FORMAT(started_at, '%Y-%m-%d %H:%i') AS started,
158 DATE_FORMAT(finished_at, '%Y-%m-%d %H:%i') AS finished,
159 TIMESTAMPDIFF(MINUTE, started_at, NOW()) AS started_min_ago,
160 products_evaluated, products_changed, errors_count, triggered_by
161 FROM `${DB}`.reprice_runs
162 ORDER BY id DESC LIMIT 1
163 ~, $ENV{SCRIPT_NAME}, __LINE__);
164}
165
166# Buybox win rate over the last 7 days (rows where we won / all rows).
167my ($bbx_total, $bbx_won) = (0, 0);
168if (_has_table('buybox_history') && _has_table('products')) {
169 my $r = $db->db_readwrite($dbh, qq~
170 SELECT COUNT(*) AS total,
171 SUM(CASE WHEN bh.winner_is_self=1 THEN 1 ELSE 0 END) AS won
172 FROM `${DB}`.buybox_history bh
173 JOIN `${DB}`.products p ON p.id = bh.product_id
174 WHERE p.user_id='$uid'
175 AND bh.observed_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
176 ~, $ENV{SCRIPT_NAME}, __LINE__);
177 $bbx_total = ($r && $r->{total}) ? $r->{total} : 0;
178 $bbx_won = ($r && $r->{won}) ? $r->{won} : 0;
179}
180my $bbx_pct = $bbx_total ? int(($bbx_won / $bbx_total) * 100) : 0;
181
182# Recent activity (last 10 price changes).
183my @recent;
184if (_has_table('price_changes') && _has_table('products')) {
185 @recent = $db->db_readwrite_multiple($dbh, qq~
186 SELECT pc.id, pc.old_price_cents, pc.new_price_cents, pc.reason,
187 pc.sync_status, pc.automated,
188 DATE_FORMAT(pc.created_at, '%m/%d %H:%i') AS t,
189 TIMESTAMPDIFF(MINUTE, pc.created_at, NOW()) AS age_min,
190 p.sku, p.title, p.marketplace, p.id AS product_id
191 FROM `${DB}`.price_changes pc
192 JOIN `${DB}`.products p ON p.id = pc.product_id
193 WHERE pc.user_id='$uid'
194 ORDER BY pc.id DESC
195 LIMIT 10
196 ~, $ENV{SCRIPT_NAME}, __LINE__);
197}
198
199$db->db_disconnect($dbh);
200
201# ---- Friendly labels -----------------------------------------------
202my $first_name = $userinfo->{display_name} || $userinfo->{email} || 'there';
203$first_name =~ s/\s.*//; $first_name =~ s/\@.*//;
204
205my $last_run_label = 'No runs yet';
206if ($last_run && $last_run->{id}) {
207 my $m = $last_run->{started_min_ago} || 0;
208 if ($m < 1) { $last_run_label = 'Just now'; }
209 elsif ($m < 60) { $last_run_label = $m . ' min ago'; }
210 elsif ($m < 60 * 24) { $last_run_label = int($m / 60) . ' hr ago'; }
211 else { $last_run_label = int($m / 1440) . ' days ago'; }
212}
213
214sub _money { my $c = shift; defined $c ? sprintf('$%.2f', $c / 100) : '--'; }
215
216# ---- Render -------------------------------------------------------
217my $body = '<div style="padding:24px 28px;max-width:1200px">';
218
219# Hero
220$body .= '<div style="margin-bottom:24px">';
221$body .= ' <h1 style="font-size:28px;margin:0 0 6px;font-weight:700">Welcome back, ' . _h($first_name) . '.</h1>';
222if ($products_total == 0) {
223 $body .= ' <p style="color:#9ca3af;margin:0">No products imported yet. <a href="/marketplaces.cgi" style="color:#10b981">Connect Amazon or Walmart</a> to pull in your catalog, then set up rules on <a href="/repricing_rules.cgi" style="color:#10b981">/repricing_rules.cgi</a>.</p>';
224} elsif ($products_repricing == 0) {
225 $body .= ' <p style="color:#9ca3af;margin:0">' . $products_total . ' product' . ($products_total==1?'':'s') . ' imported, none yet enabled for repricing. <a href="/products.cgi" style="color:#10b981">Enable repricing on a product</a> to start.</p>';
226} else {
227 $body .= ' <p style="color:#9ca3af;margin:0">Repricing engine is monitoring <strong>' . $products_repricing . '</strong> product' . ($products_repricing==1?'':'s') . ' across ' . $accounts_connected . ' marketplace account' . ($accounts_connected==1?'':'s') . '. Last run: <strong>' . _h($last_run_label) . '</strong>.</p>';
228}
229$body .= '</div>';
230
231# ---- Master + per-marketplace pause toggles -----------------------
232# Top row is the master kill-switch. Bottom row breaks it down per
233# platform so sellers can pause Amazon during an account review but
234# keep eBay running.
235{
236 my $bg_off = 'background:#0f172a;border:1px solid #1f2937';
237 my $bg_on = 'background:#7f1d1d22;border:1px solid #ef4444';
238 my $style = $is_paused ? $bg_on : $bg_off;
239 my $label = $is_paused ? 'Repricing PAUSED -- worker is skipping your products'
240 : 'Repricing is LIVE';
241 my $sub = $is_paused ? 'No price changes will be pushed until you resume.'
242 : 'The worker evaluates your active rules on every cycle.';
243 my $btn_label = $is_paused ? 'Resume repricing' : 'Pause all repricing';
244 my $btn_color = $is_paused ? '#059669' : '#ef4444';
245 my $btn_txt = $is_paused ? '#031816' : '#ffffff';
246 my $next = $is_paused ? 0 : 1;
247 my $dot_color = $is_paused ? '#ef4444' : '#059669';
248 $body .= qq~<div style="$style;border-radius:12px;padding:16px 20px;margin-bottom:10px;display:flex;align-items:center;gap:14px;flex-wrap:wrap">
249 <span style="width:10px;height:10px;border-radius:50%;background:$dot_color;box-shadow:0 0 10px $dot_color;flex-shrink:0"></span>
250 <div style="flex:1;min-width:240px">
251 <div style="color:#f9fafb;font-size:14px;font-weight:600">$label</div>
252 <div style="color:#9ca3af;font-size:12px;margin-top:2px">$sub</div>
253 </div>
254 <form method="POST" style="margin:0">
255 <input type="hidden" name="action" value="toggle_pause">
256 <input type="hidden" name="paused" value="$next">
257 <button type="submit" style="background:$btn_color;color:$btn_txt;border:0;padding:8px 16px;border-radius:8px;font-weight:600;cursor:pointer;font-size:13px">$btn_label</button>
258 </form>
259 </div>~;
260 # Per-marketplace row
261 $body .= '<div style="display:flex;gap:8px;margin-bottom:18px;flex-wrap:wrap">';
262 foreach my $r (
263 ['amazon', 'Amazon', $pause_amazon],
264 ['ebay', 'eBay', $pause_ebay],
265 ['walmart', 'Walmart', $pause_walmart],
266 ) {
267 my ($plat, $label, $paused) = @$r;
268 my $col = $paused ? '#ef4444' : '#059669';
269 my $state = $paused ? 'PAUSED' : 'LIVE';
270 my $next = $paused ? 0 : 1;
271 my $btn = $paused ? "Resume $label" : "Pause $label";
272 $body .= qq~<form method="POST" style="margin:0;flex:1;min-width:180px">
273 <input type="hidden" name="action" value="toggle_mp_pause">
274 <input type="hidden" name="platform" value="$plat">
275 <input type="hidden" name="paused" value="$next">
276 <button type="submit" style="width:100%;background:#0f172a;border:1px solid #1f2937;color:#e5e7eb;padding:10px 12px;border-radius:8px;cursor:pointer;font-size:12px;display:flex;align-items:center;gap:8px;justify-content:space-between">
277 <span><span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:$col;margin-right:6px"></span><b>$label</b> <span style="color:$col;font-size:11px;font-weight:700">$state</span></span>
278 <span style="color:#9ca3af;font-size:11px">$btn &rarr;</span>
279 </button>
280 </form>~;
281 }
282 $body .= '</div>';
283}
284
285# Anomaly chip: render only when there are anomalies in the last 24h.
286if ($anomalies > 0) {
287 $body .= qq~<div style="background:#fbbf2422;border-left:3px solid #fbbf24;color:#fbbf24;padding:12px 16px;border-radius:8px;margin-bottom:14px;font-size:13px;display:flex;align-items:center;gap:10px">
288 <span style="background:#fbbf24;color:#0a0f1f;padding:2px 8px;border-radius:6px;font-weight:700;font-size:11px">REVIEW</span>
289 <span><b>$anomalies SKU~ . ($anomalies == 1 ? '' : 's') . qq~</b> moved more than 25% in price in the last 24 hours. <a href="/price_history.cgi?anomaly=1" style="color:#fbbf24;text-decoration:underline">Review</a> to make sure your rules + floors are correct.</span>
290 </div>~;
291}
292
293# ---- Onboarding checklist (rendered while user has < 5 steps done) -
294if ($show_checklist) {
295 my @steps = (
296 { done => $step1_accounts ? 1 : 0,
297 title => 'Connect a marketplace account',
298 desc => 'Authorize Amazon, eBay, or Walmart so RePricer can read your listings.',
299 href => '/marketplaces.cgi',
300 cta => 'Connect' },
301 { done => $step2_products ? 1 : 0,
302 title => 'Import your products',
303 desc => 'Bring your SKUs into RePricer -- click "Import from marketplace" or upload a CSV.',
304 href => '/products.cgi',
305 cta => 'Import SKUs' },
306 { done => $step3_floors ? 1 : 0,
307 title => 'Set a floor price on at least one product',
308 desc => 'The floor is the price the repricer will never drop below. No floor = no protection.',
309 href => '/products.cgi',
310 cta => 'Set floors' },
311 { done => $step4_rules ? 1 : 0,
312 title => 'Create your first repricing rule',
313 desc => 'Pick a strategy (beat lowest, match Buy Box, cost-plus...) and set its guardrails.',
314 href => '/repricing_rules.cgi',
315 cta => 'New rule' },
316 { done => $step5_enabled ? 1 : 0,
317 title => 'Enable repricing on a product',
318 desc => 'Flip Reprice ON for the SKUs you want managed. The worker takes it from there.',
319 href => '/products.cgi',
320 cta => 'Enable' },
321 );
322 $body .= '<div style="background:#0f172a;border:1px solid #334155;border-radius:12px;padding:18px 20px;margin-bottom:18px">';
323 $body .= " <div style=\"display:flex;align-items:baseline;justify-content:space-between;margin-bottom:12px\">";
324 $body .= " <h3 style=\"margin:0;font-size:14px;color:#9ca3af;text-transform:uppercase;letter-spacing:.06em;font-weight:600\">Get RePricer running &middot; $steps_done of 5 done</h3>";
325 $body .= " <span style=\"color:#14b8a6;font-size:11px;letter-spacing:1.5px;text-transform:uppercase\">Setup checklist</span>";
326 $body .= " </div>";
327 foreach my $i (0..$#steps) {
328 my $s = $steps[$i];
329 my $n = $i + 1;
330 my $icon = $s->{done}
331 ? '<span style="width:22px;height:22px;border-radius:50%;background:#05966922;color:#059669;display:inline-flex;align-items:center;justify-content:center;font-weight:700;font-size:12px;flex-shrink:0">&check;</span>'
332 : qq~<span style="width:22px;height:22px;border-radius:50%;background:#1e293b;color:#94a3b8;border:1px solid #334155;display:inline-flex;align-items:center;justify-content:center;font-weight:700;font-size:12px;flex-shrink:0">$n</span>~;
333 my $title_color = $s->{done} ? '#059669' : '#f9fafb';
334 my $title_style = $s->{done} ? 'text-decoration:line-through;color:#059669' : "color:$title_color";
335 my $cta_html = $s->{done}
336 ? '<span style="color:#059669;font-size:12px;font-weight:600">DONE</span>'
337 : qq~<a href="$s->{href}" style="background:#064e3b;color:#a7f3d0;padding:6px 12px;border-radius:6px;text-decoration:none;font-size:12px;font-weight:600">$s->{cta} &rarr;</a>~;
338 $body .= qq~ <div style="display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px dashed #1f2937">
339 $icon
340 <div style="flex:1">
341 <div style="$title_style;font-size:14px;font-weight:600">$s->{title}</div>
342 <div style="color:#9ca3af;font-size:12px;margin-top:2px">$s->{desc}</div>
343 </div>
344 $cta_html
345 </div>~;
346 }
347 $body .= '</div>';
348}
349
350# KPI grid
351my @kpis = (
352 { label => 'Products tracked', value => $products_total, sub => "$products_amazon Amazon &middot; $products_walmart Walmart", href => '/products.cgi' },
353 { label => 'Being repriced', value => $products_repricing, sub => $rules_active . ' active rule' . ($rules_active==1?'':'s'), href => '/repricing_rules.cgi' },
354 { label => 'Price changes 24h', value => $changes_24h, sub => $changes_failed_24h . ' failed sync' . ($changes_failed_24h==1?'':'s'), href => '/price_history.cgi' },
355 { label => 'Price changes 7d', value => $changes_7d, sub => $bbx_total ? "Buybox win rate ${bbx_pct}%" : 'No buybox data yet', href => '/price_history.cgi' },
356);
357
358$body .= '<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:24px">';
359foreach my $k (@kpis) {
360 $body .= '<a href="' . _h($k->{href}) . '" style="display:block;background:#111827;border:1px solid #1f2937;border-radius:12px;padding:18px;text-decoration:none;color:inherit;transition:border-color .15s" onmouseover="this.style.borderColor=\'#374151\'" onmouseout="this.style.borderColor=\'#1f2937\'">';
361 $body .= ' <div style="color:#9ca3af;font-size:11px;text-transform:uppercase;letter-spacing:.06em;margin-bottom:8px">' . _h($k->{label}) . '</div>';
362 $body .= ' <div style="font-size:32px;font-weight:700;color:#f9fafb;line-height:1">' . _h($k->{value}) . '</div>';
363 $body .= ' <div style="color:#6b7280;font-size:12px;margin-top:6px">' . $k->{sub} . '</div>';
364 $body .= '</a>';
365}
366$body .= '</div>';
367
368# Last reprice run card
369$body .= '<div style="display:grid;grid-template-columns:1fr 2fr;gap:14px;margin-bottom:24px">';
370$body .= '<div style="background:#111827;border:1px solid #1f2937;border-radius:12px;padding:18px">';
371$body .= ' <h3 style="margin:0 0 12px;font-size:14px;color:#9ca3af;text-transform:uppercase;letter-spacing:.06em;font-weight:600">Last reprice run</h3>';
372if ($last_run && $last_run->{id}) {
373 my $err_color = $last_run->{errors_count} ? '#ef4444' : '#059669';
374 $body .= ' <div style="font-size:22px;font-weight:700;color:#f9fafb">' . _h($last_run_label) . '</div>';
375 $body .= ' <div style="margin-top:10px;color:#d1d5db;font-size:13px;line-height:1.7">';
376 $body .= ' Evaluated <strong>' . $last_run->{products_evaluated} . '</strong> products<br>';
377 $body .= ' Changed <strong>' . $last_run->{products_changed} . '</strong> prices<br>';
378 $body .= ' <span style="color:' . $err_color . '">' . $last_run->{errors_count} . ' error' . ($last_run->{errors_count}==1?'':'s') . '</span><br>';
379 $body .= ' Triggered: <em>' . _h($last_run->{triggered_by}) . '</em>';
380 $body .= ' </div>';
381} else {
382 $body .= ' <div style="font-size:22px;font-weight:700;color:#f9fafb">No runs yet</div>';
383 $body .= ' <div style="margin-top:10px;color:#9ca3af;font-size:13px">The worker fires every 15 minutes when at least one product has <code>reprice_enabled=1</code>. Manual runs available from <a href="/repricing_rules.cgi" style="color:#10b981">/repricing_rules.cgi</a>.</div>';
384}
385$body .= '</div>';
386
387# Recent activity card
388$body .= '<div style="background:#111827;border:1px solid #1f2937;border-radius:12px;padding:18px">';
389$body .= ' <div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">';
390$body .= ' <h3 style="margin:0;font-size:14px;color:#9ca3af;text-transform:uppercase;letter-spacing:.06em;font-weight:600;flex:1">Recent price changes</h3>';
391
392# 7-day sparkline so the dashboard "breathes" -- tiny inline SVG, no
393# Chart.js needed. Renders to a flat baseline if there's no activity.
394if (@spark_days) {
395 my $max = 0; foreach (@spark_days) { $max = $_ if $_ > $max; }
396 $max ||= 1;
397 my $w = 110; my $h = 28;
398 my $step = $w / (scalar(@spark_days) - 1);
399 my @pts;
400 for my $i (0..$#spark_days) {
401 my $x = sprintf('%.1f', $i * $step);
402 my $y = sprintf('%.1f', $h - ($spark_days[$i] / $max) * ($h - 2) - 1);
403 push @pts, "$x,$y";
404 }
405 my $poly = join(' ', @pts);
406 my $total7 = 0; $total7 += $_ for @spark_days;
407 $body .= qq~ <span style="color:#9ca3af;font-size:11px">last 7 days &middot; $total7 total</span>
408 <svg width="$w" height="$h" viewBox="0 0 $w $h" style="overflow:visible">
409 <polyline fill="none" stroke="#14b8a6" stroke-width="1.5" points="$poly" />
410 </svg>~;
411}
412$body .= ' </div>';
413if (scalar @recent) {
414 $body .= ' <table style="width:100%;font-size:13px;border-collapse:collapse">';
415 $body .= ' <thead><tr style="color:#6b7280;text-align:left;border-bottom:1px solid #1f2937">';
416 $body .= ' <th style="padding:8px 6px;font-weight:500">When</th>';
417 $body .= ' <th style="padding:8px 6px;font-weight:500">Product</th>';
418 $body .= ' <th style="padding:8px 6px;font-weight:500;text-align:right">Old</th>';
419 $body .= ' <th style="padding:8px 6px;font-weight:500;text-align:right">New</th>';
420 $body .= ' <th style="padding:8px 6px;font-weight:500">Reason</th>';
421 $body .= ' <th style="padding:8px 6px;font-weight:500">Sync</th>';
422 $body .= ' </tr></thead><tbody>';
423 foreach my $r (@recent) {
424 my $delta = ($r->{new_price_cents}||0) - ($r->{old_price_cents}||0);
425 my $delta_color = $delta < 0 ? '#059669' : ($delta > 0 ? '#f59e0b' : '#6b7280');
426 my $sync_color = $r->{sync_status} eq 'synced' ? '#059669' :
427 $r->{sync_status} eq 'failed' ? '#ef4444' :
428 $r->{sync_status} eq 'pending'? '#f59e0b' : '#6b7280';
429 $body .= '<tr style="border-bottom:1px solid #1f2937;color:#d1d5db">';
430 $body .= ' <td style="padding:10px 6px;color:#9ca3af">' . _h($r->{t}) . '</td>';
431 $body .= ' <td style="padding:10px 6px"><strong>' . _h(substr($r->{title}||$r->{sku}||'(no title)',0,40)) . '</strong> <span style="color:#6b7280;font-size:11px;text-transform:uppercase">' . _h($r->{marketplace}) . '</span></td>';
432 $body .= ' <td style="padding:10px 6px;text-align:right;color:#6b7280">' . _money($r->{old_price_cents}) . '</td>';
433 $body .= ' <td style="padding:10px 6px;text-align:right;color:' . $delta_color . ';font-weight:600">' . _money($r->{new_price_cents}) . '</td>';
434 $body .= ' <td style="padding:10px 6px;color:#9ca3af">' . _h($r->{reason}) . '</td>';
435 $body .= ' <td style="padding:10px 6px"><span style="color:' . $sync_color . '">&bull; ' . _h($r->{sync_status}) . '</span></td>';
436 $body .= '</tr>';
437 }
438 $body .= ' </tbody></table>';
439 $body .= ' <div style="margin-top:12px;text-align:right"><a href="/price_history.cgi" style="color:#10b981;font-size:13px">All price changes &rarr;</a></div>';
440} else {
441 $body .= ' <div style="padding:24px 0;color:#9ca3af;font-size:13px;text-align:center">No price changes recorded yet. Enable repricing on a product and the worker will populate this feed.</div>';
442}
443$body .= '</div>';
444$body .= '</div>';
445
446# Quick links
447$body .= '<div style="background:#0f172a;border:1px solid #1e293b;border-radius:12px;padding:16px 18px">';
448$body .= ' <div style="color:#9ca3af;font-size:11px;text-transform:uppercase;letter-spacing:.06em;margin-bottom:10px;font-weight:600">Quick links</div>';
449$body .= ' <div style="display:flex;gap:10px;flex-wrap:wrap">';
450foreach my $link (
451 ['/products.cgi', 'My Products'],
452 ['/repricing_rules.cgi', 'Repricing Rules'],
453 ['/competitors.cgi', 'Competitor Tracking'],
454 ['/marketplaces.cgi', 'Marketplace Accounts'],
455 ['/price_history.cgi', 'Price History'],
456 ['/billing.cgi', 'Billing & Plan'],
457) {
458 $body .= '<a href="' . $link->[0] . '" style="padding:8px 14px;background:#1e293b;border-radius:8px;color:#e5e7eb;text-decoration:none;font-size:13px;border:1px solid #334155">' . $link->[1] . '</a>';
459}
460$body .= ' </div>';
461$body .= '</div>';
462$body .= '</div>';
463
464print "Content-Type: text/html; charset=utf-8\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n";
465$wrap->render({
466 userinfo => $userinfo,
467 page_key => 'dashboard',
468 title => 'Dashboard',
469 body => $body,
470});
471exit;