added on local at 2026-07-01 21:47:33
| 1 | #!/usr/bin/perl | |
| 2 | #====================================================================== | |
| 3 | # RePricer -- Models management page. | |
| 4 | # | |
| 5 | # Lists every model the logged-in user owns, with controls for: | |
| 6 | # - visible on storefront (toggle storefront_listings.visible) | |
| 7 | # - reorder (storefront_listings.sort_order, via move_up/move_down) | |
| 8 | # - feature (promote to sort_order = MIN - 1) | |
| 9 | # - edit (links to /upload_model.cgi?model_id=N -- upload_model.cgi | |
| 10 | # serves the edit form when model_id is set; /edit_model.cgi 302s | |
| 11 | # to the same URL for legacy compatibility) | |
| 12 | # | |
| 13 | # Writes happen via /update_listing.cgi (per-row actions). | |
| 14 | #====================================================================== | |
| 15 | use strict; | |
| 16 | use warnings; | |
| 17 | ||
| 18 | use lib '/var/www/vhosts/3dshawn.com/repricer.3dshawn.com'; | |
| 19 | use CGI; | |
| 20 | use MODS::Template; | |
| 21 | use MODS::DBConnect; | |
| 22 | use MODS::Login; | |
| 23 | use MODS::RePricer::Config; | |
| 24 | use MODS::RePricer::Wrapper; | |
| 25 | ||
| 26 | my $q = CGI->new; | |
| 27 | my $form = $q->Vars; | |
| 28 | my $tfile = MODS::Template->new; | |
| 29 | my $db = MODS::DBConnect->new; | |
| 30 | my $auth = MODS::Login->new; | |
| 31 | my $config = MODS::RePricer::Config->new; | |
| 32 | my $wrap = MODS::RePricer::Wrapper->new; | |
| 33 | my $DB = $config->settings('database_name'); | |
| 34 | ||
| 35 | $| = 1; | |
| 36 | ||
| 37 | my $userinfo = $auth->login_verify(); | |
| 38 | if (!$userinfo) { | |
| 39 | print "Status: 302 Found\nLocation: /login.cgi\n\n"; | |
| 40 | exit; | |
| 41 | } | |
| 42 | require MODS::RePricer::Permissions; | |
| 43 | MODS::RePricer::Permissions->new->require_feature($userinfo, 'view_models'); | |
| 44 | ||
| 45 | my $uid = $userinfo->{user_id}; | |
| 46 | $uid =~ s/[^0-9]//g; | |
| 47 | ||
| 48 | my $dbh = $db->db_connect(); | |
| 49 | ||
| 50 | # The user's storefront -- needed to LEFT JOIN listing state. A creator | |
| 51 | # without a storefront yet still sees their models (just no listing | |
| 52 | # controls). | |
| 53 | my $store = $db->db_readwrite($dbh, | |
| 54 | qq~SELECT id FROM `${DB}`.storefronts WHERE user_id='$uid' ORDER BY id LIMIT 1~, | |
| 55 | $ENV{SCRIPT_NAME}, __LINE__); | |
| 56 | my $sid = ($store && $store->{id}) ? $store->{id} : 0; | |
| 57 | ||
| 58 | # Filter buckets the user can switch between. URL: /models.cgi?filter=NAME. | |
| 59 | # Each shows a different slice of the user's models. | |
| 60 | my $filter = lc($form->{filter} || 'all'); | |
| 61 | $filter =~ s/[^a-z]//g; | |
| 62 | my %VALID_FILTERS = map { $_ => 1 } qw(all drafts published hidden trash); | |
| 63 | $filter = 'all' unless $VALID_FILTERS{$filter}; | |
| 64 | ||
| 65 | # Pull every model owned by the user (including removed -- the | |
| 66 | # in-template counts and the "trash" filter need to see them too). | |
| 67 | # LEFT JOIN so models that haven't been listed yet still appear. | |
| 68 | my $sql = qq~ | |
| 69 | SELECT m.id, m.title, m.tagline, m.base_price_cents, m.currency, | |
| 70 | m.status, m.visibility, m.hero_image_pos, | |
| 71 | m.category, m.created_at, | |
| 72 | sl.visible AS listing_visible, sl.sort_order AS listing_sort, | |
| 73 | COALESCE(NULLIF(m.hero_image_url, ''), | |
| 74 | (SELECT CONCAT('/img.cgi?m=', mi.id) | |
| 75 | FROM `${DB}`.model_images mi | |
| 76 | WHERE mi.model_id = m.id | |
| 77 | ORDER BY mi.is_primary DESC, mi.sort_order ASC, mi.id ASC | |
| 78 | LIMIT 1)) AS hero_image_url | |
| 79 | FROM `${DB}`.models m | |
| 80 | LEFT JOIN `${DB}`.storefront_listings sl | |
| 81 | ON sl.model_id = m.id AND sl.storefront_id = '$sid' | |
| 82 | WHERE m.user_id = '$uid' AND m.purged_at IS NULL | |
| 83 | ORDER BY | |
| 84 | CASE WHEN m.status = 'removed' THEN 2 | |
| 85 | WHEN sl.visible = 1 THEN 0 | |
| 86 | ELSE 1 END, | |
| 87 | sl.sort_order ASC, | |
| 88 | m.created_at DESC | |
| 89 | LIMIT 500 | |
| 90 | ~; | |
| 91 | my @rows = $db->db_readwrite_multiple($dbh, $sql, $ENV{SCRIPT_NAME}, __LINE__); | |
| 92 | ||
| 93 | my @all; | |
| 94 | my %counts = ( | |
| 95 | total => 0, # everything except removed | |
| 96 | drafts => 0, # status='draft' | |
| 97 | published => 0, # status='published' AND listing.visible=1 | |
| 98 | hidden => 0, # has listing row AND visible=0 | |
| 99 | trash => 0, # status='removed' | |
| 100 | ); | |
| 101 | foreach my $r (@rows) { | |
| 102 | my $cents = $r->{base_price_cents} || 0; | |
| 103 | my $price = '$' . sprintf('%.2f', $cents / 100); | |
| 104 | # No more rotation through the /assets/images/<N>.webp stock-render | |
| 105 | # placeholders -- they read as "this is your actual model" and | |
| 106 | # confused users on marketplace-imported drafts. Single clean | |
| 107 | # "NO COVER YET" SVG fills in until the seller uploads a real image. | |
| 108 | my $hero = $r->{hero_image_url} || '/assets/no-cover.svg'; | |
| 109 | my $pos = $r->{hero_image_pos} || '50% 50%'; | |
| 110 | my $is_visible = (defined $r->{listing_visible} && $r->{listing_visible}) ? 1 : 0; | |
| 111 | my $is_listed = defined $r->{listing_visible} ? 1 : 0; | |
| 112 | my $is_removed = ($r->{status} eq 'removed') ? 1 : 0; | |
| 113 | my $is_draft = ($r->{status} eq 'draft') ? 1 : 0; | |
| 114 | my $is_pub_visible = ($r->{status} eq 'published' && $is_visible) ? 1 : 0; | |
| 115 | my $is_listed_hidden = ($is_listed && !$is_visible && !$is_removed) ? 1 : 0; | |
| 116 | ||
| 117 | if ($is_removed) { | |
| 118 | $counts{trash}++; | |
| 119 | } else { | |
| 120 | $counts{total}++; | |
| 121 | $counts{drafts}++ if $is_draft; | |
| 122 | $counts{published}++ if $is_pub_visible; | |
| 123 | $counts{hidden}++ if $is_listed_hidden; | |
| 124 | } | |
| 125 | ||
| 126 | push @all, { | |
| 127 | id => $r->{id}, | |
| 128 | title => $r->{title}, | |
| 129 | tagline => $r->{tagline} || '', | |
| 130 | price => $price, | |
| 131 | category => $r->{category} || '', | |
| 132 | status => $r->{status}, | |
| 133 | is_draft => $is_draft, | |
| 134 | is_published => ($r->{status} eq 'published') ? 1 : 0, | |
| 135 | is_removed => $is_removed, | |
| 136 | hero_image => $hero, | |
| 137 | hero_pos => $pos, | |
| 138 | is_visible => $is_visible, | |
| 139 | is_listed => $is_listed, | |
| 140 | is_pub_visible => $is_pub_visible, | |
| 141 | is_listed_hidden => $is_listed_hidden, | |
| 142 | sort_order => defined $r->{listing_sort} ? $r->{listing_sort} : '', | |
| 143 | status_pill => _status_pill($r->{status}, $is_visible, $is_listed), | |
| 144 | vis_label => $is_visible ? 'On storefront' : ($is_listed ? 'Hidden' : 'Not listed'), | |
| 145 | publish_label => ($r->{status} eq 'draft') ? 'Publish' : 'Unpublish', | |
| 146 | }; | |
| 147 | } | |
| 148 | ||
| 149 | # Apply the chosen filter for what to render in the grid. | |
| 150 | my @models; | |
| 151 | foreach my $m (@all) { | |
| 152 | if ($filter eq 'trash') { push @models, $m if $m->{is_removed}; } | |
| 153 | elsif ($filter eq 'drafts') { push @models, $m if $m->{is_draft} && !$m->{is_removed}; } | |
| 154 | elsif ($filter eq 'published') { push @models, $m if $m->{is_pub_visible}; } | |
| 155 | elsif ($filter eq 'hidden') { push @models, $m if $m->{is_listed_hidden}; } | |
| 156 | else { push @models, $m unless $m->{is_removed}; } | |
| 157 | } | |
| 158 | ||
| 159 | $db->db_disconnect($dbh); | |
| 160 | ||
| 161 | my $tvars = { | |
| 162 | user_id => $uid, | |
| 163 | has_models => scalar(@models) ? 1 : 0, | |
| 164 | model_count => $counts{total}, | |
| 165 | drafts_count => $counts{drafts}, | |
| 166 | listed_count => $counts{published}, | |
| 167 | hidden_count => $counts{hidden}, | |
| 168 | trash_count => $counts{trash}, | |
| 169 | has_trash => $counts{trash} ? 1 : 0, | |
| 170 | models => \@models, | |
| 171 | filter => $filter, | |
| 172 | is_filter_all => ($filter eq 'all') ? 1 : 0, | |
| 173 | is_filter_drafts => ($filter eq 'drafts') ? 1 : 0, | |
| 174 | is_filter_pub => ($filter eq 'published') ? 1 : 0, | |
| 175 | is_filter_hidden => ($filter eq 'hidden') ? 1 : 0, | |
| 176 | is_filter_trash => ($filter eq 'trash') ? 1 : 0, | |
| 177 | filter_label => _filter_label($filter, scalar(@models)), | |
| 178 | }; | |
| 179 | ||
| 180 | print "Content-Type: text/html; charset=utf-8\nCache-Control: no-cache\n\n"; | |
| 181 | ||
| 182 | my $body = join('', $tfile->template('repricer_models.html', $tvars, $userinfo)); | |
| 183 | ||
| 184 | $wrap->render({ | |
| 185 | userinfo => $userinfo, | |
| 186 | page_key => 'models', | |
| 187 | title => 'Models', | |
| 188 | body => $body, | |
| 189 | }); | |
| 190 | exit; | |
| 191 | ||
| 192 | ||
| 193 | sub _filter_label { | |
| 194 | my ($f, $n) = @_; | |
| 195 | return sprintf('%d total', $n) if $f eq 'all'; | |
| 196 | return sprintf('%d drafts', $n) if $f eq 'drafts'; | |
| 197 | return sprintf('%d on storefront', $n) if $f eq 'published'; | |
| 198 | return sprintf('%d hidden', $n) if $f eq 'hidden'; | |
| 199 | return sprintf('%d in trash', $n) if $f eq 'trash'; | |
| 200 | return sprintf('%d', $n); | |
| 201 | } | |
| 202 | ||
| 203 | sub _status_pill { | |
| 204 | my ($status, $is_visible, $is_listed) = @_; | |
| 205 | if ($status eq 'draft') { | |
| 206 | return '<span class="pill" style="background:rgba(245,158,11,0.18);color:#fbbf24">Draft</span>'; | |
| 207 | } | |
| 208 | if ($status eq 'archived') { | |
| 209 | return '<span class="pill" style="background:rgba(148,163,184,0.18);color:#94a3b8">Archived</span>'; | |
| 210 | } | |
| 211 | if ($status eq 'unlisted') { | |
| 212 | return '<span class="pill" style="background:rgba(59,130,246,0.18);color:#60a5fa">Unlisted</span>'; | |
| 213 | } | |
| 214 | # published | |
| 215 | if ($is_visible) { | |
| 216 | return '<span class="pill success">On storefront</span>'; | |
| 217 | } | |
| 218 | if ($is_listed) { | |
| 219 | return '<span class="pill" style="background:rgba(124,58,237,0.18);color:#a78bfa">Hidden</span>'; | |
| 220 | } | |
| 221 | return '<span class="pill" style="background:rgba(148,163,184,0.18);color:#94a3b8">Not listed</span>'; | |
| 222 | } |