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

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

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

Added
+355
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to c6037c1e7631
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 - Studio Dashboard
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# Page body lives in TEMPLATES/webstls_welcome.html (legacy filename --
14# the URL moved from /welcome.cgi -> /dashboard.cgi to match the
15# PTMatrix URL convention but the template name was kept to limit
16# the blast radius). Every counter query is guarded against the table
17# not existing yet so the page still renders cleanly on a fresh
18# install before the user has run every ALTER.
19#======================================================================
20use strict;
21use warnings;
22
23use lib '/var/www/vhosts/webstls.com/httpdocs';
24use CGI;
25use MODS::Template;
26use MODS::DBConnect;
27use MODS::Login;
28use MODS::WebSTLs::Config;
29use MODS::WebSTLs::Wrapper;
30use MODS::WebSTLs::Billing;
31use MODS::WebSTLs::Storage;
32
33my $q = CGI->new;
34my $auth = MODS::Login->new;
35my $wrap = MODS::WebSTLs::Wrapper->new;
36my $tfile = MODS::Template->new;
37my $db = MODS::DBConnect->new;
38my $cfg = MODS::WebSTLs::Config->new;
39my $bill = MODS::WebSTLs::Billing->new;
40my $DB = $cfg->settings('database_name');
41
42$|=1;
43my $userinfo = $auth->login_verify();
44if (!$userinfo) {
45 print "Status: 302 Found\nLocation: /login.cgi\n\n";
46 exit;
47}
48my $uid = $userinfo->{user_id};
49$uid =~ s/[^0-9]//g;
50
51my $dbh = $db->db_connect();
52
53# Tiny helper: returns 1 if a table exists in the active schema. Used
54# to guard every counter query against running pre-ALTER.
55sub _has_table {
56 my ($name) = @_;
57 my $r = $db->db_readwrite($dbh, qq~
58 SELECT COUNT(*) AS n FROM information_schema.tables
59 WHERE table_schema='$DB' AND table_name='$name'
60 ~, $ENV{SCRIPT_NAME}, __LINE__);
61 return ($r && $r->{n}) ? 1 : 0;
62}
63
64sub _count {
65 my ($sql) = @_;
66 my $r = $db->db_readwrite($dbh, $sql, $ENV{SCRIPT_NAME}, __LINE__);
67 return ($r && defined $r->{n}) ? $r->{n} : 0;
68}
69
70# ---- Getting-Started signals ----------------------------------------
71# Each step boils down to "does the user have at least one row of X".
72# A new account starts with all zeros and ticks them off as they go.
73# All counters aggregate across ALL of the user's storefronts (not
74# just the first one) -- some sellers run multiple branded stores and
75# we want their totals to reflect everything they ship.
76
77# Models in the catalog. "Live + drafts" excludes archived / removed
78# rows even though they're still in the table -- those are gone from
79# the user's perspective.
80my $models_count = _has_table('models') ? _count(qq~
81 SELECT COUNT(*) AS n
82 FROM `${DB}`.models
83 WHERE user_id='$uid'
84 AND purged_at IS NULL
85 AND status NOT IN ('archived','removed')
86~) : 0;
87
88# Marketplace connections. Only count rows that actually completed
89# their OAuth handshake -- disconnected / expired / error rows linger
90# in the table after revoked auth and shouldn't tick the step done.
91my $mkt_count = _has_table('marketplace_accounts') ? _count(qq~
92 SELECT COUNT(*) AS n
93 FROM `${DB}`.marketplace_accounts
94 WHERE user_id='$uid'
95 AND status='connected'
96~) : 0;
97
98# Pull every storefront the user owns. Used for the multi-storefront
99# aggregations (orders, visitors, SEO) and for the "set up your
100# storefront" check.
101my @storefronts;
102if (_has_table('storefronts')) {
103 @storefronts = $db->db_readwrite_multiple($dbh, qq~
104 SELECT id, name, tagline, about_text,
105 hero_image_1, hero_image_2, hero_image_3,
106 seo_title, seo_description, seo_og_image, subdomain
107 FROM `${DB}`.storefronts
108 WHERE user_id='$uid'
109 ORDER BY id ASC
110 ~, $ENV{SCRIPT_NAME}, __LINE__);
111}
112my $storefront_row = $storefronts[0] || {};
113my @store_ids = map { $_->{id} } @storefronts;
114my $store_id_list = join(',', map { my $v = $_; $v =~ s/[^0-9]//g; $v } @store_ids);
115
116# Paid-order count across EVERY storefront the user owns.
117my $orders_count = 0;
118if (length $store_id_list && _has_table('orders')) {
119 $orders_count = _count(qq~
120 SELECT COUNT(*) AS n
121 FROM `${DB}`.orders
122 WHERE storefront_id IN ($store_id_list)
123 AND status='paid'
124 ~);
125}
126
127# Visitor sessions across every storefront, rolling 7-day window.
128my $visitors_week = 0;
129if (length $store_id_list && _has_table('tracking_sessions')) {
130 $visitors_week = _count(qq~
131 SELECT COUNT(DISTINCT id) AS n
132 FROM `${DB}`.tracking_sessions
133 WHERE storefront_id IN ($store_id_list)
134 AND first_seen_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
135 ~);
136}
137
138# Storefront "setup" = the user has filled in any meaningful
139# customization field on at least one of their storefronts. A bare
140# subdomain doesn't count (auto-assigned at signup), but a name,
141# tagline, about copy, OR any hero image does.
142my $storefront_setup = 0;
143foreach my $s (@storefronts) {
144 if (length($s->{name} || '') ||
145 length($s->{tagline} || '') ||
146 length($s->{about_text} || '') ||
147 length($s->{hero_image_1} || '') ||
148 length($s->{hero_image_2} || '') ||
149 length($s->{hero_image_3} || '')) {
150 $storefront_setup = 1;
151 last;
152 }
153}
154
155# SEO "polished" = at least one storefront has both a seo_title AND
156# a seo_description filled in. We require both because either one
157# alone leaves social previews / search results half-broken.
158my $seo_done = 0;
159foreach my $s (@storefronts) {
160 if (length($s->{seo_title} || '') && length($s->{seo_description} || '')) {
161 $seo_done = 1;
162 last;
163 }
164}
165
166# Days as a member -- pulls from users.created_at if available.
167my $member_days = 0;
168if (_has_table('users')) {
169 my $r = $db->db_readwrite($dbh, qq~
170 SELECT DATEDIFF(NOW(), created_at) AS n
171 FROM `${DB}`.users
172 WHERE id='$uid'
173 ~, $ENV{SCRIPT_NAME}, __LINE__);
174 $member_days = ($r && defined $r->{n}) ? $r->{n} : 0;
175}
176
177# ---- Plan + entitlements --------------------------------------------
178my @ent = $bill->user_feature_entitlements($db, $dbh, $DB, $uid);
179my $sub = $bill->active_subscription($db, $dbh, $DB, $uid);
180my $plan_name = $sub->{display_name} || ucfirst($sub->{tier} || $userinfo->{plan_tier} || 'Free');
181my $plan_tier = lc($sub->{tier} || $userinfo->{plan_tier} || 'free');
182my $plan_price = '';
183my $plan_cadence = '';
184if ($sub->{price_cents}) {
185 $plan_price = $bill->fmt_money($sub->{price_cents}, $sub->{currency} || 'USD');
186 $plan_cadence = $bill->fmt_cadence($sub->{cadence});
187}
188
189# Split entitlements into included / locked groups for the two panels.
190#
191# Subtlety: the runtime gate in MODS::WebSTLs::Permissions->has_feature
192# short-circuits to "yes" for account owners (anyone whose user row has
193# no permission_groups_id). That means a free-tier OWNER who tries to
194# use, say, /optimization.cgi actually loads it fine -- the
195# plan_features table is read by the dashboard card but not enforced.
196# Showing "LOCKED" badges in that case is misleading. So: for owners we
197# treat every feature in the catalog as available; only team members
198# (those with a permission_groups_id) see the locked badges, and even
199# then the lock reflects their role grants, not plan tier.
200my $is_owner_account = !($userinfo->{owner_user_id});
201my (@features_have, @features_locked);
202foreach my $f (@ent) {
203 my $row = {
204 name => $f->{name},
205 blurb => $f->{blurb},
206 key => $f->{key},
207 };
208 if ($f->{included} || $is_owner_account) {
209 push @features_have, $row;
210 } else {
211 push @features_locked, $row;
212 }
213}
214
215$db->db_disconnect($dbh);
216
217# ---- Checklist rows --------------------------------------------------
218# Each step: { done, title, why, cta_label, cta_href }
219my @checklist = (
220 {
221 done => $models_count > 0 ? 1 : 0,
222 title => 'Upload your first model',
223 why => $models_count > 0
224 ? "$models_count uploaded \x{2014} you can keep adding from the upload page any time."
225 : 'Drop in an STL/OBJ/3MF. Once it is uploaded, you can list it, push it to marketplaces, or feature it on your storefront.',
226 cta_label => $models_count > 0 ? 'Upload another' : 'Upload now',
227 cta_href => '/upload_model.cgi',
228 },
229 {
230 done => $storefront_setup,
231 title => 'Set up your storefront',
232 why => $storefront_setup
233 ? "Live at " . ($storefront_row->{subdomain} || '') . ".webstls.com \x{2014} customize layout, hero, and copy any time."
234 : 'Pick a layout, drop in a hero image, name your studio. Buyers land here from every channel.',
235 cta_label => $storefront_setup ? 'Customize' : 'Set up storefront',
236 cta_href => '/storefront.cgi',
237 },
238 {
239 done => $mkt_count > 0 ? 1 : 0,
240 title => 'Connect a marketplace',
241 why => $mkt_count > 0
242 ? "$mkt_count account" . ($mkt_count == 1 ? '' : 's') . " connected. Push models to Etsy, Cults3D, MyMiniFactory, Patreon, and more from one console."
243 : 'Link Etsy, Cults3D, MyMiniFactory, Patreon, or Gumroad. Publish your catalog to every channel from one place.',
244 cta_label => $mkt_count > 0 ? 'Manage marketplaces' : 'Connect',
245 cta_href => '/marketplaces.cgi',
246 },
247 {
248 done => $seo_done,
249 title => 'Polish your SEO',
250 why => $seo_done
251 ? 'Storefront SEO title + description are filled in. Search engines and social previews will use them.'
252 : 'Set your storefront title, description, social-share preview. Search engines and marketplace algorithms both read these.',
253 cta_label => $seo_done ? 'Tune SEO' : 'Open SEO',
254 cta_href => '/storefront_seo.cgi',
255 },
256 {
257 done => $orders_count > 0 ? 1 : 0,
258 title => 'Make your first sale',
259 why => $orders_count > 0
260 ? "$orders_count paid order" . ($orders_count == 1 ? '' : 's') . " so far. Sales Reports tracks every marketplace as new sales roll in."
261 : '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.',
262 cta_label => $orders_count > 0 ? 'See sales' : 'View sales reports',
263 cta_href => '/sales_reports.cgi',
264 },
265);
266
267my $checklist_done = scalar grep { $_->{done} } @checklist;
268my $checklist_total = scalar @checklist;
269my $checklist_pct = $checklist_total ? int(($checklist_done / $checklist_total) * 100) : 0;
270
271# ---- Header personalization -----------------------------------------
272my $first_name = $userinfo->{display_name} || $userinfo->{email} || 'creator';
273$first_name =~ s/\s.*//;
274$first_name =~ s/\@.*//;
275
276my $member_days_label;
277if ($member_days <= 0) { $member_days_label = 'Just joined'; }
278elsif ($member_days == 1) { $member_days_label = '1 day with us'; }
279elsif ($member_days < 30) { $member_days_label = "$member_days days with us"; }
280elsif ($member_days < 365) { my $m = int($member_days / 30); $member_days_label = "$m " . ($m == 1 ? 'month' : 'months') . ' with us'; }
281else { my $y = int($member_days / 365); $member_days_label = "$y " . ($y == 1 ? 'year' : 'years') . ' with us'; }
282
283my $tvars = {
284 user_id => $uid,
285 first_name => $first_name,
286
287 plan_name => $plan_name,
288 plan_tier => $plan_tier,
289 plan_price => $plan_price,
290 plan_cadence => $plan_cadence,
291 has_paid_plan => ($plan_tier && $plan_tier ne 'free') ? 1 : 0,
292 is_free_plan => (!$plan_tier || $plan_tier eq 'free') ? 1 : 0,
293
294 member_days => $member_days,
295 member_days_label => $member_days_label,
296
297 models_count => $models_count,
298 orders_count => $orders_count,
299 mkt_count => $mkt_count,
300 visitors_week => $visitors_week,
301
302 checklist => \@checklist,
303 checklist_done => $checklist_done,
304 checklist_total => $checklist_total,
305 checklist_pct => $checklist_pct,
306 checklist_complete => ($checklist_done == $checklist_total) ? 1 : 0,
307
308 features_have => \@features_have,
309 features_have_count => scalar(@features_have),
310 has_features_have => scalar(@features_have) ? 1 : 0,
311
312 features_locked => \@features_locked,
313 features_locked_count => scalar(@features_locked),
314 has_features_locked => scalar(@features_locked) ? 1 : 0,
315};
316
317# Storage gauge -- card the dashboard renders. Suppresses entirely when
318# the user is below 60% AND on the top tier (no nudge needed) but
319# always shows on free/starter/pro so creators see how much room they
320# have left before hitting the cap.
321{
322 my $storage = MODS::WebSTLs::Storage->new;
323 my $L = $storage->limits_for_user($db, $dbh, $DB, $uid);
324 my $bar_color = $L->{over_cap} ? '#ef4444'
325 : $L->{warn} ? '#f59e0b'
326 : '#22c55e';
327 # Show the gauge if (a) using >60% of cap OR (b) not on Studio
328 # (the top tier; Studio with low usage doesn't need the nudge).
329 my $show = ($L->{pct_of_cap} >= 60 || $L->{tier} ne 'studio') ? 1 : 0;
330 # Suggested next-tier upgrade label.
331 my %upgrade = (free => 'Starter', starter => 'Pro', pro => 'Studio', studio => '');
332 my $upgrade_to = $upgrade{ $L->{tier} } || '';
333
334 $tvars->{has_storage_gauge} = $show;
335 $tvars->{storage_used_human} = MODS::WebSTLs::Storage::human_size($L->{used_bytes});
336 $tvars->{storage_cap_human} = MODS::WebSTLs::Storage::human_size($L->{hard_cap_bytes});
337 $tvars->{storage_pct} = $L->{pct_of_cap};
338 $tvars->{storage_bar_color} = $bar_color;
339 $tvars->{storage_over} = $L->{over_cap} ? 1 : 0;
340 $tvars->{storage_warn} = $L->{warn} ? 1 : 0;
341 $tvars->{storage_upload_mb} = $L->{upload_max_mb};
342 $tvars->{storage_upgrade_to} = $upgrade_to;
343 $tvars->{has_upgrade_target} = length($upgrade_to) ? 1 : 0;
344}
345
346print "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";
347
348my $body = join('', $tfile->template('webstls_welcome.html', $tvars, $userinfo));
349
350$wrap->render({
351 userinfo => $userinfo,
352 page_key => 'dashboard',
353 title => 'Dashboard &middot; WebSTLs',
354 body => $body,
355});
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help