Diff -- /var/www/vhosts/3dshawn.com/repricer.3dshawn.com/dashboard.cgi
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
to 98f2569ef831
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 | #====================================================================== | |
| 12 | use strict; | |
| 13 | use warnings; | |
| 14 | ||
| 15 | use lib '/var/www/vhosts/3dshawn.com/repricer.3dshawn.com'; | |
| 16 | use CGI; | |
| 17 | use MODS::Template; | |
| 18 | use MODS::DBConnect; | |
| 19 | use MODS::Login; | |
| 20 | use MODS::RePricer::Config; | |
| 21 | use MODS::RePricer::Wrapper; | |
| 22 | ||
| 23 | my $q = CGI->new; | |
| 24 | my $auth = MODS::Login->new; | |
| 25 | my $wrap = MODS::RePricer::Wrapper->new; | |
| 26 | my $db = MODS::DBConnect->new; | |
| 27 | my $cfg = MODS::RePricer::Config->new; | |
| 28 | my $DB = $cfg->settings('database_name'); | |
| 29 | ||
| 30 | $|=1; | |
| 31 | my $userinfo = $auth->login_verify(); | |
| 32 | if (!$userinfo) { | |
| 33 | print "Status: 302 Found\nLocation: /login.cgi\n\n"; | |
| 34 | exit; | |
| 35 | } | |
| 36 | my $uid = $userinfo->{user_id}; | |
| 37 | $uid =~ s/[^0-9]//g; | |
| 38 | ||
| 39 | my $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. | |
| 44 | if (($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 | ||
| 68 | sub _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 | } | |
| 76 | sub _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 | } | |
| 81 | sub _h { my $s = shift // ''; $s =~ s/&/&/g; $s =~ s/</</g; $s =~ s/>/>/g; $s =~ s/"/"/g; $s } | |
| 82 | ||
| 83 | # ---- KPIs ---------------------------------------------------------- | |
| 84 | my $products_total = _has_table('products') ? _scalar(qq~SELECT COUNT(*) n FROM `${DB}`.products WHERE user_id='$uid'~) : 0; | |
| 85 | my $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; | |
| 86 | my $products_amazon = _has_table('products') ? _scalar(qq~SELECT COUNT(*) n FROM `${DB}`.products WHERE user_id='$uid' AND marketplace='amazon'~) : 0; | |
| 87 | my $products_walmart = _has_table('products') ? _scalar(qq~SELECT COUNT(*) n FROM `${DB}`.products WHERE user_id='$uid' AND marketplace='walmart'~) : 0; | |
| 88 | my $rules_active = _has_table('repricing_rules') ? _scalar(qq~SELECT COUNT(*) n FROM `${DB}`.repricing_rules WHERE user_id='$uid' AND is_active=1~) : 0; | |
| 89 | my $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. | |
| 95 | my $step1_accounts = $accounts_connected; | |
| 96 | my $step2_products = $products_total; | |
| 97 | my $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; | |
| 101 | my $step4_rules = $rules_active; | |
| 102 | my $step5_enabled = $products_repricing; | |
| 103 | my $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); | |
| 108 | my $show_checklist = $steps_done < 5 ? 1 : 0; | |
| 109 | ||
| 110 | # Pause state for the master + per-marketplace toggles. | |
| 111 | my $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__); | |
| 115 | my $is_paused = ($paused_row && $paused_row->{repricing_paused}) ? 1 : 0; | |
| 116 | my $pause_amazon = ($paused_row && $paused_row->{pause_amazon}) ? 1 : 0; | |
| 117 | my $pause_ebay = ($paused_row && $paused_row->{pause_ebay}) ? 1 : 0; | |
| 118 | my $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. | |
| 122 | my $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. | |
| 132 | my @spark_days; | |
| 133 | if (_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 | } | |
| 148 | my $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; | |
| 149 | my $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; | |
| 150 | my $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) | |
| 153 | my $last_run; | |
| 154 | if (_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). | |
| 167 | my ($bbx_total, $bbx_won) = (0, 0); | |
| 168 | if (_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 | } | |
| 180 | my $bbx_pct = $bbx_total ? int(($bbx_won / $bbx_total) * 100) : 0; | |
| 181 | ||
| 182 | # Recent activity (last 10 price changes). | |
| 183 | my @recent; | |
| 184 | if (_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 ----------------------------------------------- | |
| 202 | my $first_name = $userinfo->{display_name} || $userinfo->{email} || 'there'; | |
| 203 | $first_name =~ s/\s.*//; $first_name =~ s/\@.*//; | |
| 204 | ||
| 205 | my $last_run_label = 'No runs yet'; | |
| 206 | if ($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 | ||
| 214 | sub _money { my $c = shift; defined $c ? sprintf('$%.2f', $c / 100) : '--'; } | |
| 215 | ||
| 216 | # ---- Render ------------------------------------------------------- | |
| 217 | my $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>'; | |
| 222 | if ($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 →</span> | |
| 279 | </button> | |
| 280 | </form>~; | |
| 281 | } | |
| 282 | $body .= '</div>'; | |
| 283 | } | |
| 284 | ||
| 285 | # Anomaly chip: render only when there are anomalies in the last 24h. | |
| 286 | if ($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) - | |
| 294 | if ($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 · $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">✓</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} →</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 | |
| 351 | my @kpis = ( | |
| 352 | { label => 'Products tracked', value => $products_total, sub => "$products_amazon Amazon · $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">'; | |
| 359 | foreach 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>'; | |
| 372 | if ($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. | |
| 394 | if (@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 · $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>'; | |
| 413 | if (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 . '">• ' . _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 →</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">'; | |
| 450 | foreach 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 | ||
| 464 | print "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 | }); | |
| 471 | exit; |