Diff -- /var/www/vhosts/webstls.com/httpdocs/sales_reports.cgi
Diff

/var/www/vhosts/webstls.com/httpdocs/sales_reports.cgi

added on WebSTLs (webstls.com) at 2026-07-01 22:26:46

Added
+590
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to b5033d0a17ee
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# WebSTLs -- Sales Reports.
4#
5# Replaces the three old pages (dashboard.cgi + analytics.cgi +
6# audience.cgi) with one tabbed view. Same data, one URL.
7#
8# Tabs (via ?tab=NAME, default 'overview'):
9# overview -- top KPI tiles (revenue today/MTD/30d/AOV) + delta %
10# channels -- per-channel revenue cards (storefront + marketplaces)
11# buyers -- distinct buyers, top buyers by LTV, segment counts
12# products -- top-selling models by revenue + units
13#======================================================================
14use strict;
15use warnings;
16
17use lib '/var/www/vhosts/webstls.com/httpdocs';
18use CGI;
19use MODS::Template;
20use MODS::DBConnect;
21use MODS::Login;
22use MODS::WebSTLs::Config;
23use MODS::WebSTLs::Wrapper;
24use MODS::WebSTLs::Marketplaces;
25
26my $q = CGI->new;
27my $form = $q->Vars;
28my $auth = MODS::Login->new;
29my $wrap = MODS::WebSTLs::Wrapper->new;
30my $tfile = MODS::Template->new;
31my $db = MODS::DBConnect->new;
32my $cfg = MODS::WebSTLs::Config->new;
33my $mp = MODS::WebSTLs::Marketplaces->new;
34my $DB = $cfg->settings('database_name');
35
36$|=1;
37my $userinfo = $auth->login_verify();
38unless ($userinfo) {
39 print "Status: 302 Found\nLocation: /login.cgi\n\n";
40 exit;
41}
42require MODS::WebSTLs::Permissions;
43MODS::WebSTLs::Permissions->new->require_feature($userinfo, 'view_reports');
44my $uid = $userinfo->{user_id};
45$uid =~ s/[^0-9]//g;
46
47my $tab = lc($form->{tab} || 'overview');
48$tab =~ s/[^a-z]//g;
49$tab = 'overview' unless $tab =~ /^(overview|channels|buyers|products|tax)$/;
50
51my $dbh = $db->db_connect();
52
53# Find this user's storefronts -- everything aggregates across them.
54my @sf_rows = $db->db_readwrite_multiple($dbh,
55 qq~SELECT id, name FROM `${DB}`.storefronts WHERE user_id='$uid'~,
56 $ENV{SCRIPT_NAME}, __LINE__);
57my @sids = map { $_->{id} } @sf_rows;
58my $sids_sql = scalar(@sids) ? join(',', map { "'$_'" } @sids) : "'0'";
59my $has_storefront = scalar(@sids) ? 1 : 0;
60
61#-----------------------------------------------------------------
62# Small SQL helper. Returns the scalar field `n` from a one-row query
63# (or 0 if the table doesn't exist / no rows). Wrapped in eval so a
64# missing table doesn't take the whole CGI down.
65#-----------------------------------------------------------------
66sub _n {
67 my ($sql) = @_;
68 my $r;
69 eval { $r = $db->db_readwrite($dbh, $sql, $ENV{SCRIPT_NAME}, __LINE__); 1 };
70 return ($r && defined $r->{n}) ? $r->{n} : 0;
71}
72
73# Pre-check optional tables so missing-migration installs don't 500.
74my $has_orders = _n(qq~SELECT COUNT(*) AS n FROM information_schema.tables
75 WHERE table_schema='$DB' AND table_name='orders'~);
76my $has_order_items = _n(qq~SELECT COUNT(*) AS n FROM information_schema.tables
77 WHERE table_schema='$DB' AND table_name='order_items'~);
78
79#=================================================================
80# OVERVIEW tab data
81#=================================================================
82my $rev_today_cents = 0;
83my $rev_mtd_cents = 0;
84my $rev_30d_cents = 0;
85my $rev_prev30_cents = 0;
86my $orders_today = 0;
87my $orders_mtd = 0;
88my $orders_30d = 0;
89my $aov_cents = 0;
90
91if ($tab eq 'overview' && $has_orders && $has_storefront) {
92 $rev_today_cents = _n(qq~SELECT COALESCE(SUM(total_amount_cents),0) AS n
93 FROM `${DB}`.orders
94 WHERE storefront_id IN ($sids_sql) AND status='paid'
95 AND DATE(paid_at) = CURDATE()~);
96 $rev_mtd_cents = _n(qq~SELECT COALESCE(SUM(total_amount_cents),0) AS n
97 FROM `${DB}`.orders
98 WHERE storefront_id IN ($sids_sql) AND status='paid'
99 AND paid_at >= DATE_FORMAT(CURDATE(), '%Y-%m-01')~);
100 $rev_30d_cents = _n(qq~SELECT COALESCE(SUM(total_amount_cents),0) AS n
101 FROM `${DB}`.orders
102 WHERE storefront_id IN ($sids_sql) AND status='paid'
103 AND paid_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)~);
104 $rev_prev30_cents = _n(qq~SELECT COALESCE(SUM(total_amount_cents),0) AS n
105 FROM `${DB}`.orders
106 WHERE storefront_id IN ($sids_sql) AND status='paid'
107 AND paid_at >= DATE_SUB(NOW(), INTERVAL 60 DAY)
108 AND paid_at < DATE_SUB(NOW(), INTERVAL 30 DAY)~);
109 $orders_today = _n(qq~SELECT COUNT(*) AS n FROM `${DB}`.orders
110 WHERE storefront_id IN ($sids_sql) AND status='paid'
111 AND DATE(paid_at) = CURDATE()~);
112 $orders_mtd = _n(qq~SELECT COUNT(*) AS n FROM `${DB}`.orders
113 WHERE storefront_id IN ($sids_sql) AND status='paid'
114 AND paid_at >= DATE_FORMAT(CURDATE(), '%Y-%m-01')~);
115 $orders_30d = _n(qq~SELECT COUNT(*) AS n FROM `${DB}`.orders
116 WHERE storefront_id IN ($sids_sql) AND status='paid'
117 AND paid_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)~);
118 $aov_cents = $orders_30d ? int($rev_30d_cents / $orders_30d) : 0;
119}
120
121# Recent sales feed for the Overview tab. One row per paid order with
122# the buyer label, what they bought, and the amount. GROUP_CONCAT in
123# the items subquery keeps it to a single query instead of N+1.
124my @recent_sales;
125if ($tab eq 'overview' && $has_orders && $has_storefront) {
126 my $items_join = $has_order_items
127 ? qq~(SELECT GROUP_CONCAT(m.title ORDER BY oi.id SEPARATOR ', ')
128 FROM `${DB}`.order_items oi
129 JOIN `${DB}`.models m ON m.id = oi.model_id
130 WHERE oi.order_id = o.id) AS items_titles,
131 (SELECT SUM(oi2.quantity) FROM `${DB}`.order_items oi2 WHERE oi2.order_id = o.id) AS item_count,~
132 : qq~'' AS items_titles, 0 AS item_count,~;
133 @recent_sales = $db->db_readwrite_multiple($dbh, qq~
134 SELECT o.id, o.buyer_email, o.total_amount_cents, o.currency, o.paid_at,
135 b.display_name AS buyer_name,
136 $items_join
137 1 AS keep
138 FROM `${DB}`.orders o
139 LEFT JOIN `${DB}`.buyer_accounts b ON b.id = o.buyer_account_id
140 WHERE o.storefront_id IN ($sids_sql) AND o.status='paid'
141 ORDER BY o.paid_at DESC
142 LIMIT 30
143 ~, $ENV{SCRIPT_NAME}, __LINE__);
144}
145
146my @recent_sale_rows;
147foreach my $r (@recent_sales) {
148 my $email = $r->{buyer_email} || '';
149 my $name = $r->{buyer_name} || '';
150 my $buyer = length($name) ? $name : $email;
151 my $items = $r->{items_titles} || '(item info unavailable)';
152 my $count = $r->{item_count} || 0;
153 my $items_short = $items;
154 # Truncate long concat for the table; keep full text in a tooltip.
155 if (length($items_short) > 80) {
156 $items_short = substr($items_short, 0, 77) . '...';
157 }
158 my $when = $r->{paid_at} || '';
159 $when =~ s/T/ /; $when =~ s/\.\d+$//;
160 push @recent_sale_rows, {
161 date => _h($when),
162 buyer_safe => _h($buyer),
163 buyer_email => _h($email),
164 items_safe => _h($items_short),
165 items_full => _h($items),
166 item_count => $count,
167 amount => '$' . sprintf('%.2f', ($r->{total_amount_cents} || 0) / 100),
168 };
169}
170
171my $delta_pct = 0;
172if ($rev_prev30_cents > 0) {
173 $delta_pct = int((($rev_30d_cents - $rev_prev30_cents) / $rev_prev30_cents) * 100 + 0.5);
174}
175my $delta_dir_up = ($delta_pct > 0) ? 1 : 0;
176my $delta_dir_down = ($delta_pct < 0) ? 1 : 0;
177
178# 30-day daily series for the Overview + Channels tabs' KPI modal
179# mini-charts. Each point: {date, revenue_cents, revenue, orders}.
180my @daily_sales;
181if (($tab eq 'overview' || $tab eq 'channels') && $has_orders && $has_storefront) {
182 my %by_date;
183 my @rows = $db->db_readwrite_multiple($dbh, qq~
184 SELECT DATE(paid_at) AS d,
185 COALESCE(SUM(total_amount_cents),0) AS rev_cents,
186 COUNT(*) AS orders
187 FROM `${DB}`.orders
188 WHERE storefront_id IN ($sids_sql) AND status='paid'
189 AND paid_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
190 GROUP BY DATE(paid_at)
191 ~, $ENV{SCRIPT_NAME}, __LINE__);
192 foreach my $r (@rows) { $by_date{$r->{d} || ''} = $r; }
193 my $now = time;
194 for (my $i = 29; $i >= 0; $i--) {
195 my @t = localtime($now - $i * 86400);
196 my $ymd = sprintf('%04d-%02d-%02d', $t[5]+1900, $t[4]+1, $t[3]);
197 my $r = $by_date{$ymd} || {};
198 push @daily_sales, {
199 date => $ymd,
200 revenue_cents => $r->{rev_cents} || 0,
201 revenue => int(($r->{rev_cents} || 0) / 100),
202 orders => $r->{orders} || 0,
203 };
204 }
205}
206
207# 30-day daily NEW-BUYER series for the Buyers tab. Each point shows how
208# many distinct first-time buyer_emails purchased on that date.
209my @daily_buyers;
210if ($tab eq 'buyers' && $has_orders && $has_storefront) {
211 my %by_date;
212 my @rows = $db->db_readwrite_multiple($dbh, qq~
213 SELECT DATE(o1.paid_at) AS d, COUNT(DISTINCT o1.buyer_email) AS n
214 FROM `${DB}`.orders o1
215 WHERE o1.storefront_id IN ($sids_sql) AND o1.status='paid'
216 AND o1.paid_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
217 AND NOT EXISTS (
218 SELECT 1 FROM `${DB}`.orders o2
219 WHERE o2.buyer_email = o1.buyer_email
220 AND o2.storefront_id IN ($sids_sql)
221 AND o2.status='paid'
222 AND o2.paid_at < o1.paid_at
223 )
224 GROUP BY DATE(o1.paid_at)
225 ~, $ENV{SCRIPT_NAME}, __LINE__);
226 foreach my $r (@rows) { $by_date{$r->{d} || ''} = $r; }
227 my $now = time;
228 for (my $i = 29; $i >= 0; $i--) {
229 my @t = localtime($now - $i * 86400);
230 my $ymd = sprintf('%04d-%02d-%02d', $t[5]+1900, $t[4]+1, $t[3]);
231 my $r = $by_date{$ymd} || {};
232 push @daily_buyers, {
233 date => $ymd,
234 new_buyers => $r->{n} || 0,
235 };
236 }
237}
238
239#=================================================================
240# CHANNELS tab data
241#=================================================================
242my @channels;
243if ($tab eq 'channels' && $has_storefront) {
244 # The seller's own webstls.com storefront(s) -- direct sales.
245 my $own_rev = 0;
246 my $own_orders = 0;
247 if ($has_orders) {
248 $own_rev = _n(qq~SELECT COALESCE(SUM(total_amount_cents),0) AS n
249 FROM `${DB}`.orders WHERE storefront_id IN ($sids_sql)
250 AND status='paid' AND paid_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)~);
251 $own_orders = _n(qq~SELECT COUNT(*) AS n FROM `${DB}`.orders
252 WHERE storefront_id IN ($sids_sql) AND status='paid'
253 AND paid_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)~);
254 }
255 push @channels, {
256 slug => 'webstls',
257 name => 'WebSTLs storefront',
258 is_live => 1,
259 is_awaiting => 0,
260 revenue => '$' . sprintf('%.2f', $own_rev / 100),
261 revenue_cents=> $own_rev,
262 orders => $own_orders,
263 fee_pct => '0%',
264 icon => 'W',
265 kpi_detail => _kpi_detail_json(
266 'WebSTLs storefront . last 30 days',
267 'Direct sales through your branded storefront on WebSTLs. These are the orders where the buyer found you on a webstls.com subdomain (or your custom domain) and checked out through your own storefront.',
268 qq~<code>SUM(orders.total_amount_cents) WHERE storefront_id IN (your storefronts) AND status=&#39;paid&#39; AND paid_at &gt;= NOW() - INTERVAL 30 DAY</code>. Storefront orders carry a 0% platform fee -- 100% of the revenue is yours.~,
269 'The highest-margin channel because there is no marketplace cut. Storefront traffic also lets you A/B test prices and pages, so push your most profitable models here when you can. Use the Traffic Statistics page to see how visitors are finding your storefront.',
270 { seriesGlobal => 'SALES_DAILY_SERIES', seriesKey => 'revenue', chartColor => '#22c55e', chartLabel => 'Revenue ($)', chartTitle => 'Daily revenue, last 30 days' },
271 ),
272 };
273
274 # Connected marketplace accounts. Per-platform real revenue data
275 # arrives once each adapter implements a sales fetcher; for now
276 # the connection row is enough to show the channel exists.
277 my @mp_accts;
278 my $has_mp_tbl = _n(qq~SELECT COUNT(*) AS n FROM information_schema.tables
279 WHERE table_schema='$DB' AND table_name='marketplace_accounts'~);
280 if ($has_mp_tbl) {
281 @mp_accts = $db->db_readwrite_multiple($dbh, qq~
282 SELECT platform, status, account_handle
283 FROM `${DB}`.marketplace_accounts
284 WHERE user_id='$uid'
285 ~, $ENV{SCRIPT_NAME}, __LINE__);
286 }
287 foreach my $a (@mp_accts) {
288 my $p = $mp->by_slug($a->{platform});
289 my $name = ($p && $p->{name}) || ucfirst($a->{platform} || 'Marketplace');
290 my $fees = ($p && $p->{fees}) || 'platform fees vary by listing type';
291 my $sells = ($p && $p->{sells}) || '';
292 push @channels, {
293 slug => $a->{platform},
294 name => $name,
295 is_live => 0,
296 is_awaiting => 1,
297 revenue => '$0.00',
298 revenue_cents=> 0,
299 orders => 0,
300 fee_pct => ($p && $p->{fees}) || '',
301 icon => ($p && $p->{icon}) || uc(substr($a->{platform} || 'M', 0, 1)),
302 kpi_detail => _kpi_detail_json(
303 $name . ' . awaiting sales adapter',
304 'You have connected a ' . $name . ' account, but the sales-data adapter for this marketplace is not yet pulling order history. The card appears here so you can see the connection is live; the revenue and order counts will populate once the adapter ships. ' . ($sells ? 'This platform: ' . $sells : ''),
305 'Connection lives in <code>marketplace_accounts WHERE platform=&#39;' . $a->{platform} . '&#39; AND user_id=(you)</code>. Once the per-platform sales fetcher is implemented, paid orders will flow into the <code>orders</code> table tagged with this platform and start populating the revenue here.',
306 'Each marketplace has a different fee structure (' . $fees . '), so per-channel revenue and AOV vary. Comparing AOV across channels in this tab tells you which marketplaces drive your highest-value buyers.',
307 ),
308 };
309 }
310}
311my $channel_count = scalar @channels;
312
313#=================================================================
314# BUYERS tab data (lifted from old audience.cgi, lightly cleaned)
315#=================================================================
316my $total_buyers = 0;
317my $repeat_buyers = 0;
318my $new_30d_buyers = 0;
319my $seg_recent = 0;
320my $seg_lapsed = 0;
321my @top_buyers;
322if ($tab eq 'buyers' && $has_orders && $has_storefront) {
323 $total_buyers = _n(qq~SELECT COUNT(DISTINCT buyer_email) AS n
324 FROM `${DB}`.orders WHERE storefront_id IN ($sids_sql) AND status='paid'~);
325 $repeat_buyers = _n(qq~SELECT COUNT(*) AS n FROM (
326 SELECT buyer_email FROM `${DB}`.orders
327 WHERE storefront_id IN ($sids_sql) AND status='paid'
328 GROUP BY buyer_email HAVING COUNT(*) >= 3
329 ) t~);
330 $new_30d_buyers = _n(qq~SELECT COUNT(DISTINCT buyer_email) AS n
331 FROM `${DB}`.orders o1
332 WHERE storefront_id IN ($sids_sql) AND status='paid'
333 AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
334 AND NOT EXISTS (SELECT 1 FROM `${DB}`.orders o2
335 WHERE o2.buyer_email=o1.buyer_email
336 AND o2.storefront_id IN ($sids_sql)
337 AND o2.status='paid'
338 AND o2.created_at < DATE_SUB(NOW(), INTERVAL 30 DAY))~);
339 $seg_recent = _n(qq~SELECT COUNT(DISTINCT buyer_email) AS n
340 FROM `${DB}`.orders WHERE storefront_id IN ($sids_sql) AND status='paid'
341 AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)~);
342 $seg_lapsed = _n(qq~SELECT COUNT(*) AS n FROM (
343 SELECT buyer_email, MAX(created_at) AS last_seen
344 FROM `${DB}`.orders WHERE storefront_id IN ($sids_sql) AND status='paid'
345 GROUP BY buyer_email HAVING last_seen < DATE_SUB(NOW(), INTERVAL 90 DAY)
346 ) t~);
347 @top_buyers = $db->db_readwrite_multiple($dbh, qq~
348 SELECT buyer_email, COUNT(*) AS orders,
349 SUM(total_amount_cents) AS ltv_cents,
350 MAX(created_at) AS last_seen
351 FROM `${DB}`.orders
352 WHERE storefront_id IN ($sids_sql) AND status='paid'
353 GROUP BY buyer_email
354 ORDER BY ltv_cents DESC
355 LIMIT 20
356 ~, $ENV{SCRIPT_NAME}, __LINE__);
357 foreach my $b (@top_buyers) {
358 my $email = $b->{buyer_email} || '';
359 my $local = $email; $local =~ s/\@.*$//;
360 my @parts = split /[._\-+]/, $local;
361 my $init = uc(substr($parts[0] || $local, 0, 1));
362 $init .= uc(substr($parts[1] || '', 0, 1)) if @parts > 1;
363 $b->{initials} = $init || '?';
364 $b->{email_safe} = _h($email);
365 $b->{ltv} = '$' . sprintf('%.2f', ($b->{ltv_cents} || 0) / 100);
366 $b->{last_seen_pretty} = $b->{last_seen} || '-';
367 }
368}
369
370#=================================================================
371# PRODUCTS tab data (top-selling by revenue + units across orders)
372#=================================================================
373my @top_products;
374if ($tab eq 'products' && $has_storefront && $has_order_items) {
375 @top_products = $db->db_readwrite_multiple($dbh, qq~
376 SELECT m.id, m.title, m.slug, m.hero_image_url,
377 COUNT(DISTINCT oi.order_id) AS orders,
378 SUM(oi.quantity) AS units,
379 SUM(oi.price_paid_cents * oi.quantity) AS revenue_cents
380 FROM `${DB}`.order_items oi
381 JOIN `${DB}`.orders o ON o.id = oi.order_id
382 JOIN `${DB}`.models m ON m.id = oi.model_id
383 WHERE o.storefront_id IN ($sids_sql) AND o.status='paid'
384 GROUP BY m.id, m.title, m.slug, m.hero_image_url
385 ORDER BY revenue_cents DESC
386 LIMIT 20
387 ~, $ENV{SCRIPT_NAME}, __LINE__);
388 foreach my $p (@top_products) {
389 $p->{title_safe} = _h($p->{title} || 'Untitled');
390 $p->{revenue} = '$' . sprintf('%.2f', ($p->{revenue_cents} || 0) / 100);
391 $p->{hero} = $p->{hero_image_url} || '/assets/no-cover.svg';
392 }
393}
394
395# Tax tab data -- per-jurisdiction tax collected on this seller's
396# orders. Visible-only to the seller; the platform handles
397# collection + remittance under the marketplace-facilitator model.
398my @tax_breakdown_rows;
399my ($tax_total_cents, $tax_total_orders) = (0, 0);
400my $seller_tax_mode = 'platform_collects';
401if ($has_storefront && $tab eq 'tax') {
402 # Guarded -- order_tax_lines may not exist yet on un-migrated DBs.
403 my $col = $db->db_readwrite($dbh, qq~
404 SELECT COUNT(*) AS n FROM information_schema.tables
405 WHERE table_schema='$DB' AND table_name='order_tax_lines'
406 ~, $ENV{SCRIPT_NAME}, __LINE__);
407 if ($col && $col->{n}) {
408 my @br = $db->db_readwrite_multiple($dbh, qq~
409 SELECT otl.country_iso2, otl.region_code, otl.jurisdiction_label,
410 otl.tax_type, otl.rate_pct,
411 COUNT(DISTINCT otl.order_id) AS n_orders,
412 SUM(otl.taxable_amount_cents) AS taxable_cents,
413 SUM(otl.tax_amount_cents) AS tax_cents
414 FROM `${DB}`.order_tax_lines otl
415 JOIN `${DB}`.orders o ON o.id = otl.order_id
416 WHERE o.status='paid'
417 AND o.storefront_id IN ($sids_sql)
418 GROUP BY otl.country_iso2, otl.region_code, otl.jurisdiction_label, otl.tax_type, otl.rate_pct
419 ORDER BY tax_cents DESC
420 ~, $ENV{SCRIPT_NAME}, __LINE__);
421 foreach my $r (@br) {
422 $tax_total_cents += ($r->{tax_cents} || 0);
423 $tax_total_orders += ($r->{n_orders} || 0);
424 push @tax_breakdown_rows, {
425 country => _h($r->{country_iso2}),
426 region => _h($r->{region_code} || ''),
427 label => _h($r->{jurisdiction_label}),
428 type => _h(uc $r->{tax_type}),
429 rate => sprintf('%.2f', $r->{rate_pct}),
430 orders => $r->{n_orders} || 0,
431 taxable => '$' . sprintf('%.2f', ($r->{taxable_cents} || 0) / 100),
432 tax => '$' . sprintf('%.2f', ($r->{tax_cents} || 0) / 100),
433 };
434 }
435 }
436 # Pull this storefront's tax_mode for the banner.
437 my $sf_mode_row = $db->db_readwrite($dbh, qq~
438 SELECT tax_mode FROM `${DB}`.storefronts WHERE id='$sids[0]' LIMIT 1
439 ~, $ENV{SCRIPT_NAME}, __LINE__) if @sids;
440 $seller_tax_mode = ($sf_mode_row && $sf_mode_row->{tax_mode}) || 'platform_collects';
441}
442
443$db->db_disconnect($dbh);
444
445#=================================================================
446# Build template vars
447#=================================================================
448my $tvars = {
449 tab_overview => ($tab eq 'overview') ? 1 : 0,
450 tab_channels => ($tab eq 'channels') ? 1 : 0,
451 tab_buyers => ($tab eq 'buyers') ? 1 : 0,
452 tab_products => ($tab eq 'products') ? 1 : 0,
453 tab_tax => ($tab eq 'tax') ? 1 : 0,
454
455 has_storefront => $has_storefront,
456 has_orders => $has_orders,
457
458 # Overview
459 recent_sales => \@recent_sale_rows,
460 has_recent_sales => scalar(@recent_sale_rows) ? 1 : 0,
461 rev_today => '$' . sprintf('%.2f', $rev_today_cents / 100),
462 rev_mtd => '$' . sprintf('%.2f', $rev_mtd_cents / 100),
463 rev_30d => '$' . sprintf('%.2f', $rev_30d_cents / 100),
464 aov => '$' . sprintf('%.2f', $aov_cents / 100),
465 orders_today => $orders_today,
466 orders_mtd => $orders_mtd,
467 orders_30d => $orders_30d,
468 delta_pct => abs($delta_pct),
469 delta_dir_up => $delta_dir_up,
470 delta_dir_down => $delta_dir_down,
471 has_delta => ($rev_prev30_cents > 0) ? 1 : 0,
472
473 # Channels
474 channels => \@channels,
475 channel_count => $channel_count,
476 has_channels => $channel_count ? 1 : 0,
477
478 # Buyers
479 total_buyers => $total_buyers,
480 new_30d_buyers => $new_30d_buyers,
481 repeat_buyers => $repeat_buyers,
482 seg_recent => $seg_recent,
483 seg_lapsed => $seg_lapsed,
484 top_buyers => \@top_buyers,
485 has_top_buyers => scalar(@top_buyers) ? 1 : 0,
486
487 # Products
488 top_products => \@top_products,
489 has_top_products=> scalar(@top_products) ? 1 : 0,
490
491 # Daily series feeding the KPI-modal area charts
492 daily_sales_json => _to_json_array(\@daily_sales),
493 has_daily_sales => scalar(@daily_sales) ? 1 : 0,
494 daily_buyers_json => _to_json_array(\@daily_buyers),
495 has_daily_buyers => scalar(@daily_buyers) ? 1 : 0,
496
497 # Tax
498 tax_rows => \@tax_breakdown_rows,
499 has_tax_rows => scalar(@tax_breakdown_rows) ? 1 : 0,
500 tax_total => '$' . sprintf('%.2f', $tax_total_cents / 100),
501 tax_total_orders => $tax_total_orders,
502 seller_tax_mode => _h($seller_tax_mode),
503 mode_is_platform => ($seller_tax_mode eq 'platform_collects') ? 1 : 0,
504 mode_is_self => ($seller_tax_mode eq 'seller_handles') ? 1 : 0,
505};
506
507print "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";
508
509my $body = join('', $tfile->template('webstls_sales_reports.html', $tvars, $userinfo));
510
511$wrap->render({
512 userinfo => $userinfo,
513 page_key => 'sales_reports',
514 title => 'Sales reports',
515 body => $body,
516 extra_js => ['/assets/javascript/graphs.js'],
517});
518exit;
519
520# Builds a JSON string for the data-kpi-detail attribute. The attribute
521# is single-quoted in the template, so we strip single quotes from values
522# and replace " with the HTML escape so it survives both JSON parsing and
523# attribute boundary. Used by graphs.js wireKpiCards() to open a modal.
524# The optional 5th arg is a hashref of chart fields (seriesGlobal,
525# seriesKey, chartColor, chartLabel, chartTitle) merged into the JSON.
526sub _kpi_detail_json {
527 my ($eyebrow, $description, $methodology, $interpretation, $chart) = @_;
528 my @pairs_in = (
529 [ 'eyebrow', $eyebrow ],
530 [ 'description', $description ],
531 [ 'methodology', $methodology ],
532 [ 'interpretation', $interpretation ],
533 );
534 if (ref($chart) eq 'HASH') {
535 foreach my $k (qw(seriesGlobal seriesKey chartColor chartLabel chartTitle)) {
536 push @pairs_in, [ $k, $chart->{$k} ] if defined $chart->{$k};
537 }
538 }
539 my @parts;
540 foreach my $pair (@pairs_in) {
541 my $v = defined $pair->[1] ? $pair->[1] : '';
542 $v =~ s/\\/\\\\/g;
543 $v =~ s/"/&quot;/g; # HTML-escape so it survives the attribute
544 $v =~ s/'/&#39;/g;
545 $v =~ s/\r?\n/ /g;
546 push @parts, '"' . $pair->[0] . '":"' . $v . '"';
547 }
548 return '{' . join(',', @parts) . '}';
549}
550
551# JSON encoder for the daily-series payloads. Matches the helper used
552# in admin_visitors.cgi / traffic_reports.cgi -- emits an array of flat
553# objects, safe for inline <script> insertion (the </ sequence is escaped).
554sub _to_json_array {
555 my ($aref) = @_;
556 $aref ||= [];
557 my @out;
558 foreach my $row (@$aref) {
559 my @pairs;
560 foreach my $k (sort keys %$row) {
561 push @pairs, _json_str($k) . ':' . _json_value($row->{$k});
562 }
563 push @out, '{' . join(',', @pairs) . '}';
564 }
565 return '[' . join(',', @out) . ']';
566}
567sub _json_value {
568 my ($v) = @_;
569 return 'null' unless defined $v;
570 if (ref($v) eq '') {
571 if ($v =~ /^-?(?:\d+\.?\d*|\.\d+)$/) { return $v + 0; }
572 return _json_str($v);
573 }
574 return 'null';
575}
576sub _json_str {
577 my ($s) = @_;
578 $s = '' unless defined $s;
579 $s =~ s/\\/\\\\/g; $s =~ s/"/\\"/g;
580 $s =~ s/\r/\\r/g; $s =~ s/\n/\\n/g; $s =~ s/\t/\\t/g;
581 $s =~ s|</|<\\/|g;
582 return '"' . $s . '"';
583}
584
585sub _h {
586 my $s = shift; $s = '' unless defined $s;
587 $s =~ s/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/g;
588 $s =~ s/"/&quot;/g; $s =~ s/'/&#39;/g;
589 return $s;
590}
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help