added on WebSTLs (webstls.com) at 2026-07-01 22:26:31
| 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 | #====================================================================== | |
| 20 | use strict; | |
| 21 | use warnings; | |
| 22 | ||
| 23 | use lib '/var/www/vhosts/webstls.com/httpdocs'; | |
| 24 | use CGI; | |
| 25 | use MODS::Template; | |
| 26 | use MODS::DBConnect; | |
| 27 | use MODS::Login; | |
| 28 | use MODS::WebSTLs::Config; | |
| 29 | use MODS::WebSTLs::Wrapper; | |
| 30 | use MODS::WebSTLs::Billing; | |
| 31 | use MODS::WebSTLs::Storage; | |
| 32 | ||
| 33 | my $q = CGI->new; | |
| 34 | my $auth = MODS::Login->new; | |
| 35 | my $wrap = MODS::WebSTLs::Wrapper->new; | |
| 36 | my $tfile = MODS::Template->new; | |
| 37 | my $db = MODS::DBConnect->new; | |
| 38 | my $cfg = MODS::WebSTLs::Config->new; | |
| 39 | my $bill = MODS::WebSTLs::Billing->new; | |
| 40 | my $DB = $cfg->settings('database_name'); | |
| 41 | ||
| 42 | $|=1; | |
| 43 | my $userinfo = $auth->login_verify(); | |
| 44 | if (!$userinfo) { | |
| 45 | print "Status: 302 Found\nLocation: /login.cgi\n\n"; | |
| 46 | exit; | |
| 47 | } | |
| 48 | my $uid = $userinfo->{user_id}; | |
| 49 | $uid =~ s/[^0-9]//g; | |
| 50 | ||
| 51 | my $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. | |
| 55 | sub _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 | ||
| 64 | sub _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. | |
| 80 | my $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. | |
| 91 | my $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. | |
| 101 | my @storefronts; | |
| 102 | if (_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 | } | |
| 112 | my $storefront_row = $storefronts[0] || {}; | |
| 113 | my @store_ids = map { $_->{id} } @storefronts; | |
| 114 | my $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. | |
| 117 | my $orders_count = 0; | |
| 118 | if (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. | |
| 128 | my $visitors_week = 0; | |
| 129 | if (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. | |
| 142 | my $storefront_setup = 0; | |
| 143 | foreach 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. | |
| 158 | my $seo_done = 0; | |
| 159 | foreach 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. | |
| 167 | my $member_days = 0; | |
| 168 | if (_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 -------------------------------------------- | |
| 178 | my @ent = $bill->user_feature_entitlements($db, $dbh, $DB, $uid); | |
| 179 | my $sub = $bill->active_subscription($db, $dbh, $DB, $uid); | |
| 180 | my $plan_name = $sub->{display_name} || ucfirst($sub->{tier} || $userinfo->{plan_tier} || 'Free'); | |
| 181 | my $plan_tier = lc($sub->{tier} || $userinfo->{plan_tier} || 'free'); | |
| 182 | my $plan_price = ''; | |
| 183 | my $plan_cadence = ''; | |
| 184 | if ($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. | |
| 200 | my $is_owner_account = !($userinfo->{owner_user_id}); | |
| 201 | my (@features_have, @features_locked); | |
| 202 | foreach 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 } | |
| 219 | my @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 | ||
| 267 | my $checklist_done = scalar grep { $_->{done} } @checklist; | |
| 268 | my $checklist_total = scalar @checklist; | |
| 269 | my $checklist_pct = $checklist_total ? int(($checklist_done / $checklist_total) * 100) : 0; | |
| 270 | ||
| 271 | # ---- Header personalization ----------------------------------------- | |
| 272 | my $first_name = $userinfo->{display_name} || $userinfo->{email} || 'creator'; | |
| 273 | $first_name =~ s/\s.*//; | |
| 274 | $first_name =~ s/\@.*//; | |
| 275 | ||
| 276 | my $member_days_label; | |
| 277 | if ($member_days <= 0) { $member_days_label = 'Just joined'; } | |
| 278 | elsif ($member_days == 1) { $member_days_label = '1 day with us'; } | |
| 279 | elsif ($member_days < 30) { $member_days_label = "$member_days days with us"; } | |
| 280 | elsif ($member_days < 365) { my $m = int($member_days / 30); $member_days_label = "$m " . ($m == 1 ? 'month' : 'months') . ' with us'; } | |
| 281 | else { my $y = int($member_days / 365); $member_days_label = "$y " . ($y == 1 ? 'year' : 'years') . ' with us'; } | |
| 282 | ||
| 283 | my $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 | ||
| 346 | 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"; | |
| 347 | ||
| 348 | my $body = join('', $tfile->template('webstls_welcome.html', $tvars, $userinfo)); | |
| 349 | ||
| 350 | $wrap->render({ | |
| 351 | userinfo => $userinfo, | |
| 352 | page_key => 'dashboard', | |
| 353 | title => 'Dashboard · WebSTLs', | |
| 354 | body => $body, | |
| 355 | }); |