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

O Operator
Diff

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

added on local at 2026-07-01 22:09:32

Added
+365
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to d5d265a9ae09
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# ShopCart - Dashboard (in-app landing)
4#
5# First stop for every creator after sign-in. Shows:
6# - Personalized hero with current plan badge + member-since
7# - Quick stats (models, visitors, sales, days active)
8# - Getting-Started checklist with live "done / todo" detection
9# (uploads, storefront setup, marketplace connect, SEO, first sale)
10# - "Your plan" tile listing every feature currently included
11# - "Unlock more" grid of features locked behind higher tiers
12#
13# URL convention: /dashboard.cgi is the canonical post-login landing
14# (matches PTMatrix). The page body lives in TEMPLATES/shopcart_dashboard.html.
15# Every counter query is guarded against the table not existing yet so
16# the page still renders cleanly on a fresh install before the user has
17# run every ALTER.
18#======================================================================
19use strict;
20use warnings;
21
22use lib '/var/www/vhosts/3dshawn.com/shop.3dshawn.com';
23use CGI;
24use MODS::Template;
25use MODS::DBConnect;
26use MODS::Login;
27use MODS::ShopCart::Config;
28use MODS::ShopCart::Wrapper;
29use MODS::ShopCart::Billing;
30use MODS::ShopCart::Storage;
31
32my $q = CGI->new;
33my $auth = MODS::Login->new;
34my $wrap = MODS::ShopCart::Wrapper->new;
35my $tfile = MODS::Template->new;
36my $db = MODS::DBConnect->new;
37my $cfg = MODS::ShopCart::Config->new;
38my $bill = MODS::ShopCart::Billing->new;
39my $DB = $cfg->settings('database_name');
40
41$|=1;
42my $userinfo = $auth->login_verify();
43if (!$userinfo) {
44 print "Status: 302 Found\nLocation: /login.cgi\n\n";
45 exit;
46}
47my $uid = $userinfo->{user_id};
48$uid =~ s/[^0-9]//g;
49
50my $dbh = $db->db_connect();
51
52# Tiny helper: returns 1 if a table exists in the active schema. Used
53# to guard every counter query against running pre-ALTER.
54sub _has_table {
55 my ($name) = @_;
56 my $r = $db->db_readwrite($dbh, qq~
57 SELECT COUNT(*) AS n FROM information_schema.tables
58 WHERE table_schema='$DB' AND table_name='$name'
59 ~, $ENV{SCRIPT_NAME}, __LINE__);
60 return ($r && $r->{n}) ? 1 : 0;
61}
62
63sub _count {
64 my ($sql) = @_;
65 my $r = $db->db_readwrite($dbh, $sql, $ENV{SCRIPT_NAME}, __LINE__);
66 return ($r && defined $r->{n}) ? $r->{n} : 0;
67}
68
69# ---- Getting-Started signals ----------------------------------------
70# Each step boils down to "does the user have at least one row of X".
71# A new account starts with all zeros and ticks them off as they go.
72# All counters aggregate across ALL of the user's storefronts (not
73# just the first one) -- some sellers run multiple branded stores and
74# we want their totals to reflect everything they ship.
75
76my $models_count = _has_table('models') ? _count(qq~
77 SELECT COUNT(*) AS n
78 FROM `${DB}`.models
79 WHERE user_id='$uid'
80 AND purged_at IS NULL
81 AND status NOT IN ('archived','removed')
82~) : 0;
83
84my $mkt_count = _has_table('marketplace_accounts') ? _count(qq~
85 SELECT COUNT(*) AS n
86 FROM `${DB}`.marketplace_accounts
87 WHERE user_id='$uid'
88 AND status='connected'
89~) : 0;
90
91my @storefronts;
92if (_has_table('storefronts')) {
93 @storefronts = $db->db_readwrite_multiple($dbh, qq~
94 SELECT id, name, tagline, about_text,
95 hero_image_1, hero_image_2, hero_image_3,
96 seo_title, seo_description, seo_og_image, subdomain
97 FROM `${DB}`.storefronts
98 WHERE user_id='$uid'
99 ORDER BY id ASC
100 ~, $ENV{SCRIPT_NAME}, __LINE__);
101}
102my $storefront_row = $storefronts[0] || {};
103my @store_ids = map { $_->{id} } @storefronts;
104my $store_id_list = join(',', map { my $v = $_; $v =~ s/[^0-9]//g; $v } @store_ids);
105
106my $orders_count = 0;
107if (length $store_id_list && _has_table('orders')) {
108 $orders_count = _count(qq~
109 SELECT COUNT(*) AS n
110 FROM `${DB}`.orders
111 WHERE storefront_id IN ($store_id_list)
112 AND status='paid'
113 ~);
114}
115
116my $visitors_week = 0;
117if (length $store_id_list && _has_table('tracking_sessions')) {
118 $visitors_week = _count(qq~
119 SELECT COUNT(DISTINCT id) AS n
120 FROM `${DB}`.tracking_sessions
121 WHERE storefront_id IN ($store_id_list)
122 AND first_seen_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
123 ~);
124}
125
126my $storefront_setup = 0;
127foreach my $s (@storefronts) {
128 if (length($s->{name} || '') ||
129 length($s->{tagline} || '') ||
130 length($s->{about_text} || '') ||
131 length($s->{hero_image_1} || '') ||
132 length($s->{hero_image_2} || '') ||
133 length($s->{hero_image_3} || '')) {
134 $storefront_setup = 1;
135 last;
136 }
137}
138
139my $seo_done = 0;
140foreach my $s (@storefronts) {
141 if (length($s->{seo_title} || '') && length($s->{seo_description} || '')) {
142 $seo_done = 1;
143 last;
144 }
145}
146
147my $member_days = 0;
148if (_has_table('users')) {
149 my $r = $db->db_readwrite($dbh, qq~
150 SELECT DATEDIFF(NOW(), created_at) AS n
151 FROM `${DB}`.users
152 WHERE id='$uid'
153 ~, $ENV{SCRIPT_NAME}, __LINE__);
154 $member_days = ($r && defined $r->{n}) ? $r->{n} : 0;
155}
156
157# ---- Plan + entitlements --------------------------------------------
158my @ent = $bill->user_feature_entitlements($db, $dbh, $DB, $uid);
159my $sub = $bill->active_subscription($db, $dbh, $DB, $uid);
160my $plan_name = $sub->{display_name} || ucfirst($sub->{tier} || $userinfo->{plan_tier} || 'Free');
161my $plan_tier = lc($sub->{tier} || $userinfo->{plan_tier} || 'free');
162my $plan_price = '';
163my $plan_cadence = '';
164if ($sub->{price_cents}) {
165 $plan_price = $bill->fmt_money($sub->{price_cents}, $sub->{currency} || 'USD');
166 $plan_cadence = $bill->fmt_cadence($sub->{cadence});
167}
168
169my $is_owner_account = !($userinfo->{owner_user_id});
170my (@features_have, @features_locked);
171foreach my $f (@ent) {
172 my $row = {
173 name => $f->{name},
174 blurb => $f->{blurb},
175 key => $f->{key},
176 };
177 if ($f->{included} || $is_owner_account) {
178 push @features_have, $row;
179 } else {
180 push @features_locked, $row;
181 }
182}
183
184# ---- Storage usage gauge --------------------------------------------
185# Phase 2B: per-file upload caps are now the visible tier differentiator,
186# but total storage is still tracked + shown to the seller so they can
187# see how much room they have. Storage.pm->limits_for_user returns the
188# tier-resolved hard cap + the denormalized used_bytes. Falls back
189# gracefully (zero usage on a fresh account) so the card still renders.
190my $store_obj = MODS::ShopCart::Storage->new;
191my $store_limits = $store_obj->limits_for_user($db, $dbh, $DB, $uid);
192my $store_used = ($store_limits && $store_limits->{used_bytes}) ? $store_limits->{used_bytes} : 0;
193my $store_cap = ($store_limits && $store_limits->{hard_cap_bytes}) ? $store_limits->{hard_cap_bytes} : 0;
194my $store_tier = ($store_limits && $store_limits->{tier}) ? $store_limits->{tier} : ($plan_tier || 'free');
195my $store_pct = $store_cap > 0 ? int(($store_used * 100) / $store_cap) : 0;
196$store_pct = 100 if $store_pct > 100;
197
198# Tier-name display matches the seller's actual plan label (falls back
199# to ucfirst of the tier slug if the billing row has no display_name).
200my $store_tier_label = $plan_name || ucfirst($store_tier);
201
202# Tint thresholds: green <50, yellow 50-79, red >=80. Mirrors the
203# Storage.pm soft-alert threshold (80%) so the gauge turns red exactly
204# when the alert worker would email the seller.
205my $store_bar_color = '#22c55e'; # green
206$store_bar_color = '#f59e0b' if $store_pct >= 50; # yellow
207$store_bar_color = '#ef4444' if $store_pct >= 80; # red (warning)
208
209# "Upgrade for more" only nudges sellers who are actually filling up.
210# Suppress below 60% so the card stays informational, not pushy.
211my $store_show_upgrade = ($store_pct > 60 && $store_tier ne 'business') ? 1 : 0;
212
213# Pretty-print bytes -> "4.2 GB used" / "of 5 GB". Matches Storage.pm's
214# user-facing reject message style so the whole product speaks one
215# vocabulary about size.
216sub _fmt_size_dash {
217 my ($n) = @_;
218 $n = 0 unless defined $n;
219 $n += 0;
220 if ($n >= 1073741824) { return sprintf('%.1f GB', $n / 1073741824); }
221 if ($n >= 1048576) { return sprintf('%.1f MB', $n / 1048576); }
222 if ($n >= 1024) { return sprintf('%.1f KB', $n / 1024); }
223 return "$n B";
224}
225# Cap display rounds to integer GB so "5 GB" reads cleanly on the Free
226# plan rather than "5.0 GB"; same for whole-GB Pro/Business caps.
227sub _fmt_cap_dash {
228 my ($n) = @_;
229 $n = 0 unless defined $n;
230 $n += 0;
231 if ($n >= 1073741824) {
232 my $g = $n / 1073741824;
233 return ($g == int($g)) ? (int($g) . ' GB') : sprintf('%.1f GB', $g);
234 }
235 if ($n >= 1048576) {
236 my $m = $n / 1048576;
237 return ($m == int($m)) ? (int($m) . ' MB') : sprintf('%.1f MB', $m);
238 }
239 return _fmt_size_dash($n);
240}
241my $store_used_display = _fmt_size_dash($store_used);
242$store_used_display = '0 GB' if $store_used == 0; # avoid "0 B" on fresh accounts
243my $store_cap_display = _fmt_cap_dash($store_cap);
244
245$db->db_disconnect($dbh);
246
247# ---- Checklist rows --------------------------------------------------
248my @checklist = (
249 {
250 done => $models_count > 0 ? 1 : 0,
251 title => 'Upload your first model',
252 why => $models_count > 0
253 ? "$models_count uploaded \x{2014} you can keep adding from the upload page any time."
254 : 'Drop in an STL/OBJ/3MF. Once it is uploaded, you can list it, push it to marketplaces, or feature it on your storefront.',
255 cta_label => $models_count > 0 ? 'Upload another' : 'Upload now',
256 cta_href => '/upload_model.cgi',
257 },
258 {
259 done => $storefront_setup,
260 title => 'Set up your storefront',
261 why => $storefront_setup
262 ? "Live at " . ($storefront_row->{subdomain} || '') . ".shop.3dshawn.com \x{2014} customize layout, hero, and copy any time."
263 : 'Pick a layout, drop in a hero image, name your studio. Buyers land here from every channel.',
264 cta_label => $storefront_setup ? 'Customize' : 'Set up storefront',
265 cta_href => '/storefront.cgi',
266 },
267 {
268 done => $mkt_count > 0 ? 1 : 0,
269 title => 'Connect a marketplace',
270 why => $mkt_count > 0
271 ? "$mkt_count account" . ($mkt_count == 1 ? '' : 's') . " connected. Push models to Etsy, Cults3D, MyMiniFactory, Patreon, and more from one console."
272 : 'Link Etsy, Cults3D, MyMiniFactory, Patreon, or Gumroad. Publish your catalog to every channel from one place.',
273 cta_label => $mkt_count > 0 ? 'Manage marketplaces' : 'Connect',
274 cta_href => '/marketplaces.cgi',
275 },
276 {
277 done => $seo_done,
278 title => 'Polish your SEO',
279 why => $seo_done
280 ? 'Storefront SEO title + description are filled in. Search engines and social previews will use them.'
281 : 'Set your storefront title, description, social-share preview. Search engines and marketplace algorithms both read these.',
282 cta_label => $seo_done ? 'Tune SEO' : 'Open SEO',
283 cta_href => '/storefront_seo.cgi',
284 },
285 {
286 done => $orders_count > 0 ? 1 : 0,
287 title => 'Make your first sale',
288 why => $orders_count > 0
289 ? "$orders_count paid order" . ($orders_count == 1 ? '' : 's') . " so far. Sales Reports tracks every marketplace as new sales roll in."
290 : 'Once your storefront is live and your models are published, traffic plus a working checkout closes the loop. See your sales as they happen in Sales Reports.',
291 cta_label => $orders_count > 0 ? 'See sales' : 'View sales reports',
292 cta_href => '/sales_reports.cgi',
293 },
294);
295
296my $checklist_done = scalar grep { $_->{done} } @checklist;
297my $checklist_total = scalar @checklist;
298my $checklist_pct = $checklist_total ? int(($checklist_done / $checklist_total) * 100) : 0;
299
300# ---- Header personalization -----------------------------------------
301my $first_name = $userinfo->{display_name} || $userinfo->{email} || 'creator';
302$first_name =~ s/\s.*//;
303$first_name =~ s/\@.*//;
304
305my $member_days_label;
306if ($member_days <= 0) { $member_days_label = 'Just joined'; }
307elsif ($member_days == 1) { $member_days_label = '1 day with us'; }
308elsif ($member_days < 30) { $member_days_label = "$member_days days with us"; }
309elsif ($member_days < 365) { my $m = int($member_days / 30); $member_days_label = "$m " . ($m == 1 ? 'month' : 'months') . ' with us'; }
310else { my $y = int($member_days / 365); $member_days_label = "$y " . ($y == 1 ? 'year' : 'years') . ' with us'; }
311
312my $tvars = {
313 user_id => $uid,
314 first_name => $first_name,
315
316 plan_name => $plan_name,
317 plan_tier => $plan_tier,
318 plan_price => $plan_price,
319 plan_cadence => $plan_cadence,
320 has_paid_plan => ($plan_tier && $plan_tier ne 'free') ? 1 : 0,
321 is_free_plan => (!$plan_tier || $plan_tier eq 'free') ? 1 : 0,
322
323 member_days => $member_days,
324 member_days_label => $member_days_label,
325
326 models_count => $models_count,
327 orders_count => $orders_count,
328 mkt_count => $mkt_count,
329 visitors_week => $visitors_week,
330
331 checklist => \@checklist,
332 checklist_done => $checklist_done,
333 checklist_total => $checklist_total,
334 checklist_pct => $checklist_pct,
335 checklist_complete => ($checklist_done == $checklist_total) ? 1 : 0,
336
337 features_have => \@features_have,
338 features_have_count => scalar(@features_have),
339 has_features_have => scalar(@features_have) ? 1 : 0,
340
341 features_locked => \@features_locked,
342 features_locked_count => scalar(@features_locked),
343 has_features_locked => scalar(@features_locked) ? 1 : 0,
344
345 # Storage usage gauge (Phase 2B). Renders inline next to the
346 # "Your plan" tile so sellers see how much room they have without
347 # leaving the dashboard.
348 storage_used_display => $store_used_display,
349 storage_cap_display => $store_cap_display,
350 storage_tier_label => $store_tier_label,
351 storage_pct => $store_pct,
352 storage_bar_color => $store_bar_color,
353 storage_show_upgrade => $store_show_upgrade,
354};
355
356print "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";
357
358my $body = join('', $tfile->template('shopcart_dashboard.html', $tvars, $userinfo));
359
360$wrap->render({
361 userinfo => $userinfo,
362 page_key => 'dashboard',
363 title => 'Dashboard',
364 body => $body,
365});