Diff -- /var/www/vhosts/3dshawn.com/shop.3dshawn.com/dashboard.cgi
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
to d5d265a9ae09
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 | #====================================================================== | |
| 19 | use strict; | |
| 20 | use warnings; | |
| 21 | ||
| 22 | use lib '/var/www/vhosts/3dshawn.com/shop.3dshawn.com'; | |
| 23 | use CGI; | |
| 24 | use MODS::Template; | |
| 25 | use MODS::DBConnect; | |
| 26 | use MODS::Login; | |
| 27 | use MODS::ShopCart::Config; | |
| 28 | use MODS::ShopCart::Wrapper; | |
| 29 | use MODS::ShopCart::Billing; | |
| 30 | use MODS::ShopCart::Storage; | |
| 31 | ||
| 32 | my $q = CGI->new; | |
| 33 | my $auth = MODS::Login->new; | |
| 34 | my $wrap = MODS::ShopCart::Wrapper->new; | |
| 35 | my $tfile = MODS::Template->new; | |
| 36 | my $db = MODS::DBConnect->new; | |
| 37 | my $cfg = MODS::ShopCart::Config->new; | |
| 38 | my $bill = MODS::ShopCart::Billing->new; | |
| 39 | my $DB = $cfg->settings('database_name'); | |
| 40 | ||
| 41 | $|=1; | |
| 42 | my $userinfo = $auth->login_verify(); | |
| 43 | if (!$userinfo) { | |
| 44 | print "Status: 302 Found\nLocation: /login.cgi\n\n"; | |
| 45 | exit; | |
| 46 | } | |
| 47 | my $uid = $userinfo->{user_id}; | |
| 48 | $uid =~ s/[^0-9]//g; | |
| 49 | ||
| 50 | my $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. | |
| 54 | sub _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 | ||
| 63 | sub _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 | ||
| 76 | my $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 | ||
| 84 | my $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 | ||
| 91 | my @storefronts; | |
| 92 | if (_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 | } | |
| 102 | my $storefront_row = $storefronts[0] || {}; | |
| 103 | my @store_ids = map { $_->{id} } @storefronts; | |
| 104 | my $store_id_list = join(',', map { my $v = $_; $v =~ s/[^0-9]//g; $v } @store_ids); | |
| 105 | ||
| 106 | my $orders_count = 0; | |
| 107 | if (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 | ||
| 116 | my $visitors_week = 0; | |
| 117 | if (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 | ||
| 126 | my $storefront_setup = 0; | |
| 127 | foreach 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 | ||
| 139 | my $seo_done = 0; | |
| 140 | foreach my $s (@storefronts) { | |
| 141 | if (length($s->{seo_title} || '') && length($s->{seo_description} || '')) { | |
| 142 | $seo_done = 1; | |
| 143 | last; | |
| 144 | } | |
| 145 | } | |
| 146 | ||
| 147 | my $member_days = 0; | |
| 148 | if (_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 -------------------------------------------- | |
| 158 | my @ent = $bill->user_feature_entitlements($db, $dbh, $DB, $uid); | |
| 159 | my $sub = $bill->active_subscription($db, $dbh, $DB, $uid); | |
| 160 | my $plan_name = $sub->{display_name} || ucfirst($sub->{tier} || $userinfo->{plan_tier} || 'Free'); | |
| 161 | my $plan_tier = lc($sub->{tier} || $userinfo->{plan_tier} || 'free'); | |
| 162 | my $plan_price = ''; | |
| 163 | my $plan_cadence = ''; | |
| 164 | if ($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 | ||
| 169 | my $is_owner_account = !($userinfo->{owner_user_id}); | |
| 170 | my (@features_have, @features_locked); | |
| 171 | foreach 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. | |
| 190 | my $store_obj = MODS::ShopCart::Storage->new; | |
| 191 | my $store_limits = $store_obj->limits_for_user($db, $dbh, $DB, $uid); | |
| 192 | my $store_used = ($store_limits && $store_limits->{used_bytes}) ? $store_limits->{used_bytes} : 0; | |
| 193 | my $store_cap = ($store_limits && $store_limits->{hard_cap_bytes}) ? $store_limits->{hard_cap_bytes} : 0; | |
| 194 | my $store_tier = ($store_limits && $store_limits->{tier}) ? $store_limits->{tier} : ($plan_tier || 'free'); | |
| 195 | my $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). | |
| 200 | my $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. | |
| 205 | my $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. | |
| 211 | my $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. | |
| 216 | sub _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. | |
| 227 | sub _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 | } | |
| 241 | my $store_used_display = _fmt_size_dash($store_used); | |
| 242 | $store_used_display = '0 GB' if $store_used == 0; # avoid "0 B" on fresh accounts | |
| 243 | my $store_cap_display = _fmt_cap_dash($store_cap); | |
| 244 | ||
| 245 | $db->db_disconnect($dbh); | |
| 246 | ||
| 247 | # ---- Checklist rows -------------------------------------------------- | |
| 248 | my @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 | ||
| 296 | my $checklist_done = scalar grep { $_->{done} } @checklist; | |
| 297 | my $checklist_total = scalar @checklist; | |
| 298 | my $checklist_pct = $checklist_total ? int(($checklist_done / $checklist_total) * 100) : 0; | |
| 299 | ||
| 300 | # ---- Header personalization ----------------------------------------- | |
| 301 | my $first_name = $userinfo->{display_name} || $userinfo->{email} || 'creator'; | |
| 302 | $first_name =~ s/\s.*//; | |
| 303 | $first_name =~ s/\@.*//; | |
| 304 | ||
| 305 | my $member_days_label; | |
| 306 | if ($member_days <= 0) { $member_days_label = 'Just joined'; } | |
| 307 | elsif ($member_days == 1) { $member_days_label = '1 day with us'; } | |
| 308 | elsif ($member_days < 30) { $member_days_label = "$member_days days with us"; } | |
| 309 | elsif ($member_days < 365) { my $m = int($member_days / 30); $member_days_label = "$m " . ($m == 1 ? 'month' : 'months') . ' with us'; } | |
| 310 | else { my $y = int($member_days / 365); $member_days_label = "$y " . ($y == 1 ? 'year' : 'years') . ' with us'; } | |
| 311 | ||
| 312 | my $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 | ||
| 356 | 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"; | |
| 357 | ||
| 358 | my $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 | }); |