added on local at 2026-07-01 22:09:34
| 1 | <!DOCTYPE html> | |
| 2 | <html lang="en"> | |
| 3 | <head> | |
| 4 | <meta charset="UTF-8"> | |
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 6 | <title>ShopCart — Installation & Configuration</title> | |
| 7 | <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| 8 | <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| 9 | <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet"> | |
| 10 | <link rel="stylesheet" href="html/assets/css/site.css"> | |
| 11 | <style> | |
| 12 | /* ── Doc layout: 240px TOC + reading column ── */ | |
| 13 | .doc-shell { display: grid; grid-template-columns: 260px 1fr; max-width: 1280px; margin: 0 auto; gap: 40px; padding: 40px; } | |
| 14 | .doc-toc { | |
| 15 | position: sticky; top: 32px; align-self: start; | |
| 16 | max-height: calc(100vh - 64px); | |
| 17 | overflow-y: auto; | |
| 18 | background: var(--col-surface-1); | |
| 19 | border: 1px solid var(--col-border); | |
| 20 | border-radius: var(--radius-lg); | |
| 21 | padding: 22px; | |
| 22 | } | |
| 23 | .doc-toc h4 { font-size: 11px; font-weight: 700; letter-spacing: 1.8px; text-transform: uppercase; color: var(--col-text-3); margin-bottom: 14px; } | |
| 24 | .doc-toc ol { counter-reset: toc; padding-left: 0; } | |
| 25 | .doc-toc ol li { counter-increment: toc; list-style: none; margin: 4px 0; } | |
| 26 | .doc-toc ol li::before { content: counter(toc, decimal-leading-zero) "."; color: var(--col-text-dim); font-family: var(--font-mono); font-size: 11px; margin-right: 8px; } | |
| 27 | .doc-toc a { color: var(--col-text-2); font-size: 13px; transition: color var(--t-fast); } | |
| 28 | .doc-toc a:hover { color: var(--col-accent-bright); } | |
| 29 | ||
| 30 | .doc-body { min-width: 0; max-width: 880px; } | |
| 31 | .doc-hero { margin-bottom: 48px; } | |
| 32 | .doc-hero h1 { font-family: var(--font-display); font-size: 44px; font-weight: 700; letter-spacing: -1px; color: #fff; line-height: 1.05; } | |
| 33 | .doc-hero .lead { color: var(--col-text-2); font-size: 17px; margin-top: 14px; max-width: 640px; line-height: 1.65; } | |
| 34 | ||
| 35 | .doc-body section { margin-bottom: 56px; } | |
| 36 | .doc-body h2 { | |
| 37 | font-family: var(--font-display); | |
| 38 | font-size: 28px; font-weight: 700; | |
| 39 | color: #fff; letter-spacing: -0.5px; | |
| 40 | margin-bottom: 12px; | |
| 41 | padding-bottom: 12px; | |
| 42 | border-bottom: 1px solid var(--col-border); | |
| 43 | display: flex; align-items: baseline; gap: 14px; | |
| 44 | } | |
| 45 | .doc-body h2 .num { | |
| 46 | font-family: var(--font-mono); | |
| 47 | font-size: 13px; | |
| 48 | color: var(--col-accent-bright); | |
| 49 | font-weight: 600; | |
| 50 | letter-spacing: 1px; | |
| 51 | } | |
| 52 | .doc-body h3 { | |
| 53 | font-family: var(--font-display); | |
| 54 | font-size: 18px; font-weight: 600; | |
| 55 | color: var(--col-text); margin: 28px 0 10px; | |
| 56 | } | |
| 57 | .doc-body p { color: var(--col-text-2); line-height: 1.7; margin: 12px 0; } | |
| 58 | .doc-body p strong { color: #fff; font-weight: 600; } | |
| 59 | .doc-body ul, .doc-body ol { color: var(--col-text-2); padding-left: 22px; line-height: 1.7; } | |
| 60 | .doc-body li { margin: 6px 0; } | |
| 61 | .doc-body code { | |
| 62 | font-family: var(--font-mono); font-size: 12.5px; | |
| 63 | background: var(--col-surface-2); border: 1px solid var(--col-border); | |
| 64 | padding: 1px 7px; border-radius: 5px; | |
| 65 | color: var(--col-accent-bright); | |
| 66 | } | |
| 67 | .doc-body a { color: var(--col-accent-bright); } | |
| 68 | .doc-body a:hover { text-decoration: underline; } | |
| 69 | ||
| 70 | /* Code blocks */ | |
| 71 | pre { | |
| 72 | background: var(--col-surface-1); | |
| 73 | border: 1px solid var(--col-border); | |
| 74 | border-radius: var(--radius-md); | |
| 75 | padding: 16px 18px; | |
| 76 | overflow-x: auto; | |
| 77 | font-family: var(--font-mono); font-size: 12.5px; | |
| 78 | color: #d6e1f2; | |
| 79 | line-height: 1.6; | |
| 80 | margin: 16px 0; | |
| 81 | position: relative; | |
| 82 | } | |
| 83 | pre::before { | |
| 84 | content: attr(data-label); | |
| 85 | position: absolute; top: 8px; right: 12px; | |
| 86 | font-size: 9px; font-weight: 700; | |
| 87 | letter-spacing: 1.6px; text-transform: uppercase; | |
| 88 | color: var(--col-text-3); | |
| 89 | background: var(--col-surface-2); padding: 3px 8px; | |
| 90 | border-radius: 4px; border: 1px solid var(--col-border); | |
| 91 | } | |
| 92 | pre code { background: none; border: none; padding: 0; color: inherit; font-size: inherit; } | |
| 93 | pre .c { color: var(--col-text-3); font-style: italic; } /* comment */ | |
| 94 | pre .k { color: #c084fc; } /* keyword */ | |
| 95 | pre .s { color: #a3e635; } /* string */ | |
| 96 | pre .v { color: var(--col-cyan-bright); } /* var */ | |
| 97 | pre .o { color: var(--col-warning); } /* operator/path */ | |
| 98 | pre .n { color: #fbbf24; } /* number */ | |
| 99 | pre .p { color: var(--col-accent-bright); } /* prompt */ | |
| 100 | ||
| 101 | table { border-collapse: collapse; width: 100%; margin: 16px 0; font-size: 13px; } | |
| 102 | table th, table td { padding: 10px 14px; text-align: left; border-bottom: 1px solid var(--col-border); } | |
| 103 | table th { background: var(--col-surface-2); color: var(--col-text-3); font-weight: 600; font-size: 11px; letter-spacing: 1.2px; text-transform: uppercase; } | |
| 104 | table td { color: var(--col-text); } | |
| 105 | table td:first-child { font-family: var(--font-mono); color: var(--col-accent-bright); font-size: 12px; } | |
| 106 | ||
| 107 | /* Step badge */ | |
| 108 | .step-badge { | |
| 109 | display: inline-flex; align-items: center; gap: 8px; | |
| 110 | padding: 4px 12px; | |
| 111 | background: rgba(59,130,246,0.10); | |
| 112 | border: 1px solid rgba(59,130,246,0.3); | |
| 113 | border-radius: 999px; | |
| 114 | color: var(--col-accent-bright); | |
| 115 | font-size: 11px; | |
| 116 | font-weight: 700; | |
| 117 | letter-spacing: 1.5px; | |
| 118 | text-transform: uppercase; | |
| 119 | margin-bottom: 14px; | |
| 120 | } | |
| 121 | ||
| 122 | .check-list { list-style: none; padding: 0; } | |
| 123 | .check-list li { | |
| 124 | display: flex; align-items: flex-start; gap: 10px; | |
| 125 | padding: 8px 0; | |
| 126 | } | |
| 127 | .check-list li::before { | |
| 128 | content: ''; | |
| 129 | width: 20px; height: 20px; | |
| 130 | flex-shrink: 0; | |
| 131 | border-radius: 50%; | |
| 132 | background: rgba(34,197,94,0.18); | |
| 133 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2322c55e' stroke-width='3'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E"); | |
| 134 | background-size: 12px; | |
| 135 | background-position: center; | |
| 136 | background-repeat: no-repeat; | |
| 137 | margin-top: 2px; | |
| 138 | } | |
| 139 | ||
| 140 | .doc-foot { margin-top: 80px; padding-top: 32px; border-top: 1px solid var(--col-border); color: var(--col-text-3); font-size: 13px; } | |
| 141 | ||
| 142 | @media (max-width: 1000px) { | |
| 143 | .doc-shell { grid-template-columns: 1fr; padding: 24px; } | |
| 144 | .doc-toc { position: static; max-height: none; } | |
| 145 | } | |
| 146 | </style> | |
| 147 | </head> | |
| 148 | <body> | |
| 149 | <!-- Lightweight top brand strip (no full app shell needed for an install doc) --> | |
| 150 | <header style="border-bottom:1px solid var(--col-border);background:var(--col-bg-2);padding:18px 40px;display:flex;align-items:center;gap:14px"> | |
| 151 | <div class="brand-mark">S<span>C</span></div> | |
| 152 | <div class="brand-text"> | |
| 153 | <span class="brand-name">ShopCart</span> | |
| 154 | <span class="brand-sub">Installation Guide</span> | |
| 155 | </div> | |
| 156 | <span class="pill brand" style="margin-left:auto">shop.3dshawn.com</span> | |
| 157 | </header> | |
| 158 | ||
| 159 | <div class="doc-shell"> | |
| 160 | <!-- ───── Table of contents ───── --> | |
| 161 | <aside class="doc-toc"> | |
| 162 | <h4>Contents</h4> | |
| 163 | <ol> | |
| 164 | <li><a href="#requirements">Requirements</a></li> | |
| 165 | <li><a href="#layout">Project layout</a></li> | |
| 166 | <li><a href="#deploy">Deploy the files</a></li> | |
| 167 | <li><a href="#perl-modules">Install Perl modules</a></li> | |
| 168 | <li><a href="#database">Set up MySQL</a></li> | |
| 169 | <li><a href="#config">Configure the app</a></li> | |
| 170 | <li><a href="#apache">Apache / vhost</a></li> | |
| 171 | <li><a href="#permissions">File permissions</a></li> | |
| 172 | <li><a href="#smoke-test">Smoke test</a></li> | |
| 173 | <li><a href="#first-user">Create the first user</a></li> | |
| 174 | <li><a href="#email">Transactional email (optional)</a></li> | |
| 175 | <li><a href="#stripe">Stripe (optional, for billing)</a></li> | |
| 176 | <li><a href="#r2">R2 / B2 file storage (optional)</a></li> | |
| 177 | <li><a href="#troubleshooting">Troubleshooting</a></li> | |
| 178 | <li><a href="#flags">Feature flags & staged reveal</a></li> | |
| 179 | </ol> | |
| 180 | </aside> | |
| 181 | ||
| 182 | <!-- ───── Body ───── --> | |
| 183 | <main class="doc-body"> | |
| 184 | ||
| 185 | <div class="doc-hero"> | |
| 186 | <span class="step-badge"><span class="dot-status green"></span> Production install</span> | |
| 187 | <h1>ShopCart — Installation & Configuration</h1> | |
| 188 | <p class="lead">A step-by-step deploy guide for the unified ShopCart Creator Platform. Target: a Linux server hosting <code>shop.3dshawn.com</code> at <code>/var/www/vhosts/3dshawn.com/shop.3dshawn.com/</code>.</p> | |
| 189 | </div> | |
| 190 | ||
| 191 | <!-- 1. REQUIREMENTS --> | |
| 192 | <section id="requirements"> | |
| 193 | <h2><span class="num">01</span> Requirements</h2> | |
| 194 | <p>ShopCart is a modular monolith: Perl + MySQL on the back, vanilla HTML/CSS/JS on the front.</p> | |
| 195 | ||
| 196 | <table> | |
| 197 | <thead><tr><th>Component</th><th>Version</th><th>Notes</th></tr></thead> | |
| 198 | <tbody> | |
| 199 | <tr><td>Linux</td><td>any modern</td><td>tested on RHEL/CentOS, Debian/Ubuntu, AlmaLinux</td></tr> | |
| 200 | <tr><td>Apache</td><td>2.4+</td><td>with <code>mod_cgi</code> or <code>mod_cgid</code> enabled</td></tr> | |
| 201 | <tr><td>Perl</td><td>5.16+</td><td>5.30+ recommended</td></tr> | |
| 202 | <tr><td>MySQL</td><td>8.0+</td><td>or MariaDB 10.5+ — schema uses <code>JSON</code> & <code>INET6_ATON</code></td></tr> | |
| 203 | <tr><td>OpenSSL</td><td>1.1+</td><td>required for HTTPS termination at Apache (no Perl SSL modules needed)</td></tr> | |
| 204 | <tr><td>RAM</td><td>1 GB+</td><td>for a single-server pilot; scale per traffic</td></tr> | |
| 205 | <tr><td>Disk</td><td>10 GB+</td><td>excluding model file storage (use R2/B2 for that)</td></tr> | |
| 206 | </tbody> | |
| 207 | </table> | |
| 208 | ||
| 209 | <p>Optional but recommended: Redis (cache + background queue), Cloudflare in front (CDN + SSL for custom domains), <a href="https://www.cloudflare.com/products/r2/">Cloudflare R2</a> for model file storage.</p> | |
| 210 | </section> | |
| 211 | ||
| 212 | <!-- 2. LAYOUT --> | |
| 213 | <section id="layout"> | |
| 214 | <h2><span class="num">02</span> Project layout</h2> | |
| 215 | <p>After deploying you should see this structure under your vhost root:</p> | |
| 216 | ||
| 217 | <pre data-label="filesystem"><code><span class="o">/var/www/vhosts/3dshawn.com/shop.3dshawn.com/</span> | |
| 218 | ├── <span class="o">assets/</span> | |
| 219 | │ ├── css/site.css | |
| 220 | │ ├── javascript/site.js | |
| 221 | │ ├── javascript/graphs.js | |
| 222 | │ └── images/ <span class="c"># 39 reference webp images</span> | |
| 223 | ├── <span class="o">MODS/</span> | |
| 224 | │ ├── Config.pm <span class="c"># framework config (paths, db name)</span> | |
| 225 | │ ├── DBConnect.pm <span class="c"># MySQL DBI wrapper — fill in creds here</span> | |
| 226 | │ ├── Template.pm <span class="c"># [loop:] [if:] [$var] templating engine</span> | |
| 227 | │ ├── Login.pm <span class="c"># auth: sessions, argon2/PBKDF2 passwords</span> | |
| 228 | │ ├── ReadSetCookie.pm <span class="c"># cookie helper used by Login.pm</span> | |
| 229 | │ ├── RandCode.pm <span class="c"># alpha-numeric token generator</span> | |
| 230 | │ └── ShopCart/ | |
| 231 | │ ├── Config.pm <span class="c"># ShopCart settings (db name, brand, urls)</span> | |
| 232 | │ └── Wrapper.pm <span class="c"># sidebar+topbar shell renderer</span> | |
| 233 | ├── <span class="o">TEMPLATES/</span> | |
| 234 | │ ├── shopcart_wrapper.html <span class="c"># sidebar+topbar shell</span> | |
| 235 | │ ├── shopcart_marketing.html <span class="c"># logged-out shell</span> | |
| 236 | │ ├── shopcart_dashboard.html <span class="c"># page bodies, one per .cgi</span> | |
| 237 | │ ├── shopcart_index.html | |
| 238 | │ ├── shopcart_login.html | |
| 239 | │ └── ... (13 page bodies) | |
| 240 | ├── .htaccess <span class="c"># Apache config (Options +ExecCGI, FilesMatch, headers)</span> | |
| 241 | ├── index.cgi <span class="c"># landing page</span> | |
| 242 | ├── login.cgi signup.cgi logout.cgi | |
| 243 | ├── dashboard.cgi models.cgi upload.cgi | |
| 244 | ├── storefront.cgi analytics.cgi optimization.cgi | |
| 245 | ├── settings.cgi admin.cgi | |
| 246 | ├── messages.cgi audience.cgi | |
| 247 | └── (deploy artifacts only — no <code>db_schema.sql</code> here) | |
| 248 | ||
| 249 | <span class="c"># Plus two protective .htaccess files inside subdirectories:</span> | |
| 250 | MODS/.htaccess <span class="c"># Require all denied — keeps Perl source out of the web</span> | |
| 251 | TEMPLATES/.htaccess <span class="c"># Require all denied — same for template sources</span> | |
| 252 | ||
| 253 | <span class="c"># Lives ALONGSIDE html/ in the source bundle, not inside it:</span> | |
| 254 | ../db_schema.sql <span class="c"># MySQL schema — load once, then keep off the server</span> | |
| 255 | ../installation_instructions.html <span class="c"># this document — open locally, not deployed</span></code></pre> | |
| 256 | ||
| 257 | <p><strong>Two-file pattern.</strong> Each page is a thin <code>.cgi</code> handler (~1-3 KB) that verifies the session, populates <code>$tvars</code>, and asks <code>MODS::Template</code> to render its matching <code>TEMPLATES/shopcart_*.html</code> body. The body string is then handed to <code>MODS::ShopCart::Wrapper</code>, which renders the sidebar+topbar shell around it.</p> | |
| 258 | </section> | |
| 259 | ||
| 260 | <!-- 3. DEPLOY --> | |
| 261 | <section id="deploy"> | |
| 262 | <h2><span class="num">03</span> Deploy the files</h2> | |
| 263 | <p>Upload the contents of this <code>html/</code> folder directly into <code>/var/www/vhosts/3dshawn.com/shop.3dshawn.com/</code>. SSH+rsync is fastest:</p> | |
| 264 | ||
| 265 | <pre data-label="bash"><code><span class="p">$</span> <span class="k">rsync</span> -avz --delete \ | |
| 266 | ./html/ \ | |
| 267 | user@shop.3dshawn.com:/var/www/vhosts/3dshawn.com/shop.3dshawn.com/</code></pre> | |
| 268 | ||
| 269 | <p>Or via SCP / SFTP / cPanel File Manager — whatever you use. Make sure these files arrive intact:</p> | |
| 270 | <ul class="check-list"> | |
| 271 | <li>All <code>.cgi</code> files at the root</li> | |
| 272 | <li>The <code>MODS/</code> folder (with <code>ShopCart/</code> subfolder)</li> | |
| 273 | <li>The <code>TEMPLATES/</code> folder (every <code>shopcart_*.html</code>)</li> | |
| 274 | <li>The <code>assets/</code> folder</li> | |
| 275 | <li><strong>Three <code>.htaccess</code> files</strong> — one in the webroot, one in <code>MODS/</code>, one in <code>TEMPLATES/</code></li> | |
| 276 | </ul> | |
| 277 | ||
| 278 | <p style="margin-top:14px"><strong>Don't deploy <code>db_schema.sql</code> to the webroot.</strong> It lives alongside <code>html/</code> in the source bundle, not inside it. Upload it to a one-off location like <code>/tmp/db_schema.sql</code> for the schema-load step (§5), then delete it. The <code>.htaccess</code> blocks <code>*.sql</code> from being web-served if it ever ends up in the webroot, but keeping it out entirely is cleaner.</p> | |
| 279 | ||
| 280 | <div class="banner warning" style="margin-top:14px"> | |
| 281 | <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg> | |
| 282 | <div class="text-xs"><strong>WinSCP / FileZilla hide dotfiles by default.</strong> If <code>.htaccess</code> doesn't appear in your local file pane, toggle <em>Options → Preferences → Panels → Show hidden files</em> in WinSCP, or <em>Server → Force showing hidden files</em> in FileZilla. The site will 500 on every request without these three files.</div> | |
| 283 | </div> | |
| 284 | </section> | |
| 285 | ||
| 286 | <!-- 4. PERL MODULES --> | |
| 287 | <section id="perl-modules"> | |
| 288 | <h2><span class="num">04</span> Install Perl modules</h2> | |
| 289 | <p>ShopCart is deliberately light on CPAN. The minimum to run is <strong>three modules</strong> — everything else is optional with a built-in fallback.</p> | |
| 290 | ||
| 291 | <h3>Required (just to boot)</h3> | |
| 292 | <table> | |
| 293 | <thead><tr><th>Module</th><th>Used by</th><th>Why</th></tr></thead> | |
| 294 | <tbody> | |
| 295 | <tr><td>DBI</td><td>MODS/DBConnect.pm</td><td>generic MySQL connector</td></tr> | |
| 296 | <tr><td>DBD::mysql</td><td>MODS/DBConnect.pm</td><td>DBI driver for MySQL/MariaDB</td></tr> | |
| 297 | <tr><td>CGI</td><td>every .cgi</td><td>request parsing & cookie I/O</td></tr> | |
| 298 | </tbody> | |
| 299 | </table> | |
| 300 | ||
| 301 | <pre data-label="bash"><code><span class="p">#</span> <span class="c"># RHEL / Alma / Rocky / CentOS</span> | |
| 302 | <span class="p">#</span> <span class="k">dnf</span> install -y perl-DBI perl-DBD-MySQL perl-CGI | |
| 303 | ||
| 304 | <span class="p">#</span> <span class="c"># Debian / Ubuntu</span> | |
| 305 | <span class="p">#</span> <span class="k">apt-get</span> install -y libdbi-perl libdbd-mysql-perl libcgi-pm-perl | |
| 306 | ||
| 307 | <span class="p">#</span> <span class="c"># Or via cpanm — works on any distro</span> | |
| 308 | <span class="p">$</span> <span class="k">cpanm</span> DBI DBD::mysql CGI</code></pre> | |
| 309 | ||
| 310 | <h3>Optional — better passwords / faster sessions</h3> | |
| 311 | <p>These are nice to have but <strong>not required</strong>. <code>MODS/Login.pm</code> detects them at startup and uses them when present, otherwise falls back gracefully.</p> | |
| 312 | ||
| 313 | <table> | |
| 314 | <thead><tr><th>Module</th><th>What it gives you</th><th>Fallback if missing</th></tr></thead> | |
| 315 | <tbody> | |
| 316 | <tr><td>Crypt::Argon2</td><td>Argon2id — current best-practice password hash</td><td>Salted iterated SHA-256 (100,000 rounds) — uses only <code>Digest::SHA::sha256_hex</code> which has been in Perl forever</td></tr> | |
| 317 | <tr><td>Data::UUID</td><td>RFC 4122 v4 session tokens</td><td>Built-in random UUIDv4 generator (no external deps)</td></tr> | |
| 318 | </tbody> | |
| 319 | </table> | |
| 320 | ||
| 321 | <pre data-label="bash"><code><span class="p">$</span> <span class="k">cpanm</span> Crypt::Argon2 Data::UUID</code></pre> | |
| 322 | ||
| 323 | <p><strong>You can skip both</strong> and the site still runs end-to-end. Crypt::Argon2 is an XS module that needs a C compiler — locked-down shared hosting usually doesn't have one, in which case the iter-sha256 fallback kicks in. That fallback is plenty strong for most installs: 100k SHA-256 rounds with a 16-char random salt = 100ms+ per password verify, which rate-limits brute-force attempts naturally.</p> | |
| 324 | <p><strong>Important about Digest::SHA versions:</strong> the <code>pbkdf2_hex</code> export was added in Digest::SHA 5.65 (2014). Older Perl installs (RHEL 7, CentOS 7) ship with earlier Digest::SHA versions. Login.pm uses only <code>sha256_hex</code> — present in every Digest::SHA version since 2003 — so it works regardless of version.</p> | |
| 325 | <p>If you later install <code>Crypt::Argon2</code>, restart Apache. New signups will be hashed with Argon2id; existing accounts keep working with their iter-sha256 hash until they reset their password.</p> | |
| 326 | ||
| 327 | <h3>Not needed</h3> | |
| 328 | <p>Earlier scaffolds shipped with <code>JSON</code>, <code>LWP::UserAgent</code>, <code>IO::Socket::SSL</code>, <code>HTTP::Request</code>, and <code>Data::Dumper</code> — none of those are referenced by the ShopCart codebase. Install them later if you add Stripe webhooks or external API calls.</p> | |
| 329 | ||
| 330 | <h3>Verify</h3> | |
| 331 | <pre data-label="bash"><code><span class="p">$</span> <span class="k">perl</span> -MDBI -MDBD::mysql -MCGI -e <span class="s">'print "core deps ok\n"'</span> | |
| 332 | <span class="p">$</span> <span class="k">perl</span> -e <span class="s">'eval { require Crypt::Argon2 } and print "Argon2 ok (best)\n" or print "PBKDF2 fallback (fine)\n"'</span></code></pre> | |
| 333 | </section> | |
| 334 | ||
| 335 | <!-- 5. DATABASE --> | |
| 336 | <section id="database"> | |
| 337 | <h2><span class="num">05</span> Set up MySQL / MariaDB</h2> | |
| 338 | ||
| 339 | <div class="banner warning" style="margin-bottom:18px"> | |
| 340 | <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg> | |
| 341 | <div class="text-xs"><strong>Database names are case-sensitive on Linux MariaDB / MySQL.</strong> If Plesk creates your DB as <code>SHOPCART</code> (mixed case), then <code>SHOPCART</code> is a different database that doesn't exist. Whatever case you use when creating the DB must match exactly in <code>MODS/DBConnect.pm</code> and <code>MODS/ShopCart/Config.pm</code>.</div> | |
| 342 | </div> | |
| 343 | ||
| 344 | <h3>5.1 — Plesk users (recommended path)</h3> | |
| 345 | <p>In the Plesk panel: <strong>Websites & Domains → shop.3dshawn.com → Databases → Add Database</strong>. Plesk creates the database AND its dedicated user in one step, with proper grants. Pick a database name like <code>SHOPCART</code> (note the mixed case) — write it down exactly, you'll need it later.</p> | |
| 346 | <p>Plesk creates the user with a wildcard host (<code>'SHOPCART'@'%'</code>), which is correct for both local and remote app servers. You don't need to add a separate <code>@'localhost'</code> grant.</p> | |
| 347 | ||
| 348 | <h3>5.2 — Bare MariaDB users (non-Plesk)</h3> | |
| 349 | <p>If you're not on Plesk, create the database and user by hand. Use BACKTICKS around the DB name to lock in the exact case you intend:</p> | |
| 350 | ||
| 351 | <pre data-label="mysql"><code><span class="p">mysql></span> <span class="k">CREATE DATABASE</span> `SHOPCART` <span class="k">CHARACTER SET</span> utf8mb4; | |
| 352 | ||
| 353 | <span class="p">mysql></span> <span class="k">CREATE USER</span> <span class="s">'shopcart'@'localhost'</span> <span class="k">IDENTIFIED BY</span> <span class="s">'CHANGE_ME_STRONG_PASSWORD'</span>; | |
| 354 | ||
| 355 | <span class="p">mysql></span> <span class="k">GRANT</span> SELECT, INSERT, UPDATE, DELETE, | |
| 356 | CREATE, ALTER, INDEX, REFERENCES, EXECUTE, | |
| 357 | TRIGGER | |
| 358 | <span class="k">ON</span> `SHOPCART`.* <span class="k">TO</span> <span class="s">'shopcart'@'localhost'</span>; | |
| 359 | ||
| 360 | <span class="p">mysql></span> <span class="k">FLUSH PRIVILEGES</span>;</code></pre> | |
| 361 | ||
| 362 | <p><code>TRIGGER</code> is in there so the schema's BEFORE INSERT / BEFORE UPDATE triggers will fire from the app's connection. Without it, INSERTs into tables that need triggers will fail.</p> | |
| 363 | ||
| 364 | <h3>5.3 — Load the schema</h3> | |
| 365 | <pre data-label="bash"><code><span class="c"># Upload db_schema.sql to a one-off location first (NOT the webroot):</span> | |
| 366 | <span class="p">$</span> <span class="k">scp</span> ./db_schema.sql user@shop.3dshawn.com:/tmp/ | |
| 367 | ||
| 368 | <span class="c"># Then load it:</span> | |
| 369 | <span class="p">$</span> <span class="k">mysql</span> -u shopcart -p SHOPCART < /tmp/db_schema.sql | |
| 370 | ||
| 371 | <span class="c"># Clean up — schema load is one-shot:</span> | |
| 372 | <span class="p">$</span> <span class="k">rm</span> /tmp/db_schema.sql</code></pre> | |
| 373 | ||
| 374 | <p>This creates 70 tables, 39 triggers, and seeds: 9 <code>platform_health</code> rows, 6 <code>system_components</code>, 10 <code>feature_flags</code>, 36 <code>storefront_themes</code>, 4 <code>billing_plans</code> (one row per tier, with monthly price + yearly-discount config in the same row) and their entitlement rows in <code>plan_features</code>, 9 <code>platform_settings</code> SEO defaults. Includes visitor-tracking + chat tables (<code>tracking_sessions</code>, <code>tracking_events</code>, <code>support_chats</code>, <code>support_chat_messages</code>, <code>support_push_links</code>) and the new <code>storefront_custom_themes</code> table for creator-built color palettes.</p> | |
| 375 | ||
| 376 | <p>You may see <strong>~165 warnings</strong> — those are mostly <code>DROP TABLE/TRIGGER IF EXISTS</code> against non-existent objects on a fresh database. They're benign. Verify success with:</p> | |
| 377 | ||
| 378 | <pre data-label="mysql"><code><span class="p">mysql></span> <span class="k">USE</span> `SHOPCART`; | |
| 379 | <span class="p">mysql></span> <span class="k">SELECT</span> COUNT(*) <span class="k">FROM</span> information_schema.tables <span class="k">WHERE</span> table_schema=<span class="s">'SHOPCART'</span>; | |
| 380 | <span class="c">-- expect 63</span> | |
| 381 | <span class="p">mysql></span> <span class="k">SELECT</span> COUNT(*) <span class="k">FROM</span> information_schema.triggers <span class="k">WHERE</span> trigger_schema=<span class="s">'SHOPCART'</span>; | |
| 382 | <span class="c">-- expect 39</span> | |
| 383 | <span class="p">mysql></span> <span class="k">SELECT</span> flag_key, default_value <span class="k">FROM</span> feature_flags; | |
| 384 | <span class="p">mysql></span> <span class="k">SELECT</span> tier, cadence, price_cents <span class="k">FROM</span> billing_plans <span class="k">ORDER BY</span> sort_order; | |
| 385 | <span class="c">-- expect 8 rows: free/starter/pro/studio x monthly/annual</span> | |
| 386 | <span class="p">mysql></span> <span class="k">DESCRIBE</span> promotions; | |
| 387 | <span class="c">-- 23 columns including scope, kind, code, public_slug, starts_at, ends_at, max_redemptions</span> | |
| 388 | <span class="p">mysql></span> <span class="k">SELECT</span> setting_key <span class="k">FROM</span> platform_settings <span class="k">WHERE</span> setting_key <span class="k">LIKE</span> <span class="s">'seo.%'</span>; | |
| 389 | <span class="c">-- expect 9 rows: seo.title, seo.description, seo.keywords, seo.og_image, ...</span></code></pre> | |
| 390 | ||
| 391 | <h3>5.4 — Storefront design system (layouts + color themes)</h3> | |
| 392 | <p>Each storefront has both a <strong>layout</strong> (page structure) and a <strong>color theme</strong> (palette). These are decoupled — any layout works with any theme. Both are stored on the <code>storefronts</code> row:</p> | |
| 393 | <ul> | |
| 394 | <li><code>storefronts.layout_id</code> — references <code>MODS::ShopCart::Layouts</code> (36 layouts shipped). Defaults to <strong>1</strong> (Catalog Classic).</li> | |
| 395 | <li><code>storefronts.theme_id</code> — references <code>MODS::ShopCart::Themes</code> (36 themes shipped). Defaults to <strong>1</strong> (Dungeon Forge).</li> | |
| 396 | <li><code>storefronts.custom_theme_id</code> — optional override. When non-NULL, points at a row in <code>storefront_custom_themes</code> (creator-built palette). The buyer-facing renderer uses <code>resolve_for_storefront()</code> which prefers <code>custom_theme_id</code> over <code>theme_id</code>.</li> | |
| 397 | </ul> | |
| 398 | <p>That's <strong>36 layouts × 36 color themes = 1,296 design combinations</strong> a creator can pick from out of the box, plus any number of custom themes they build themselves via the in-app color picker. Pickers paginate at 12 per page.</p> | |
| 399 | <p>Built-in layouts and themes are <em>Perl-defined</em>, not DB-defined. To add a new layout:</p> | |
| 400 | <ol> | |
| 401 | <li>Drop a new template file in <code>html/TEMPLATES/store_layouts/<slug>.html</code>.</li> | |
| 402 | <li>Add a small hash to <code>@LAYOUTS</code> in <code>html/MODS/ShopCart/Layouts.pm</code>.</li> | |
| 403 | <li>Done. The picker shows it instantly. No DB migration.</li> | |
| 404 | </ol> | |
| 405 | <p>To add a color theme: edit <code>html/MODS/ShopCart/Themes.pm</code>, add a hash with the new colors, save. Same — no migration needed.</p> | |
| 406 | <p>The <strong>storefront_themes</strong> table is kept around (referenced by <code>template_performance</code> for analytics) but is not load-bearing — it just holds metadata. Validation happens in Perl.</p> | |
| 407 | ||
| 408 | <h4>Custom (creator-built) themes</h4> | |
| 409 | <p>Creators can build their own palette in the Storefront editor with a 13-input color picker (Accent, Backgrounds, Borders, Text). The colors land in <strong>storefront_custom_themes</strong>:</p> | |
| 410 | <ul> | |
| 411 | <li>One row per saved custom theme. A storefront can have multiple saved themes and switch between them.</li> | |
| 412 | <li>14 color fields: <code>bg</code>, <code>bg_2</code>, <code>surface</code>, <code>surface_2</code>, <code>cborder</code>, <code>cborder_2</code>, <code>ctext</code>, <code>ctext_2</code>, <code>ctext_3</code>, <code>accent</code>, <code>accent_2</code>, <code>accent_deep</code>, <code>accent_glow</code>, plus <code>name</code>.</li> | |
| 413 | <li>Column names <code>cborder*</code> and <code>ctext*</code> are prefixed because <code>border</code> and <code>text</code> are reserved words in standard SQL. Perl maps them back to the canonical theme-hash keys (<code>border</code>, <code>border_2</code>, <code>text</code>, ...) in <code>Themes::custom_by_id()</code>.</li> | |
| 414 | <li>Applying a custom theme sets <code>storefronts.custom_theme_id</code> to the row's id. Switching back to a built-in clears <code>custom_theme_id</code> to NULL.</li> | |
| 415 | <li><code>accent_glow</code> is auto-derived from <code>accent</code> at 35% alpha (the builder fills it in via JS so creators don't have to think about it).</li> | |
| 416 | </ul> | |
| 417 | ||
| 418 | <h4>Migrating an existing database</h4> | |
| 419 | <p>If you ran an older version of <code>db_schema.sql</code>, apply these to bring the storefronts table forward:</p> | |
| 420 | <pre data-label="mysql"><code><span class="p">mysql></span> <span class="k">USE</span> `SHOPCART`; | |
| 421 | <span class="c">-- 5.4a: layout column (older schemas only had theme_id)</span> | |
| 422 | <span class="p">mysql></span> <span class="k">ALTER</span> <span class="k">TABLE</span> storefronts <span class="k">ADD COLUMN</span> layout_id BIGINT UNSIGNED NULL <span class="k">DEFAULT</span> 1 <span class="k">AFTER</span> theme_id; | |
| 423 | <span class="c">-- 5.4b: custom-theme override column</span> | |
| 424 | <span class="p">mysql></span> <span class="k">ALTER</span> <span class="k">TABLE</span> storefronts <span class="k">ADD COLUMN</span> custom_theme_id BIGINT UNSIGNED NULL <span class="k">DEFAULT</span> <span class="k">NULL</span> <span class="k">AFTER</span> theme_id; | |
| 425 | <span class="c">-- 5.4c: custom themes table (creator-built palettes saved per storefront)</span> | |
| 426 | <span class="p">mysql></span> <span class="k">CREATE</span> <span class="k">TABLE</span> <span class="k">IF</span> <span class="k">NOT</span> <span class="k">EXISTS</span> storefront_custom_themes ( | |
| 427 | id BIGINT UNSIGNED <span class="k">NOT</span> <span class="k">NULL</span> AUTO_INCREMENT, | |
| 428 | storefront_id BIGINT UNSIGNED <span class="k">NOT</span> <span class="k">NULL</span>, | |
| 429 | name VARCHAR(120) <span class="k">NOT</span> <span class="k">NULL</span> <span class="k">DEFAULT</span> <span class="s">'My Custom Theme'</span>, | |
| 430 | bg VARCHAR(32) <span class="k">NOT</span> <span class="k">NULL</span>, | |
| 431 | bg_2 VARCHAR(32) <span class="k">NOT</span> <span class="k">NULL</span>, | |
| 432 | surface VARCHAR(32) <span class="k">NOT</span> <span class="k">NULL</span>, | |
| 433 | surface_2 VARCHAR(32) <span class="k">NOT</span> <span class="k">NULL</span>, | |
| 434 | cborder VARCHAR(32) <span class="k">NOT</span> <span class="k">NULL</span>, | |
| 435 | cborder_2 VARCHAR(32) <span class="k">NOT</span> <span class="k">NULL</span>, | |
| 436 | ctext VARCHAR(32) <span class="k">NOT</span> <span class="k">NULL</span>, | |
| 437 | ctext_2 VARCHAR(32) <span class="k">NOT</span> <span class="k">NULL</span>, | |
| 438 | ctext_3 VARCHAR(32) <span class="k">NOT</span> <span class="k">NULL</span>, | |
| 439 | accent VARCHAR(32) <span class="k">NOT</span> <span class="k">NULL</span>, | |
| 440 | accent_2 VARCHAR(32) <span class="k">NOT</span> <span class="k">NULL</span>, | |
| 441 | accent_deep VARCHAR(32) <span class="k">NOT</span> <span class="k">NULL</span>, | |
| 442 | accent_glow VARCHAR(48) <span class="k">NOT</span> <span class="k">NULL</span>, | |
| 443 | created_at DATETIME <span class="k">NOT</span> <span class="k">NULL</span>, | |
| 444 | updated_at DATETIME <span class="k">NOT</span> <span class="k">NULL</span>, | |
| 445 | <span class="k">PRIMARY KEY</span> (id), | |
| 446 | <span class="k">KEY</span> idx_sct_storefront (storefront_id), | |
| 447 | <span class="k">CONSTRAINT</span> fk_sct_storefront <span class="k">FOREIGN KEY</span> (storefront_id) <span class="k">REFERENCES</span> storefronts (id) <span class="k">ON DELETE CASCADE</span> | |
| 448 | ) ENGINE=InnoDB <span class="k">DEFAULT</span> CHARSET=utf8mb4; | |
| 449 | <span class="c">-- Existing storefronts continue to use built-in theme_id until a creator builds + applies a custom theme.</span></code></pre> | |
| 450 | ||
| 451 | <h3>5.5 — Page editor (rich-text WYSIWYG)</h3> | |
| 452 | <p>The page editor (<code>page.cgi</code>) is a custom-built WYSIWYG — <strong>vanilla HTML / CSS / JS only</strong>, no third-party libraries, no CDN dependencies, no build step. Creators get headings, bold/italic/underline/strike, ordered & bullet lists, links, blockquote, code blocks, images, and clear-formatting. Drag-and-drop and clipboard paste both work for image uploads.</p> | |
| 453 | ||
| 454 | <p>Implementation: a <code>contenteditable</code> div + the standard <code>document.execCommand</code> API + the Selection/Range API for the image fallback path. The editor lives entirely inside <code>TEMPLATES/shopcart_page_edit.html</code>.</p> | |
| 455 | ||
| 456 | <h4>How image uploads work</h4> | |
| 457 | <ol> | |
| 458 | <li>Editor sends file to <code>upload_image.cgi</code>.</li> | |
| 459 | <li>CGI authenticates, validates by magic bytes (JPG/PNG/GIF/WebP only, ≤10MB), generates a 16-char hex id, saves to <code>httpdocs/uploads/<storefront_id>/<id>.<ext></code>, and INSERTs a <code>page_images</code> row.</li> | |
| 460 | <li>Returns <code>{success:1, url:"/img.cgi?id=<id>"}</code>.</li> | |
| 461 | <li>Editor inserts <code><img src="/img.cgi?id=..."></code> into the page body.</li> | |
| 462 | <li>When a buyer visits the page, their browser hits <code>img.cgi?id=...</code>, which looks up the row, resolves the path, and streams bytes with the right <code>Content-Type</code>.</li> | |
| 463 | </ol> | |
| 464 | <p><strong>Direct file paths never leak.</strong> The on-disk location is only known to <code>img.cgi</code>; clients only ever see token URLs. The <code>/uploads/</code> directory has a <code>.htaccess</code> that blocks all direct HTTP access (<code>Require all denied</code>) as defense in depth, while the CGI continues to read files via the filesystem.</p> | |
| 465 | ||
| 466 | <h4>One-time setup on the server</h4> | |
| 467 | <pre data-label="bash"><code><span class="c"># Create the per-storefront uploads root + drop the .htaccess</span> | |
| 468 | <span class="p">$</span> <span class="k">mkdir -p</span> /var/www/vhosts/3dshawn.com/shop.3dshawn.com/uploads | |
| 469 | <span class="p">$</span> <span class="k">chown</span> stlsweb:psacln /var/www/vhosts/3dshawn.com/shop.3dshawn.com/uploads | |
| 470 | <span class="p">$</span> <span class="k">chmod</span> 755 /var/www/vhosts/3dshawn.com/shop.3dshawn.com/uploads | |
| 471 | ||
| 472 | <span class="c"># Upload uploads/.htaccess (in the repo) so direct access is denied</span> | |
| 473 | <span class="c"># Then make sure both CGIs are executable</span> | |
| 474 | <span class="p">$</span> <span class="k">chmod</span> 755 /var/www/vhosts/3dshawn.com/shop.3dshawn.com/upload_image.cgi | |
| 475 | <span class="p">$</span> <span class="k">chmod</span> 755 /var/www/vhosts/3dshawn.com/shop.3dshawn.com/img.cgi</code></pre> | |
| 476 | <p>The directory must be writable by the user Apache runs CGI scripts as (<code>stlsweb</code> on this Plesk install). Per-storefront subdirs (<code>/uploads/1/</code>, <code>/uploads/2/</code>, …) are created automatically by <code>upload_image.cgi</code> on the first upload for each storefront.</p> | |
| 477 | ||
| 478 | <h4>Editable storefront fields</h4> | |
| 479 | <p>The page editor lets the creator click anywhere on the storefront chrome to edit it in place. The fields that are click-to-edit:</p> | |
| 480 | <ul> | |
| 481 | <li><code>storefronts.name</code> — store name (already existed)</li> | |
| 482 | <li><code>storefronts.tagline</code> — short tagline shown in hero / about</li> | |
| 483 | <li><code>storefronts.about_text</code> — longer about copy</li> | |
| 484 | <li><code>storefronts.hero_image_1 / _2 / _3</code> — editable hero image slots used by Catalog (3), Lookbook (1) and Storyteller (1) layouts. Other layouts use product images dynamically.</li> | |
| 485 | <li><code>storefronts.hero_image_1_pos / _2_pos / _3_pos</code> — CSS <code>background-position</code> for each hero (e.g. <code>"30% 70%"</code>). Empty defaults to <code>"50% 50%"</code> (center). Set by the click-and-drag focal-point picker on each image in the page editor.</li> | |
| 486 | <li><code>storefronts.cta_primary_label / cta_secondary_label</code> — the two main call-to-action labels on the hero (defaults: <code>"Get it now"</code>, <code>"See more designs"</code>).</li> | |
| 487 | <li><code>storefronts.featured_badge_label</code> — small uppercase pill above the hero title (default: <code>"Featured release"</code>).</li> | |
| 488 | <li><code>storefronts.section_heading</code> — heading above the secondary product strip (default: <code>"More from the studio"</code>).</li> | |
| 489 | <li><code>storefronts.footer_byline</code> — right-side text in the footer after the store name (default: <code>"powered by shop.3dshawn.com"</code>).</li> | |
| 490 | </ul> | |
| 491 | <p>Save handler is <code>update_storefront_field.cgi</code>. It whitelists field names server-side, caps lengths, and verifies the user owns the storefront before writing. Text wrapping with edit markers happens centrally in <code>store.cgi</code>, so adding a new editable text field automatically lights up on every layout that uses that tvar.</p> | |
| 492 | ||
| 493 | <h4>Migration for storefront fields (existing database)</h4> | |
| 494 | <pre data-label="mysql"><code><span class="p">mysql></span> <span class="k">USE</span> `SHOPCART`; | |
| 495 | <span class="p">mysql></span> <span class="k">ALTER</span> <span class="k">TABLE</span> storefronts | |
| 496 | <span class="k">ADD COLUMN</span> tagline VARCHAR(255) <span class="k">NULL</span> <span class="k">AFTER</span> name, | |
| 497 | <span class="k">ADD COLUMN</span> about_text TEXT <span class="k">NULL</span> <span class="k">AFTER</span> tagline, | |
| 498 | <span class="k">ADD COLUMN</span> hero_image_1 VARCHAR(255) <span class="k">NULL</span> <span class="k">AFTER</span> about_text, | |
| 499 | <span class="k">ADD COLUMN</span> hero_image_2 VARCHAR(255) <span class="k">NULL</span> <span class="k">AFTER</span> hero_image_1, | |
| 500 | <span class="k">ADD COLUMN</span> hero_image_3 VARCHAR(255) <span class="k">NULL</span> <span class="k">AFTER</span> hero_image_2, | |
| 501 | <span class="k">ADD COLUMN</span> hero_image_1_pos VARCHAR(20) <span class="k">NULL</span> <span class="k">AFTER</span> hero_image_1, | |
| 502 | <span class="k">ADD COLUMN</span> hero_image_2_pos VARCHAR(20) <span class="k">NULL</span> <span class="k">AFTER</span> hero_image_2, | |
| 503 | <span class="k">ADD COLUMN</span> hero_image_3_pos VARCHAR(20) <span class="k">NULL</span> <span class="k">AFTER</span> hero_image_3, | |
| 504 | <span class="k">ADD COLUMN</span> cta_primary_label VARCHAR(60) <span class="k">NULL</span> <span class="k">AFTER</span> hero_image_3_pos, | |
| 505 | <span class="k">ADD COLUMN</span> cta_secondary_label VARCHAR(60) <span class="k">NULL</span> <span class="k">AFTER</span> cta_primary_label, | |
| 506 | <span class="k">ADD COLUMN</span> featured_badge_label VARCHAR(60) <span class="k">NULL</span> <span class="k">AFTER</span> cta_secondary_label, | |
| 507 | <span class="k">ADD COLUMN</span> section_heading VARCHAR(120) <span class="k">NULL</span> <span class="k">AFTER</span> featured_badge_label, | |
| 508 | <span class="k">ADD COLUMN</span> footer_byline VARCHAR(160) <span class="k">NULL</span> <span class="k">AFTER</span> section_heading, | |
| 509 | <span class="k">ADD COLUMN</span> featured_title VARCHAR(200) <span class="k">NULL</span> <span class="k">AFTER</span> footer_byline, | |
| 510 | <span class="k">ADD COLUMN</span> featured_price VARCHAR(40) <span class="k">NULL</span> <span class="k">AFTER</span> featured_title, | |
| 511 | <span class="k">ADD COLUMN</span> featured_hero_image VARCHAR(255) <span class="k">NULL</span> <span class="k">AFTER</span> featured_price, | |
| 512 | <span class="k">ADD COLUMN</span> featured_hero_pos VARCHAR(20) <span class="k">NULL</span> <span class="k">AFTER</span> featured_hero_image;</code></pre> | |
| 513 | ||
| 514 | <p>The <code>featured_*</code> columns are <strong>storefront-level overrides</strong> for the Spotlight layout's hero content. When set, they take priority over the auto-selected featured product. This lets a creator customize the spotlight (title, price, hero image, focal point) before uploading any real models, or override the auto-pick after they have a catalog. Edited in-place via <code>/update_storefront_field.cgi</code>.</p> | |
| 515 | ||
| 516 | <h4>Migration (page images table, existing database)</h4> | |
| 517 | <pre data-label="mysql"><code><span class="p">mysql></span> <span class="k">USE</span> `SHOPCART`; | |
| 518 | <span class="p">mysql></span> <span class="k">CREATE</span> <span class="k">TABLE</span> page_images ( | |
| 519 | id VARCHAR(32) <span class="k">NOT NULL</span>, | |
| 520 | user_id BIGINT UNSIGNED <span class="k">NOT NULL</span>, | |
| 521 | storefront_id BIGINT UNSIGNED <span class="k">NOT NULL</span>, | |
| 522 | filename VARCHAR(191) <span class="k">NOT NULL</span>, | |
| 523 | mime_type VARCHAR(50) <span class="k">NOT NULL</span>, | |
| 524 | size_bytes BIGINT UNSIGNED <span class="k">NOT NULL</span>, | |
| 525 | created_at TIMESTAMP <span class="k">NULL DEFAULT NULL</span>, | |
| 526 | <span class="k">PRIMARY KEY</span> (id), | |
| 527 | <span class="k">KEY</span> idx_pi_user (user_id), | |
| 528 | <span class="k">KEY</span> idx_pi_storefront (storefront_id), | |
| 529 | <span class="k">CONSTRAINT</span> fk_pi_user <span class="k">FOREIGN KEY</span> (user_id) <span class="k">REFERENCES</span> users (id) <span class="k">ON DELETE CASCADE</span>, | |
| 530 | <span class="k">CONSTRAINT</span> fk_pi_storefront <span class="k">FOREIGN KEY</span> (storefront_id) <span class="k">REFERENCES</span> storefronts (id) <span class="k">ON DELETE CASCADE</span> | |
| 531 | ) <span class="k">ENGINE</span>=InnoDB <span class="k">DEFAULT CHARSET</span>=utf8mb4 <span class="k">ROW_FORMAT</span>=DYNAMIC; | |
| 532 | <span class="p">mysql></span> <span class="k">CREATE TRIGGER</span> tr_page_images_created_at <span class="k">BEFORE INSERT ON</span> page_images | |
| 533 | <span class="k">FOR EACH ROW SET</span> NEW.created_at = COALESCE(NEW.created_at, NOW());</code></pre> | |
| 534 | ||
| 535 | <h4>Cleanup</h4> | |
| 536 | <p>If a creator removes an image from a page body, the on-disk file and the <code>page_images</code> row both remain. A periodic reaper (cron) that scans page bodies for live <code>/img.cgi?id=</code> references and deletes orphaned rows + files is on the roadmap. Disk usage will grow slowly until that lands.</p> | |
| 537 | ||
| 538 | <h3>5.6 — Marketplace integrations (multi-session roadmap)</h3> | |
| 539 | <p>Creators can list each item on outside marketplaces (Etsy, Shopify, MyMiniFactory, Cults3D, etc.) alongside their primary shop.3dshawn.com store. The framework lives in <code>MODS::ShopCart::Marketplaces</code> + the <code>/marketplaces.cgi</code> dashboard. Each per-platform adapter ships as its own session.</p> | |
| 540 | ||
| 541 | <h4>Available today</h4> | |
| 542 | <ul> | |
| 543 | <li><strong>Manual export</strong> — <code>marketplace_export.cgi?model_id=N</code>. One page with every field a marketplace listing needs, plus copy-to-clipboard and download links. Works for every platform regardless of API status. The creator pastes into Etsy/MMF/wherever manually.</li> | |
| 544 | </ul> | |
| 545 | ||
| 546 | <h4>Roadmap (live API adapters)</h4> | |
| 547 | <ol> | |
| 548 | <li><strong>Etsy</strong> — huge handmade + digital-download marketplace, mature OAuth 2.0 + REST API. <a href="https://developers.etsy.com/" target="_blank">developers.etsy.com</a></li> | |
| 549 | <li><strong>Gumroad</strong> — simplest API, low setup friction. <a href="https://help.gumroad.com/article/280-create-product-api" target="_blank">Gumroad product API</a></li> | |
| 550 | <li><strong>MyMiniFactory</strong> — tabletop minis hub. <a href="https://www.myminifactory.com/developers" target="_blank">MMF developers</a></li> | |
| 551 | <li><strong>Thangs</strong> — search-first 3D marketplace with creator Memberships and single-purchase downloads. 0% fee on Memberships, ~12% on marketplace sales. <a href="https://thangs.com/developers" target="_blank">Thangs developers</a></li> | |
| 552 | <li><strong>Cults3D, Printables, CGTrader, Thingiverse</strong> — 3D-native marketplaces.</li> | |
| 553 | <li><strong>Patreon</strong> — different shape (subscription tiers), built last. <a href="https://docs.patreon.com" target="_blank">Patreon API docs</a></li> | |
| 554 | <li><strong>Amazon SP-API, Walmart Marketplace</strong> — only if volume justifies; multi-week approval per platform.</li> | |
| 555 | </ol> | |
| 556 | ||
| 557 | <h4>Each new adapter needs (per platform)</h4> | |
| 558 | <ul> | |
| 559 | <li>A registered developer account on that platform (you, the user, must create it — shop.3dshawn.com can't sign up for Etsy on your behalf)</li> | |
| 560 | <li>Approved API credentials (client ID + secret, or API key)</li> | |
| 561 | <li>OAuth redirect URI registered: <code>https://shop.3dshawn.com/marketplace_oauth.cgi?platform=<slug></code></li> | |
| 562 | <li>A new module at <code>MODS/ShopCart/Marketplaces/<Slug>.pm</code> implementing the adapter interface</li> | |
| 563 | </ul> | |
| 564 | ||
| 565 | <h4>DB migration (existing database)</h4> | |
| 566 | <pre data-label="mysql"><code><span class="p">mysql></span> <span class="k">USE</span> `SHOPCART`; | |
| 567 | ||
| 568 | <span class="c">-- Per-user platform credentials. Holds OAuth tokens when adapters are wired up.</span> | |
| 569 | <span class="p">mysql></span> <span class="k">CREATE</span> <span class="k">TABLE</span> marketplace_accounts ( | |
| 570 | id BIGINT UNSIGNED <span class="k">NOT NULL AUTO_INCREMENT</span>, | |
| 571 | user_id BIGINT UNSIGNED <span class="k">NOT NULL</span>, | |
| 572 | platform VARCHAR(32) <span class="k">NOT NULL</span>, | |
| 573 | status <span class="k">ENUM</span>(<span class="s">'disconnected'</span>,<span class="s">'connected'</span>,<span class="s">'expired'</span>,<span class="s">'error'</span>) <span class="k">NOT NULL DEFAULT</span> <span class="s">'disconnected'</span>, | |
| 574 | access_token TEXT <span class="k">NULL</span>, | |
| 575 | refresh_token TEXT <span class="k">NULL</span>, | |
| 576 | token_expires_at TIMESTAMP <span class="k">NULL DEFAULT NULL</span>, | |
| 577 | account_handle VARCHAR(191) <span class="k">NULL</span>, | |
| 578 | metadata LONGTEXT <span class="k">NULL</span>, | |
| 579 | last_error VARCHAR(500) <span class="k">NULL</span>, | |
| 580 | connected_at TIMESTAMP <span class="k">NULL DEFAULT NULL</span>, | |
| 581 | last_used_at TIMESTAMP <span class="k">NULL DEFAULT NULL</span>, | |
| 582 | created_at TIMESTAMP <span class="k">NULL DEFAULT NULL</span>, | |
| 583 | updated_at TIMESTAMP <span class="k">NOT NULL DEFAULT CURRENT_TIMESTAMP</span>, | |
| 584 | <span class="k">PRIMARY KEY</span> (id), | |
| 585 | <span class="k">UNIQUE KEY</span> uq_mp_user_platform (user_id, platform), | |
| 586 | <span class="k">KEY</span> idx_mp_status (status), | |
| 587 | <span class="k">CONSTRAINT</span> fk_mp_user <span class="k">FOREIGN KEY</span> (user_id) <span class="k">REFERENCES</span> users (id) <span class="k">ON DELETE CASCADE</span> | |
| 588 | ) <span class="k">ENGINE</span>=InnoDB <span class="k">DEFAULT CHARSET</span>=utf8mb4 <span class="k">ROW_FORMAT</span>=DYNAMIC; | |
| 589 | ||
| 590 | <span class="c">-- Push history per (model, platform). Status + remote listing id.</span> | |
| 591 | <span class="p">mysql></span> <span class="k">CREATE</span> <span class="k">TABLE</span> marketplace_pushes ( | |
| 592 | id BIGINT UNSIGNED <span class="k">NOT NULL AUTO_INCREMENT</span>, | |
| 593 | user_id BIGINT UNSIGNED <span class="k">NOT NULL</span>, | |
| 594 | model_id BIGINT UNSIGNED <span class="k">NOT NULL</span>, | |
| 595 | platform VARCHAR(32) <span class="k">NOT NULL</span>, | |
| 596 | status <span class="k">ENUM</span>(<span class="s">'pending'</span>,<span class="s">'success'</span>,<span class="s">'failed'</span>) <span class="k">NOT NULL DEFAULT</span> <span class="s">'pending'</span>, | |
| 597 | remote_listing_id VARCHAR(191) <span class="k">NULL</span>, | |
| 598 | remote_url VARCHAR(500) <span class="k">NULL</span>, | |
| 599 | error_message TEXT <span class="k">NULL</span>, | |
| 600 | request_payload LONGTEXT <span class="k">NULL</span>, | |
| 601 | response_payload LONGTEXT <span class="k">NULL</span>, | |
| 602 | created_at TIMESTAMP <span class="k">NULL DEFAULT NULL</span>, | |
| 603 | <span class="k">PRIMARY KEY</span> (id), | |
| 604 | <span class="k">KEY</span> idx_mp_push_user (user_id), | |
| 605 | <span class="k">KEY</span> idx_mp_push_model (model_id), | |
| 606 | <span class="k">KEY</span> idx_mp_push_platform (platform), | |
| 607 | <span class="k">CONSTRAINT</span> fk_mp_push_user <span class="k">FOREIGN KEY</span> (user_id) <span class="k">REFERENCES</span> users (id) <span class="k">ON DELETE CASCADE</span>, | |
| 608 | <span class="k">CONSTRAINT</span> fk_mp_push_model <span class="k">FOREIGN KEY</span> (model_id) <span class="k">REFERENCES</span> models (id) <span class="k">ON DELETE CASCADE</span> | |
| 609 | ) <span class="k">ENGINE</span>=InnoDB <span class="k">DEFAULT CHARSET</span>=utf8mb4 <span class="k">ROW_FORMAT</span>=DYNAMIC; | |
| 610 | ||
| 611 | <span class="p">mysql></span> <span class="k">CREATE TRIGGER</span> tr_marketplace_accounts_created_at <span class="k">BEFORE INSERT ON</span> marketplace_accounts | |
| 612 | <span class="k">FOR EACH ROW SET</span> NEW.created_at = COALESCE(NEW.created_at, NOW()); | |
| 613 | <span class="p">mysql></span> <span class="k">CREATE TRIGGER</span> tr_marketplace_pushes_created_at <span class="k">BEFORE INSERT ON</span> marketplace_pushes | |
| 614 | <span class="k">FOR EACH ROW SET</span> NEW.created_at = COALESCE(NEW.created_at, NOW()); | |
| 615 | ||
| 616 | <span class="c">-- Marketplace -> ShopCart import: provenance columns on `models` so the</span> | |
| 617 | <span class="c">-- "Pull in your models" button can dedup creations across re-runs.</span> | |
| 618 | <span class="c">-- Native uploads leave all three NULL. Fresh installs from the current</span> | |
| 619 | <span class="c">-- db_schema.sql already include them.</span> | |
| 620 | <span class="p">mysql></span> <span class="k">ALTER TABLE</span> models | |
| 621 | <span class="k">ADD COLUMN</span> source_platform <span class="k">VARCHAR</span>(32) <span class="k">NULL</span> <span class="k">AFTER</span> hero_image_pos, | |
| 622 | <span class="k">ADD COLUMN</span> source_external_id <span class="k">VARCHAR</span>(191) <span class="k">NULL</span> <span class="k">AFTER</span> source_platform, | |
| 623 | <span class="k">ADD COLUMN</span> source_url <span class="k">VARCHAR</span>(500) <span class="k">NULL</span> <span class="k">AFTER</span> source_external_id, | |
| 624 | <span class="k">ADD KEY</span> idx_models_source (user_id, source_platform, source_external_id);</code></pre> | |
| 625 | ||
| 626 | <h3>5.7 — Upload page model fields</h3> | |
| 627 | <p>The full upload form (<code>/upload_model.cgi</code>) writes a dozen extra fields onto each new model: tagline, visibility, scheduled release, highlights, included items, video links, specs, print settings, printer compatibility, materials, file options, and tested printers. If you've already created the <code>models</code> table from an older revision, run the ALTER below; fresh installs from the current <code>db_schema.sql</code> already include them.</p> | |
| 628 | ||
| 629 | <pre data-label="ALTER existing models table"><code><span class="p">mysql></span> <span class="k">ALTER TABLE</span> models | |
| 630 | <span class="k">ADD COLUMN</span> tagline <span class="k">VARCHAR</span>(180) <span class="k">NULL</span> <span class="k">AFTER</span> slug, | |
| 631 | <span class="k">ADD COLUMN</span> visibility <span class="k">ENUM</span>(<span class="s">'public'</span>,<span class="s">'unlisted'</span>,<span class="s">'private'</span>) <span class="k">NOT NULL DEFAULT</span> <span class="s">'public'</span> <span class="k">AFTER</span> status, | |
| 632 | <span class="k">ADD COLUMN</span> scheduled_publish_at <span class="k">TIMESTAMP NULL DEFAULT NULL</span> <span class="k">AFTER</span> visibility, | |
| 633 | <span class="k">ADD COLUMN</span> highlights <span class="k">MEDIUMTEXT NULL</span> <span class="k">AFTER</span> scheduled_publish_at, | |
| 634 | <span class="k">ADD COLUMN</span> included_items <span class="k">MEDIUMTEXT NULL</span> <span class="k">AFTER</span> highlights, | |
| 635 | <span class="k">ADD COLUMN</span> video_links <span class="k">MEDIUMTEXT NULL</span> <span class="k">AFTER</span> included_items, | |
| 636 | <span class="k">ADD COLUMN</span> specs_json <span class="k">MEDIUMTEXT NULL</span> <span class="k">AFTER</span> video_links, | |
| 637 | <span class="k">ADD COLUMN</span> print_settings_json <span class="k">MEDIUMTEXT NULL</span> <span class="k">AFTER</span> specs_json, | |
| 638 | <span class="k">ADD COLUMN</span> printer_compat <span class="k">VARCHAR</span>(255) <span class="k">NULL</span> <span class="k">AFTER</span> print_settings_json, | |
| 639 | <span class="k">ADD COLUMN</span> materials <span class="k">VARCHAR</span>(255) <span class="k">NULL</span> <span class="k">AFTER</span> printer_compat, | |
| 640 | <span class="k">ADD COLUMN</span> file_options <span class="k">VARCHAR</span>(255) <span class="k">NULL</span> <span class="k">AFTER</span> materials, | |
| 641 | <span class="k">ADD COLUMN</span> tested_printers <span class="k">VARCHAR</span>(255) <span class="k">NULL</span> <span class="k">AFTER</span> file_options, | |
| 642 | <span class="k">ADD COLUMN</span> hero_image_url <span class="k">VARCHAR</span>(255) <span class="k">NULL</span> <span class="k">AFTER</span> tested_printers, | |
| 643 | <span class="k">ADD COLUMN</span> hero_image_pos <span class="k">VARCHAR</span>(20) <span class="k">NULL</span> <span class="k">AFTER</span> hero_image_url, | |
| 644 | <span class="k">ADD COLUMN</span> purged_at <span class="k">TIMESTAMP NULL DEFAULT NULL</span> <span class="k">AFTER</span> hero_image_pos, | |
| 645 | <span class="k">ADD KEY</span> idx_models_scheduled (scheduled_publish_at);</code></pre> | |
| 646 | ||
| 647 | <p>The two <code>hero_image_*</code> columns store the per-model hero image (URL via <code>/img.cgi?id=...</code>) and its focal-point position (CSS <code>background-position</code>). They are edited in-place from the storefront when this model is the featured product, via <code>update_model_field.cgi</code>. NULL falls back to the layout's placeholder image at <code>/assets/images/<idx>.webp</code>.</p> | |
| 648 | ||
| 649 | <p><code>purged_at</code> is the “permanent delete by user” flag. It is set by <code>/purge_models.cgi</code> when the creator empties their Trash. Every user-facing query joins <code>WHERE purged_at IS NULL</code> so the row becomes invisible to the user (and update endpoints refuse to write to it). Admin tooling can still see purged rows for moderation / recovery. Nothing is actually destroyed by the user.</p> | |
| 650 | ||
| 651 | <p>The upload page also writes selected marketplaces to <code>marketplace_pushes</code> (status <code>queued</code>) and saves uploaded files under <code>httpdocs/uploads/models/<storefront_id>/<model_id>/</code> (same <code>.htaccess</code>-protected tree as page images). The <strong>plan_tier</strong> field on <code>users</code> drives demo mode: free-tier creators can pick at most two marketplaces per push; paid tiers are unlimited.</p> | |
| 652 | ||
| 653 | <h3>5.8 — Admin Packages (plans + entitlements)</h3> | |
| 654 | <p>Existing pricing pages now read from <code>billing_plans</code>, and the “Available Features” toggles on <code>/settings.cgi</code> are gated by a new entitlement table <code>plan_features</code>. The admin console at <code>/admin_packages.cgi</code> lets staff edit plan price / display name / marketing copy / included features without a code change. Fresh installs from the current <code>db_schema.sql</code> already include everything; <strong>existing databases</strong> need the migration below.</p> | |
| 655 | ||
| 656 | <h4>DATABASE CHANGES TO RUN</h4> | |
| 657 | <pre data-label="mysql"><code><span class="p">mysql></span> <span class="k">USE</span> `SHOPCART`; | |
| 658 | ||
| 659 | <span class="c">-- 1. Relax tier from ENUM to VARCHAR so admins can add custom tiers</span> | |
| 660 | <span class="c">-- from /admin_packages.cgi without a schema migration.</span> | |
| 661 | <span class="p">mysql></span> <span class="k">ALTER TABLE</span> billing_plans <span class="k">MODIFY</span> tier <span class="k">VARCHAR</span>(40) <span class="k">NOT NULL</span>; | |
| 662 | ||
| 663 | <span class="c">-- 2. New admin-managed columns on billing_plans.</span> | |
| 664 | <span class="p">mysql></span> <span class="k">ALTER TABLE</span> billing_plans | |
| 665 | <span class="k">ADD COLUMN</span> tagline <span class="k">VARCHAR</span>(255) <span class="k">NULL</span> <span class="k">AFTER</span> display_name, | |
| 666 | <span class="k">ADD COLUMN</span> cta_label <span class="k">VARCHAR</span>(60) <span class="k">NULL</span> <span class="k">AFTER</span> tagline, | |
| 667 | <span class="k">ADD COLUMN</span> is_featured <span class="k">TINYINT</span>(1) <span class="k">NOT NULL DEFAULT</span> 0 <span class="k">AFTER</span> is_active; | |
| 668 | ||
| 669 | <span class="c">-- 3. Per-plan feature entitlements. Read by settings.cgi to decide</span> | |
| 670 | <span class="c">-- whether each toggle is Included or Locked (Upgrade CTA).</span> | |
| 671 | <span class="p">mysql></span> <span class="k">CREATE TABLE</span> plan_features ( | |
| 672 | plan_id BIGINT UNSIGNED <span class="k">NOT NULL</span>, | |
| 673 | feature_key <span class="k">VARCHAR</span>(64) <span class="k">NOT NULL</span>, | |
| 674 | is_included <span class="k">TINYINT</span>(1) <span class="k">NOT NULL DEFAULT</span> 0, | |
| 675 | updated_at <span class="k">TIMESTAMP</span> <span class="k">NOT NULL DEFAULT CURRENT_TIMESTAMP</span>, | |
| 676 | <span class="k">PRIMARY KEY</span> (plan_id, feature_key), | |
| 677 | <span class="k">CONSTRAINT</span> fk_plan_features_plan <span class="k">FOREIGN KEY</span> (plan_id) | |
| 678 | <span class="k">REFERENCES</span> billing_plans (id) <span class="k">ON DELETE CASCADE</span> | |
| 679 | ) <span class="k">ENGINE</span>=InnoDB <span class="k">DEFAULT CHARSET</span>=utf8mb4 <span class="k">ROW_FORMAT</span>=DYNAMIC; | |
| 680 | ||
| 681 | <span class="c">-- 4. Seed marketing copy + the "Most popular" flag on the existing rows.</span> | |
| 682 | <span class="p">mysql></span> <span class="k">UPDATE</span> billing_plans <span class="k">SET</span> is_featured=1 <span class="k">WHERE</span> tier=<span class="s">'pro'</span>; | |
| 683 | <span class="p">mysql></span> <span class="k">UPDATE</span> billing_plans <span class="k">SET</span> tagline=<span class="s">'Solo creators just publishing to one or two marketplaces.'</span>, | |
| 684 | cta_label=<span class="s">'Start free'</span> <span class="k">WHERE</span> tier=<span class="s">'free'</span>; | |
| 685 | <span class="p">mysql></span> <span class="k">UPDATE</span> billing_plans <span class="k">SET</span> tagline=<span class="s">'Active creators publishing to several marketplaces.'</span>, | |
| 686 | cta_label=<span class="s">'Start 14-day free trial'</span> <span class="k">WHERE</span> tier=<span class="s">'starter'</span>; | |
| 687 | <span class="p">mysql></span> <span class="k">UPDATE</span> billing_plans <span class="k">SET</span> tagline=<span class="s">'Storefront + optimization for creators going independent.'</span>, | |
| 688 | cta_label=<span class="s">'Start 14-day free trial'</span> <span class="k">WHERE</span> tier=<span class="s">'pro'</span>; | |
| 689 | <span class="p">mysql></span> <span class="k">UPDATE</span> billing_plans <span class="k">SET</span> tagline=<span class="s">'Teams. Multiple storefronts. Pooled-data recommendations.'</span>, | |
| 690 | cta_label=<span class="s">'Talk to sales'</span> <span class="k">WHERE</span> tier=<span class="s">'studio'</span>; | |
| 691 | ||
| 692 | <span class="c">-- 5. Seed entitlements. Pro/Studio get every feature; Starter</span> | |
| 693 | <span class="c">-- unlocks publishing/optimization in addition to the Free set;</span> | |
| 694 | <span class="c">-- Free gets storefront/audience/analytics (and explicit locked</span> | |
| 695 | <span class="c">-- rows for publishing/optimization so the settings page knows</span> | |
| 696 | <span class="c">-- to render an Upgrade CTA).</span> | |
| 697 | <span class="p">mysql></span> <span class="k">INSERT INTO</span> plan_features (plan_id, feature_key, is_included) | |
| 698 | <span class="k">SELECT</span> id, <span class="s">'storefront'</span>, 1 <span class="k">FROM</span> billing_plans; | |
| 699 | <span class="p">mysql></span> <span class="k">INSERT INTO</span> plan_features (plan_id, feature_key, is_included) | |
| 700 | <span class="k">SELECT</span> id, <span class="s">'audience'</span>, 1 <span class="k">FROM</span> billing_plans; | |
| 701 | <span class="p">mysql></span> <span class="k">INSERT INTO</span> plan_features (plan_id, feature_key, is_included) | |
| 702 | <span class="k">SELECT</span> id, <span class="s">'analytics'</span>, 1 <span class="k">FROM</span> billing_plans; | |
| 703 | <span class="p">mysql></span> <span class="k">INSERT INTO</span> plan_features (plan_id, feature_key, is_included) | |
| 704 | <span class="k">SELECT</span> id, <span class="s">'publishing'</span>, 1 <span class="k">FROM</span> billing_plans <span class="k">WHERE</span> tier <span class="k">IN</span> (<span class="s">'starter'</span>,<span class="s">'pro'</span>,<span class="s">'studio'</span>); | |
| 705 | <span class="p">mysql></span> <span class="k">INSERT INTO</span> plan_features (plan_id, feature_key, is_included) | |
| 706 | <span class="k">SELECT</span> id, <span class="s">'optimization'</span>, 1 <span class="k">FROM</span> billing_plans <span class="k">WHERE</span> tier <span class="k">IN</span> (<span class="s">'starter'</span>,<span class="s">'pro'</span>,<span class="s">'studio'</span>); | |
| 707 | <span class="p">mysql></span> <span class="k">INSERT INTO</span> plan_features (plan_id, feature_key, is_included) | |
| 708 | <span class="k">SELECT</span> id, <span class="s">'publishing'</span>, 0 <span class="k">FROM</span> billing_plans <span class="k">WHERE</span> tier=<span class="s">'free'</span>; | |
| 709 | <span class="p">mysql></span> <span class="k">INSERT INTO</span> plan_features (plan_id, feature_key, is_included) | |
| 710 | <span class="k">SELECT</span> id, <span class="s">'optimization'</span>, 0 <span class="k">FROM</span> billing_plans <span class="k">WHERE</span> tier=<span class="s">'free'</span>;</code></pre> | |
| 711 | ||
| 712 | <p>Verify with:</p> | |
| 713 | <pre data-label="mysql"><code><span class="p">mysql></span> <span class="k">SELECT</span> tier, cadence, price_cents, is_featured <span class="k">FROM</span> billing_plans <span class="k">ORDER BY</span> sort_order; | |
| 714 | <span class="p">mysql></span> <span class="k">SELECT</span> bp.tier, pf.feature_key, pf.is_included | |
| 715 | <span class="k">FROM</span> plan_features pf <span class="k">JOIN</span> billing_plans bp <span class="k">ON</span> bp.id=pf.plan_id | |
| 716 | <span class="k">ORDER BY</span> bp.sort_order, pf.feature_key;</code></pre> | |
| 717 | ||
| 718 | <p>The canonical feature catalogue (the keys you can attach to a plan) lives in <code>MODS/ShopCart/Billing.pm</code> as <code>@FEATURE_CATALOG</code>. Edit that list to add a new feature key — the admin Packages UI picks it up automatically and the settings page renders it as a new row. The seeded keys today are <code>publishing</code>, <code>storefront</code>, <code>optimization</code>, <code>analytics</code>, <code>audience</code>.</p> | |
| 719 | ||
| 720 | <h4>5.8a — Monthly + yearly on one row (post-consolidation)</h4> | |
| 721 | <p>The original billing schema kept a separate <code>billing_plans</code> row for each (tier, cadence) pair, so adding the four standard plans landed eight rows. We've collapsed that to <strong>one row per tier</strong>: monthly price lives on <code>price_cents</code>, the yearly upgrade is expressed as one of three modes via the new <code>yearly_*</code> columns, and the per-subscription <code>subscriptions.cadence</code> column records whether each subscriber is billed monthly or annually.</p> | |
| 722 | <p>Admins can now configure annual pricing per tier from <code>/admin_packages.cgi</code> → Edit, using whichever mode they prefer:</p> | |
| 723 | <ul> | |
| 724 | <li><code>yearly_mode='none'</code> — tier offers monthly billing only (Free tier).</li> | |
| 725 | <li><code>yearly_mode='percent_off'</code> — <code>yearly_pct_off</code>% off the (monthly × 12) total. 17% ≈ "2 months free"; 20% is a clean round discount.</li> | |
| 726 | <li><code>yearly_mode='dollars_off'</code> — subtract <code>yearly_off_cents</code> from the 12-month total.</li> | |
| 727 | <li><code>yearly_mode='explicit'</code> — <code>yearly_price_cents</code> is the exact annual price (used by the seeded plans: $90, $290, $990 = exactly 10x monthly).</li> | |
| 728 | </ul> | |
| 729 | <p>The effective annual price is resolved at runtime by <code>MODS::ShopCart::Billing::compute_yearly_price_cents()</code>, which both the pricing page and the MRR/ARR queries call.</p> | |
| 730 | ||
| 731 | <h4>Migrating an existing database</h4> | |
| 732 | <p>If you ran the older 8-rows-per-tier schema, apply the following once. <strong>Safe to run with active subscriptions</strong> — we backfill <code>subscriptions.cadence</code> from the soon-to-be-deleted annual plan rows before deleting them.</p> | |
| 733 | <pre data-label="mysql"><code><span class="p">mysql></span> <span class="k">USE</span> `SHOPCART`; | |
| 734 | <span class="c">-- 1. New yearly-* columns on billing_plans.</span> | |
| 735 | <span class="p">mysql></span> <span class="k">ALTER TABLE</span> billing_plans | |
| 736 | <span class="k">ADD COLUMN</span> yearly_mode <span class="k">ENUM</span>(<span class="s">'none'</span>,<span class="s">'percent_off'</span>,<span class="s">'dollars_off'</span>,<span class="s">'explicit'</span>) <span class="k">NOT NULL</span> <span class="k">DEFAULT</span> <span class="s">'none'</span> <span class="k">AFTER</span> price_cents, | |
| 737 | <span class="k">ADD COLUMN</span> yearly_pct_off <span class="k">TINYINT UNSIGNED</span> <span class="k">NOT NULL</span> <span class="k">DEFAULT</span> 0 <span class="k">AFTER</span> yearly_mode, | |
| 738 | <span class="k">ADD COLUMN</span> yearly_off_cents <span class="k">INT UNSIGNED</span> <span class="k">NOT NULL</span> <span class="k">DEFAULT</span> 0 <span class="k">AFTER</span> yearly_pct_off, | |
| 739 | <span class="k">ADD COLUMN</span> yearly_price_cents <span class="k">INT UNSIGNED</span> <span class="k">NOT NULL</span> <span class="k">DEFAULT</span> 0 <span class="k">AFTER</span> yearly_off_cents; | |
| 740 | <span class="c">-- 2. cadence on subscriptions (which used to be inferred from the plan).</span> | |
| 741 | <span class="p">mysql></span> <span class="k">ALTER TABLE</span> subscriptions | |
| 742 | <span class="k">ADD COLUMN</span> cadence <span class="k">ENUM</span>(<span class="s">'monthly'</span>,<span class="s">'annual'</span>) <span class="k">NOT NULL</span> <span class="k">DEFAULT</span> <span class="s">'monthly'</span> <span class="k">AFTER</span> plan_id; | |
| 743 | <span class="c">-- 3. For any live annual subscription, pin cadence='annual' before</span> | |
| 744 | <span class="c">-- we drop the annual plan rows it points at.</span> | |
| 745 | <span class="p">mysql></span> <span class="k">UPDATE</span> subscriptions s | |
| 746 | <span class="k">JOIN</span> billing_plans p <span class="k">ON</span> p.id = s.plan_id | |
| 747 | <span class="k">SET</span> s.cadence=<span class="s">'annual'</span> | |
| 748 | <span class="k">WHERE</span> p.cadence=<span class="s">'annual'</span>; | |
| 749 | <span class="c">-- 4. Copy each annual row's price onto the corresponding monthly row.</span> | |
| 750 | <span class="p">mysql></span> <span class="k">UPDATE</span> billing_plans m | |
| 751 | <span class="k">JOIN</span> billing_plans a <span class="k">ON</span> a.tier = m.tier <span class="k">AND</span> a.cadence=<span class="s">'annual'</span> | |
| 752 | <span class="k">SET</span> m.yearly_mode = <span class="s">'explicit'</span>, m.yearly_price_cents = a.price_cents | |
| 753 | <span class="k">WHERE</span> m.cadence=<span class="s">'monthly'</span>; | |
| 754 | <span class="p">mysql></span> <span class="k">UPDATE</span> billing_plans <span class="k">SET</span> yearly_mode=<span class="s">'none'</span>, yearly_price_cents=0 <span class="k">WHERE</span> tier=<span class="s">'free'</span>; | |
| 755 | <span class="c">-- 5. Repoint any annual-subscription's plan_id at the surviving monthly row.</span> | |
| 756 | <span class="p">mysql></span> <span class="k">UPDATE</span> subscriptions s | |
| 757 | <span class="k">JOIN</span> billing_plans a <span class="k">ON</span> a.id = s.plan_id <span class="k">AND</span> a.cadence=<span class="s">'annual'</span> | |
| 758 | <span class="k">JOIN</span> billing_plans m <span class="k">ON</span> m.tier = a.tier <span class="k">AND</span> m.cadence=<span class="s">'monthly'</span> | |
| 759 | <span class="k">SET</span> s.plan_id = m.id; | |
| 760 | <span class="c">-- 6. Drop the now-redundant annual rows + their plan_features.</span> | |
| 761 | <span class="p">mysql></span> <span class="k">DELETE</span> pf <span class="k">FROM</span> plan_features pf | |
| 762 | <span class="k">JOIN</span> billing_plans bp <span class="k">ON</span> bp.id = pf.plan_id | |
| 763 | <span class="k">WHERE</span> bp.cadence=<span class="s">'annual'</span>; | |
| 764 | <span class="p">mysql></span> <span class="k">DELETE</span> <span class="k">FROM</span> billing_plans <span class="k">WHERE</span> cadence=<span class="s">'annual'</span>;</code></pre> | |
| 765 | <p>After this the catalogue has 4 rows (one per tier). The <code>billing_plans.cadence</code> column itself is retained (always <code>'monthly'</code>) for backward compatibility with old SELECTs, but is no longer the unique key.</p> | |
| 766 | ||
| 767 | <h3>5.9 — Visitor region (country drill-down)</h3> | |
| 768 | <p>The world map on <code>/admin_visitors.cgi</code> lets staff click a country and drill into a state/region breakdown. Region data lives on <code>tracking_sessions.region</code> (free-form, e.g. <code>"California"</code>, <code>"Bayern"</code>). Fresh installs from the current <code>db_schema.sql</code> already include it; <strong>existing databases</strong> need this ALTER:</p> | |
| 769 | ||
| 770 | <h4>DATABASE CHANGES TO RUN</h4> | |
| 771 | <pre data-label="mysql"><code><span class="p">mysql></span> <span class="k">USE</span> `SHOPCART`; | |
| 772 | <span class="p">mysql></span> <span class="k">ALTER TABLE</span> tracking_sessions | |
| 773 | <span class="k">ADD COLUMN</span> region <span class="k">VARCHAR</span>(80) <span class="k">NULL</span> <span class="k">AFTER</span> country;</code></pre> | |
| 774 | ||
| 775 | <p>The drill-down panel queries the column only when it exists (<code>region_column_ready()</code> in <code>MODS::ShopCart::Tracking</code>), so the migration is non-blocking — the rest of the visitors page keeps working without it; the per-country drill just shows a friendly "no region data yet" notice in place of the state bars.</p> | |
| 776 | ||
| 777 | <p>Populating the column is a separate step: either extend <code>track.js</code> to send a state name (best-effort from timezone or browser locale), or wire server-side IP→region lookup in the ingest endpoint. The column accepts any free-form label so a Phase-1 implementation can store whatever the tracker can derive.</p> | |
| 778 | ||
| 779 | <h3>5.11 — Seller categories (storefront filter chips + upload picker)</h3> | |
| 780 | <p>Each seller has their own list of categories (e.g. <em>"Halloween 2026"</em>, <em>"Patreon exclusive"</em>) used both as the Category select on <code>/upload_model.cgi</code> and as the storefront filter chips. New sellers receive a one-time merge of the rows in <code>default_categories</code> into their <code>seller_categories</code> at first touch of <code>/categories.cgi</code> or <code>/upload_model.cgi</code> — admins edit the default list from <code>/admin_categories.cgi</code>. The merge runs once per seller (gated by <code>users.categories_seeded_at</code>), so subsequent admin edits affect future sellers only.</p> | |
| 781 | ||
| 782 | <p>Fresh installs from the current <code>db_schema.sql</code> already include all three tables, the sentinel column on <code>users</code>, and the seed rows. <strong>Existing databases</strong> need this migration:</p> | |
| 783 | ||
| 784 | <h4>DATABASE CHANGES TO RUN</h4> | |
| 785 | <pre data-label="mysql"><code><span class="p">mysql></span> <span class="k">USE</span> `SHOPCART`; | |
| 786 | <span class="p">mysql></span> <span class="k">CREATE TABLE</span> default_categories ( | |
| 787 | id <span class="k">BIGINT UNSIGNED NOT NULL AUTO_INCREMENT</span>, | |
| 788 | slug <span class="k">VARCHAR</span>(80) <span class="k">NOT NULL</span>, | |
| 789 | name <span class="k">VARCHAR</span>(120) <span class="k">NOT NULL</span>, | |
| 790 | description <span class="k">VARCHAR</span>(255) <span class="k">NULL</span>, | |
| 791 | icon <span class="k">VARCHAR</span>(40) <span class="k">NULL</span>, | |
| 792 | color <span class="k">VARCHAR</span>(20) <span class="k">NULL</span>, | |
| 793 | sort_order <span class="k">SMALLINT NOT NULL DEFAULT</span> 0, | |
| 794 | is_public <span class="k">TINYINT</span>(1) <span class="k">NOT NULL DEFAULT</span> 1, | |
| 795 | created_at <span class="k">TIMESTAMP NULL DEFAULT NULL</span>, | |
| 796 | updated_at <span class="k">TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP</span>, | |
| 797 | <span class="k">PRIMARY KEY</span> (id), | |
| 798 | <span class="k">UNIQUE KEY</span> uq_dc_slug (slug) | |
| 799 | ) <span class="k">ENGINE</span>=InnoDB <span class="k">DEFAULT CHARSET</span>=utf8mb4 <span class="k">ROW_FORMAT</span>=DYNAMIC; | |
| 800 | ||
| 801 | <span class="p">mysql></span> <span class="k">ALTER TABLE</span> users | |
| 802 | <span class="k">ADD COLUMN</span> categories_seeded_at <span class="k">TIMESTAMP NULL DEFAULT NULL AFTER</span> created_at; | |
| 803 | ||
| 804 | <span class="c">-- Seed the default list (25 rows). See db_schema.sql for the full INSERT.</span></code></pre> | |
| 805 | ||
| 806 | <p>If your install pre-dates the <code>seller_categories</code> / <code>model_seller_categories</code> tables (the per-seller list itself, not just the defaults), run those CREATE TABLE statements from <code>db_schema.sql</code> first — <code>default_categories</code> doesn't depend on them, but the seed merge needs them.</p> | |
| 807 | ||
| 808 | <p>The merge is gated on <code>users.categories_seeded_at IS NULL</code>, so it's safe to run the migration on a live system — existing sellers who have already curated their list (timestamp already set on their first visit) won't be touched, and sellers who haven't visited yet will get the merge on their next pageload.</p> | |
| 809 | ||
| 810 | <h3>5.12 — Fulfillment + shipping (physical orders)</h3> | |
| 811 | <p>Sellers who list <code>product_kind</code> in <code>'physical'</code> or <code>'both'</code> need a place to walk orders through their lifecycle (new → printing → packed → shipped → delivered), record tracking numbers, and print labels. Six new tables plus a handful of columns on <code>orders</code> and <code>order_items</code> back this workflow. Fresh installs from the current <code>db_schema.sql</code> include them all; <strong>existing databases</strong> need this migration:</p> | |
| 812 | ||
| 813 | <h4>DATABASE CHANGES TO RUN</h4> | |
| 814 | <pre data-label="mysql"><code><span class="p">mysql></span> <span class="k">USE</span> `SHOPCART`; | |
| 815 | ||
| 816 | <span class="c">-- Per-seller settings: origin address, defaults.</span> | |
| 817 | <span class="p">mysql></span> <span class="k">CREATE TABLE</span> seller_shipping_settings ( | |
| 818 | user_id <span class="k">BIGINT UNSIGNED NOT NULL</span>, | |
| 819 | origin_name <span class="k">VARCHAR</span>(120) <span class="k">NULL</span>, | |
| 820 | origin_company <span class="k">VARCHAR</span>(120) <span class="k">NULL</span>, | |
| 821 | origin_line1 <span class="k">VARCHAR</span>(160) <span class="k">NULL</span>, | |
| 822 | origin_line2 <span class="k">VARCHAR</span>(160) <span class="k">NULL</span>, | |
| 823 | origin_city <span class="k">VARCHAR</span>(120) <span class="k">NULL</span>, | |
| 824 | origin_region <span class="k">VARCHAR</span>(80) <span class="k">NULL</span>, | |
| 825 | origin_postal <span class="k">VARCHAR</span>(20) <span class="k">NULL</span>, | |
| 826 | origin_country <span class="k">CHAR</span>(2) <span class="k">NULL</span>, | |
| 827 | origin_phone <span class="k">VARCHAR</span>(40) <span class="k">NULL</span>, | |
| 828 | origin_email <span class="k">VARCHAR</span>(255) <span class="k">NULL</span>, | |
| 829 | default_carrier <span class="k">ENUM</span>('usps','ups','fedex','dhl','other','manual') <span class="k">NOT NULL DEFAULT</span> 'manual', | |
| 830 | default_service <span class="k">VARCHAR</span>(80) <span class="k">NULL</span>, | |
| 831 | auto_email_tracking <span class="k">TINYINT</span>(1) <span class="k">NOT NULL DEFAULT</span> 1, | |
| 832 | require_signature <span class="k">TINYINT</span>(1) <span class="k">NOT NULL DEFAULT</span> 0, | |
| 833 | handling_fee_cents <span class="k">INT UNSIGNED NOT NULL DEFAULT</span> 0, | |
| 834 | default_handling_days <span class="k">TINYINT UNSIGNED NOT NULL DEFAULT</span> 2, | |
| 835 | insurance_default_cents <span class="k">INT UNSIGNED NULL</span>, | |
| 836 | updated_at <span class="k">TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP</span>, | |
| 837 | <span class="k">PRIMARY KEY</span> (user_id) | |
| 838 | ); | |
| 839 | ||
| 840 | <span class="c">-- Carrier credentials per seller (one row per carrier they ship with).</span> | |
| 841 | <span class="p">mysql></span> <span class="k">CREATE TABLE</span> shipping_carriers (...); <span class="c">-- see db_schema.sql for full definition</span> | |
| 842 | <span class="p">mysql></span> <span class="k">CREATE TABLE</span> shipping_zones (...); <span class="c">-- rate-rule table the estimator walks</span> | |
| 843 | <span class="p">mysql></span> <span class="k">CREATE TABLE</span> shipping_labels (...); <span class="c">-- one record per shipment</span> | |
| 844 | <span class="p">mysql></span> <span class="k">CREATE TABLE</span> shipment_events (...); <span class="c">-- carrier status updates per label</span> | |
| 845 | <span class="p">mysql></span> <span class="k">CREATE TABLE</span> fulfillment_notes (...); <span class="c">-- per-order internal notes</span> | |
| 846 | ||
| 847 | <span class="c">-- orders: lifecycle status + shipping method + notes</span> | |
| 848 | <span class="p">mysql></span> <span class="k">ALTER TABLE</span> orders | |
| 849 | <span class="k">ADD COLUMN</span> fulfillment_status <span class="k">ENUM</span>('new','processing','printing','printed','packed','shipped','partially_shipped','delivered','completed','on_hold','cancelled','returned') <span class="k">NOT NULL DEFAULT</span> 'new', | |
| 850 | <span class="k">ADD COLUMN</span> fulfilled_at <span class="k">TIMESTAMP NULL DEFAULT NULL</span>, | |
| 851 | <span class="k">ADD COLUMN</span> seller_notes <span class="k">TEXT NULL</span>, | |
| 852 | <span class="k">ADD COLUMN</span> buyer_notes <span class="k">TEXT NULL</span>, | |
| 853 | <span class="k">ADD COLUMN</span> shipping_method <span class="k">VARCHAR</span>(80) <span class="k">NULL</span>, | |
| 854 | <span class="k">ADD KEY</span> idx_orders_fulfillment (fulfillment_status, created_at); | |
| 855 | ||
| 856 | <span class="c">-- order_items: link to label record + expand the lifecycle enum</span> | |
| 857 | <span class="p">mysql></span> <span class="k">ALTER TABLE</span> order_items | |
| 858 | <span class="k">ADD COLUMN</span> label_id <span class="k">BIGINT UNSIGNED NULL</span>, | |
| 859 | <span class="k">ADD COLUMN</span> notes <span class="k">TEXT NULL</span>, | |
| 860 | <span class="k">MODIFY COLUMN</span> fulfillment_status | |
| 861 | <span class="k">ENUM</span>('not_required','new','pending','processing','printing','printed','packed', | |
| 862 | 'shipped','delivered','completed','on_hold','cancelled','canceled','returned') | |
| 863 | <span class="k">NOT NULL DEFAULT</span> 'not_required';</code></pre> | |
| 864 | ||
| 865 | <p>The full <code>CREATE TABLE</code> bodies (with all foreign-key constraints + indexes) are in <code>db_schema.sql</code> under section §14. UI surfaces consuming this:</p> | |
| 866 | <ul> | |
| 867 | <li><code>/fulfill.cgi</code> — status-tabbed list of physical orders</li> | |
| 868 | <li><code>/fulfill_order.cgi?order_id=N</code> — per-order detail (addresses, items, label/tracking, notes, status changes)</li> | |
| 869 | <li><code>/shipping_settings.cgi</code> — origin address, carriers, zones (rate rules)</li> | |
| 870 | <li><code>/print_label.cgi?label_id=N</code> — printable label + packing slip</li> | |
| 871 | </ul> | |
| 872 | <p>The rate estimator on the order detail page walks <code>shipping_zones</code> in <code>sort_order</code> and picks the first whose <code>country_codes</code> list matches the buyer's destination. Live carrier API integration (USPS / UPS / FedEx) is stubbed: the credentials forms in <code>/shipping_settings.cgi</code> save to <code>shipping_carriers</code> but the dispatcher uses the rule-based estimator until an API class is wired. Labels are recorded manually (you paste the tracking number from the carrier's site); <code>/print_label.cgi</code> renders a print-ready HTML page (not carrier-accepted postage — that needs the live API).</p> | |
| 873 | ||
| 874 | <h3>5.13 — Listing upload rename (<code>/upload.cgi</code> → <code>/upload_model.cgi</code>)</h3> | |
| 875 | <p>The upload form was renamed so the URL maps to the DB tables it writes (<code>models</code>, <code>model_files</code>, <code>model_images</code>, <code>model_tags</code>). The old <code>/upload.cgi</code> remains as a tiny 301 stub so any external bookmarks and marketplace-export links keep working; everything internal now points at <code>/upload_model.cgi</code>. The sidebar "New Listing" entry was removed — the upload action lives on <code>/models.cgi</code> as an "Upload new model" button. No DB changes for this rename.</p> | |
| 876 | ||
| 877 | <h3>5.10 — Compatibility note (MySQL / MariaDB versions)</h3> | |
| 878 | <p>The schema is hand-tuned for portability. It runs on <strong>MariaDB 5.5+ and MySQL 5.5+</strong> without changes thanks to several deliberate trade-offs:</p> | |
| 879 | <ul> | |
| 880 | <li><code>TIMESTAMP</code> instead of <code>DATETIME</code> — old MySQL only allowed <code>CURRENT_TIMESTAMP</code> defaults on <code>TIMESTAMP</code>. Note: <code>TIMESTAMP</code> values can't go past 2038-01-19 — a known issue we'll address by migrating to <code>DATETIME</code> when on MySQL 5.6+ exclusively.</li> | |
| 881 | <li><code>BEFORE INSERT</code> / <code>BEFORE UPDATE</code> triggers fill <code>created_at</code> and <code>updated_at</code> — pre-MySQL-5.6.5 only allows one <code>CURRENT_TIMESTAMP</code> column per table.</li> | |
| 882 | <li><code>LONGTEXT</code> instead of <code>JSON</code> — JSON column type didn't arrive until MariaDB 10.2 / MySQL 5.7. App code parses JSON on read.</li> | |
| 883 | <li>No <code>FULLTEXT</code> indexes — MariaDB 5.5 only supports FULLTEXT on MyISAM, and FULLTEXT on InnoDB scales poorly anyway. Catalog search uses <code>LIKE</code>; for serious search at scale plug in Meilisearch or Typesense.</li> | |
| 884 | <li><code>VARCHAR(191)</code> on indexed string columns — old InnoDB caps index keys at 767 bytes, and 191×4 (utf8mb4) = 764. <code>email</code>, <code>slug</code>, and <code>custom_domain</code> all sit at 191.</li> | |
| 885 | <li>No <code>INET6_ATON()</code> in SQL — added in MariaDB 10.0+. The app packs IPs in Perl using <code>Socket::inet_pton</code> instead.</li> | |
| 886 | <li>No <code>utf8mb4_0900_ai_ci</code> collation — that's MySQL 8.0+ only. Server default <code>utf8mb4</code> collation is fine.</li> | |
| 887 | </ul> | |
| 888 | <p>Upgrading to MariaDB 10.5+ or MySQL 8.0+ is recommended when feasible — you get JSON validation, fractional-second timestamps, native FULLTEXT on InnoDB, and the larger 3072-byte index limit, but none of it is blocking.</p> | |
| 889 | </section> | |
| 890 | ||
| 891 | <!-- 6. CONFIG --> | |
| 892 | <section id="config"> | |
| 893 | <h2><span class="num">06</span> Configure the app</h2> | |
| 894 | <p>Two files need editing before the site can boot.</p> | |
| 895 | ||
| 896 | <h3>6.1 — Database credentials</h3> | |
| 897 | <p>Open <code>MODS/DBConnect.pm</code> and fill in the top block (lines 5-9):</p> | |
| 898 | ||
| 899 | <pre data-label="MODS/DBConnect.pm"><code><span class="c"># Default DB — used by every .cgi page on shop.3dshawn.com</span> | |
| 900 | <span class="k">my</span> <span class="v">$default_db_host</span> = <span class="s">'localhost'</span>; <span class="c"># or RDS endpoint</span> | |
| 901 | <span class="k">my</span> <span class="v">$default_db_name</span> = <span class="s">'SHOPCART'</span>; | |
| 902 | <span class="k">my</span> <span class="v">$default_db_login</span> = <span class="s">'shopcart'</span>; | |
| 903 | <span class="k">my</span> <span class="v">$default_db_password</span> = <span class="s">'CHANGE_ME_STRONG_PASSWORD'</span>; | |
| 904 | <span class="k">my</span> <span class="v">$default_db_port</span> = <span class="s">'3306'</span>;</code></pre> | |
| 905 | ||
| 906 | <h3>6.2 — ShopCart settings</h3> | |
| 907 | <p>Open <code>MODS/ShopCart/Config.pm</code>. The defaults are fine for most installs — confirm these:</p> | |
| 908 | ||
| 909 | <pre data-label="MODS/ShopCart/Config.pm"><code><span class="v">database_name</span> => <span class="s">'SHOPCART'</span>, <span class="c"># match your CREATE DATABASE</span> | |
| 910 | <span class="v">website_title</span> => <span class="s">'ShopCart — Online Seller Platform'</span>, | |
| 911 | <span class="v">cookie_name</span> => <span class="s">'shopcart_session'</span>, | |
| 912 | <span class="v">session_minutes</span> => <span class="n">720</span>, <span class="c"># 12h</span> | |
| 913 | <span class="v">secure_cookies</span> => <span class="n">1</span>, <span class="c"># 1 = HTTPS only</span> | |
| 914 | <span class="v">assets</span> => <span class="s">'/assets'</span>, | |
| 915 | <span class="v">from_email</span> => <span class="s">'noreply@shop.3dshawn.com'</span>,</code></pre> | |
| 916 | ||
| 917 | <p>If you're on plain HTTP during initial testing, temporarily set <code>secure_cookies => 0</code> so cookies are sent. Switch back to <code>1</code> once HTTPS is live.</p> | |
| 918 | ||
| 919 | <h3>6.3 — (optional) Node-name tag</h3> | |
| 920 | <p><code>MODS/Config.pm</code> reads <code>MODS/nodename.txt</code> to display a "STAGING" corner banner. The read is now optional — if the file is missing, the banner is silently disabled. To enable on staging:</p> | |
| 921 | ||
| 922 | <pre data-label="bash"><code><span class="p">$</span> <span class="k">echo</span> <span class="s">"shopcart-stage-01"</span> > /var/www/vhosts/3dshawn.com/shop.3dshawn.com/MODS/nodename.txt</code></pre> | |
| 923 | </section> | |
| 924 | ||
| 925 | <!-- 7. APACHE --> | |
| 926 | <section id="apache"> | |
| 927 | <h2><span class="num">07</span> Apache / vhost</h2> | |
| 928 | <p>ShopCart is plain CGI — every <code>.cgi</code> file should be executed, every other request served as a static file. <strong>The recommended approach is .htaccess</strong> — it works on Plesk, cPanel, DirectAdmin and bare Apache without touching the main server config.</p> | |
| 929 | ||
| 930 | <h3>7.1 — Three .htaccess files (already in the source bundle)</h3> | |
| 931 | <p>Three <code>.htaccess</code> files ship with the project. Upload all three; together they handle CGI execution and lock down internal directories. <strong>Do NOT use <code><Directory></code> blocks in .htaccess</strong> — Apache rejects the entire file with the cryptic error <code>"<Directory not allowed here"</code> and returns 500 on every request.</p> | |
| 932 | ||
| 933 | <pre data-label=".htaccess (webroot)"><code><span class="c"># /var/www/vhosts/3dshawn.com/shop.3dshawn.com/.htaccess</span> | |
| 934 | ||
| 935 | <span class="k">Options</span> +ExecCGI -Indexes +FollowSymLinks | |
| 936 | <span class="k">AddHandler</span> cgi-script .cgi | |
| 937 | <span class="k">DirectoryIndex</span> index.cgi index.html | |
| 938 | ||
| 939 | <span class="c"># Block direct download of source / data / log files</span> | |
| 940 | <<span class="k">FilesMatch</span> "\.(sql|pm|log|bak|prev|dat|db)$"> | |
| 941 | <span class="k">Require</span> all denied | |
| 942 | </<span class="k">FilesMatch</span>> | |
| 943 | ||
| 944 | <span class="c"># Long-cache static assets</span> | |
| 945 | <<span class="k">FilesMatch</span> "\.(css|js|woff2?|ttf|webp|png|jpg|svg|ico)$"> | |
| 946 | <<span class="k">IfModule</span> mod_headers.c> | |
| 947 | <span class="k">Header</span> set Cache-Control "public, max-age=31536000, immutable" | |
| 948 | </<span class="k">IfModule</span>> | |
| 949 | </<span class="k">FilesMatch</span>> | |
| 950 | ||
| 951 | <span class="c"># Light security headers</span> | |
| 952 | <<span class="k">IfModule</span> mod_headers.c> | |
| 953 | <span class="k">Header</span> always set X-Content-Type-Options "nosniff" | |
| 954 | <span class="k">Header</span> always set X-Frame-Options "SAMEORIGIN" | |
| 955 | <span class="k">Header</span> always set Referrer-Policy "strict-origin-when-cross-origin" | |
| 956 | </<span class="k">IfModule</span>></code></pre> | |
| 957 | ||
| 958 | <pre data-label=".htaccess (MODS/ and TEMPLATES/)"><code><span class="c"># MODS/.htaccess — and identical TEMPLATES/.htaccess</span> | |
| 959 | <span class="k">Require</span> all denied</code></pre> | |
| 960 | ||
| 961 | <p>WinSCP / FileZilla hide dotfiles by default. In WinSCP, toggle <strong>Options → Preferences → Panels → Show hidden files</strong> (or press Ctrl+Alt+H) so the <code>.htaccess</code> files actually transfer.</p> | |
| 962 | ||
| 963 | <h3>7.2 — Verify .htaccess uploaded</h3> | |
| 964 | <pre data-label="bash"><code><span class="p">$</span> <span class="k">ls</span> -la /var/www/vhosts/3dshawn.com/shop.3dshawn.com/.htaccess | |
| 965 | <span class="p">$</span> <span class="k">ls</span> -la /var/www/vhosts/3dshawn.com/shop.3dshawn.com/MODS/.htaccess | |
| 966 | <span class="p">$</span> <span class="k">ls</span> -la /var/www/vhosts/3dshawn.com/shop.3dshawn.com/TEMPLATES/.htaccess | |
| 967 | ||
| 968 | <span class="c"># Sanity-check: no <Directory> blocks in any of them</span> | |
| 969 | <span class="p">$</span> <span class="k">grep</span> -r "<Directory" /var/www/vhosts/3dshawn.com/shop.3dshawn.com/.htaccess /var/www/vhosts/3dshawn.com/shop.3dshawn.com/MODS/.htaccess /var/www/vhosts/3dshawn.com/shop.3dshawn.com/TEMPLATES/.htaccess | |
| 970 | <span class="c"># (no output = good)</span></code></pre> | |
| 971 | ||
| 972 | <h3>7.3 — Plesk specifics</h3> | |
| 973 | <p>On Plesk hosts, <strong>don't edit the vhost config files</strong> — Plesk regenerates them and clobbers your changes. Instead:</p> | |
| 974 | <ul> | |
| 975 | <li>Upload the three <code>.htaccess</code> files via SFTP/WinSCP (works as-is)</li> | |
| 976 | <li>For things you can't put in .htaccess (custom error pages, mod_rewrite tweaks at vhost scope), use <strong>Websites & Domains → shop.3dshawn.com → Apache & nginx Settings → Additional Apache directives for HTTPS</strong></li> | |
| 977 | <li>Plesk creates the vhost log at <code>/var/www/vhosts/system/shop.3dshawn.com/logs/error_log</code> — that's where CGI errors land, not <code>/var/log/httpd/error_log</code></li> | |
| 978 | </ul> | |
| 979 | ||
| 980 | <h3>7.4 — Bare-Apache vhost (non-Plesk hosts)</h3> | |
| 981 | <p>If you control the server config directly, you can skip the .htaccess files and put everything in the vhost config — that's more efficient because Apache doesn't re-read .htaccess on every request:</p> | |
| 982 | ||
| 983 | <pre data-label="apache vhost (bare Apache only)"><code><<span class="k">VirtualHost</span> *:443> | |
| 984 | <span class="k">ServerName</span> shop.3dshawn.com | |
| 985 | <span class="k">DocumentRoot</span> /var/www/vhosts/3dshawn.com/shop.3dshawn.com | |
| 986 | ||
| 987 | <span class="k">SSLEngine</span> on | |
| 988 | <span class="k">SSLCertificateFile</span> /etc/letsencrypt/live/shop.3dshawn.com/fullchain.pem | |
| 989 | <span class="k">SSLCertificateKeyFile</span> /etc/letsencrypt/live/shop.3dshawn.com/privkey.pem | |
| 990 | ||
| 991 | <<span class="k">Directory</span> /var/www/vhosts/3dshawn.com/shop.3dshawn.com> | |
| 992 | <span class="k">Options</span> +ExecCGI -Indexes +FollowSymLinks | |
| 993 | <span class="k">AddHandler</span> cgi-script .cgi | |
| 994 | <span class="k">DirectoryIndex</span> index.cgi index.html | |
| 995 | <span class="k">AllowOverride</span> None | |
| 996 | <span class="k">Require</span> all granted | |
| 997 | </<span class="k">Directory</span>> | |
| 998 | <<span class="k">Directory</span> /var/www/vhosts/3dshawn.com/shop.3dshawn.com/MODS> | |
| 999 | <span class="k">Require</span> all denied | |
| 1000 | </<span class="k">Directory</span>> | |
| 1001 | <<span class="k">Directory</span> /var/www/vhosts/3dshawn.com/shop.3dshawn.com/TEMPLATES> | |
| 1002 | <span class="k">Require</span> all denied | |
| 1003 | </<span class="k">Directory</span>> | |
| 1004 | <<span class="k">FilesMatch</span> "\.(sql|pm|log)$"> | |
| 1005 | <span class="k">Require</span> all denied | |
| 1006 | </<span class="k">FilesMatch</span>> | |
| 1007 | ||
| 1008 | <span class="k">ErrorLog</span> /var/log/httpd/shop.3dshawn.com-error.log | |
| 1009 | <span class="k">CustomLog</span> /var/log/httpd/shop.3dshawn.com-access.log combined | |
| 1010 | </<span class="k">VirtualHost</span>></code></pre> | |
| 1011 | ||
| 1012 | <p>Required Apache modules: <code>mod_cgi</code> or <code>mod_cgid</code>, <code>mod_headers</code>, <code>mod_ssl</code>, <code>mod_rewrite</code>. On RHEL/CentOS these are usually loaded by default; on Debian/Ubuntu run <code>a2enmod cgi headers ssl rewrite</code> and reload Apache.</p> | |
| 1013 | ||
| 1014 | <h3>7.5 — Wildcard subdomains for storefronts (optional)</h3> | |
| 1015 | <p>ShopCart lets every creator pick a subdomain (e.g. <code>grimforge</code>) at signup. Without any extra setup, those storefronts are reachable at <code>shop.3dshawn.com/store.cgi?subdomain=grimforge</code> — that always works. If you want the prettier <code>grimforge.shop.3dshawn.com</code> URL to work too, do the steps below <strong>once</strong> on your platform, and every existing and future creator's subdomain becomes live automatically.</p> | |
| 1016 | ||
| 1017 | <p style="color:var(--col-text-3);font-size:13px;margin-top:-8px"><em>This is optional. Skip the section entirely if you're happy with the path-based URLs — nothing in the codebase depends on subdomains being configured.</em></p> | |
| 1018 | ||
| 1019 | <div class="banner info" style="margin-top:14px"> | |
| 1020 | <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/></svg> | |
| 1021 | <div class="text-xs"><strong>Critical Plesk gotcha:</strong> Plesk runs <strong>nginx as a reverse proxy in front of Apache</strong>. nginx routes requests by <code>server_name</code> <em>before</em> Apache ever sees them. A simple <code>ServerAlias *.shop.3dshawn.com</code> in Apache directives <strong>does nothing on Plesk</strong> — nginx still doesn't recognize the hostname and serves a 72-byte fallback page. The signs you're hitting this: response has <code>Server: nginx</code>, tiny <code>Content-Length</code>, and a <code>Last-Modified</code> header from years ago (static fallback file). The fix is Step 2 below — add a wildcard subdomain through the Plesk UI, which regenerates <strong>both</strong> nginx and Apache configs to accept the wildcard.</div> | |
| 1022 | </div> | |
| 1023 | ||
| 1024 | <h4 style="font-family:var(--font-display);font-size:15px;color:var(--col-text);margin-top:24px;margin-bottom:8px">Step 1 — Wildcard DNS A-record</h4> | |
| 1025 | <p>Add a wildcard A-record at whichever DNS provider hosts <code>shop.3dshawn.com</code> (Plesk DNS, Cloudflare, Route 53, GoDaddy — wherever your nameservers point). Most provider UIs:</p> | |
| 1026 | <ul> | |
| 1027 | <li><strong>Type:</strong> A</li> | |
| 1028 | <li><strong>Host / Name:</strong> <code>*</code> (some UIs want <code>*.shop.3dshawn.com</code>; both mean the same thing)</li> | |
| 1029 | <li><strong>Value / Points to:</strong> your server's public IPv4 address</li> | |
| 1030 | <li><strong>TTL:</strong> 3600 (1 hour) is fine</li> | |
| 1031 | </ul> | |
| 1032 | <p>If you also serve IPv6, add an AAAA record with the same wildcard host pointing at your server's IPv6 address.</p> | |
| 1033 | ||
| 1034 | <p>Verify after DNS propagates (give it a minute):</p> | |
| 1035 | <pre data-label="bash"><code><span class="p">$</span> <span class="k">dig</span> +short anything.shop.3dshawn.com | |
| 1036 | <span class="c"># should print your server IP. Try a different "anything" — same answer.</span></code></pre> | |
| 1037 | ||
| 1038 | <h4 style="font-family:var(--font-display);font-size:15px;color:var(--col-text);margin-top:24px;margin-bottom:8px">Step 2 — Plesk: add a wildcard subdomain (recommended path)</h4> | |
| 1039 | <p>This is the cleanest way on Plesk. It regenerates both nginx and Apache to handle wildcards properly — bypasses the "nginx ignores ServerAlias" trap entirely.</p> | |
| 1040 | ||
| 1041 | <ol> | |
| 1042 | <li>Plesk panel → <strong>Websites & Domains</strong></li> | |
| 1043 | <li>Click <strong>Add Subdomain</strong> (button near the top of the panel)</li> | |
| 1044 | <li><strong>Subdomain name:</strong> <code>*</code> (a literal asterisk)</li> | |
| 1045 | <li><strong>Document root:</strong> set to <code>httpdocs</code> — the <em>same</em> folder as the parent <code>shop.3dshawn.com</code> uses. <strong>Don't</strong> let Plesk create a separate folder; we want the wildcard vhost serving the same files as the main vhost.</li> | |
| 1046 | <li>Click <strong>OK</strong>. Plesk will run through "Adding a domain → Configuring the DNS zone → Creating physical hosting → Configuring PHP" — takes ~10 seconds.</li> | |
| 1047 | </ol> | |
| 1048 | ||
| 1049 | <p>After Plesk finishes:</p> | |
| 1050 | <ul> | |
| 1051 | <li>A new vhost <code>*.shop.3dshawn.com</code> appears in your domains list with status <code>Active</code></li> | |
| 1052 | <li>nginx and Apache both have the wildcard registered</li> | |
| 1053 | <li>The vhost shares <code>/var/www/vhosts/3dshawn.com/shop.3dshawn.com/</code> with the main site</li> | |
| 1054 | </ul> | |
| 1055 | ||
| 1056 | <p><strong>If Plesk creates a separate folder anyway</strong> (e.g. <code>/var/www/vhosts/shop.3dshawn.com/wildcard.shop.3dshawn.com/httpdocs/</code>): in the new <code>*.shop.3dshawn.com</code> domain row, click <strong>Hosting & DNS</strong> → <strong>Hosting Settings</strong>, change <strong>Document root</strong> to <code>httpdocs</code> (the parent's), save. Otherwise the wildcard vhost will serve from an empty directory.</p> | |
| 1057 | ||
| 1058 | <p><strong>If Plesk shows a different IP</strong> for the wildcard subdomain than your DNS A-record points at: same screen, switch the IP to match. Usually it's a non-issue (Plesk's display IP is just a panel hint) but the curl test in Step 4 will reveal it for sure.</p> | |
| 1059 | ||
| 1060 | <h4 style="font-family:var(--font-display);font-size:15px;color:var(--col-text);margin-top:24px;margin-bottom:8px">Step 2b — Old way (don't use unless Step 2 isn't possible)</h4> | |
| 1061 | <p>If your Plesk version blocks <code>*</code> as a subdomain name, or you're not on Plesk at all, you can manually paste these into the appropriate config field (Plesk's <strong>Apache & nginx Settings → Additional directives for HTTPS</strong>, or the bare-Apache vhost from §7.4):</p> | |
| 1062 | ||
| 1063 | <pre data-label="apache directives (fallback)"><code><span class="k">ServerAlias</span> *.shop.3dshawn.com</code></pre> | |
| 1064 | ||
| 1065 | <p>For nginx (Plesk's <strong>Additional nginx directives</strong> field):</p> | |
| 1066 | <pre data-label="nginx directives (fallback)"><code><span class="k">server_name</span> shop.3dshawn.com www.shop.3dshawn.com *.shop.3dshawn.com;</code></pre> | |
| 1067 | ||
| 1068 | <p>The actual subdomain → <code>store.cgi?subdomain=…</code> rewrite lives in <code>.htaccess</code> (see Step 3) regardless of which path you choose.</p> | |
| 1069 | ||
| 1070 | <h4 style="font-family:var(--font-display);font-size:15px;color:var(--col-text);margin-top:24px;margin-bottom:8px">Step 3 — Rewrite logic in .htaccess (single source of truth)</h4> | |
| 1071 | <p>The shipped <code>.htaccess</code> already includes the wildcard → <code>store.cgi</code> rewrite. Putting it in <code>.htaccess</code> instead of Plesk's per-vhost directives field means: (a) one source of truth, (b) Plesk regenerating its config can't wipe it, (c) it applies to <em>both</em> the main shop.3dshawn.com vhost and the new <code>*.shop.3dshawn.com</code> vhost since they share <code>httpdocs/</code>.</p> | |
| 1072 | ||
| 1073 | <pre data-label=".htaccess (already in your webroot)"><code><<span class="k">IfModule</span> mod_rewrite.c> | |
| 1074 | <span class="k">RewriteEngine</span> On | |
| 1075 | ||
| 1076 | <span class="c"># Don't rewrite the canonical hosts</span> | |
| 1077 | <span class="k">RewriteCond</span> %{HTTP_HOST} !^shopcart\.com$ [NC] | |
| 1078 | <span class="k">RewriteCond</span> %{HTTP_HOST} !^www\.shopcart\.com$ [NC] | |
| 1079 | ||
| 1080 | <span class="c"># grimforge.shop.3dshawn.com → /store.cgi?subdomain=grimforge</span> | |
| 1081 | <span class="k">RewriteCond</span> %{HTTP_HOST} ^([a-z0-9-]+)\.shopcart\.com$ [NC] | |
| 1082 | <span class="k">RewriteRule</span> ^/?$ /store.cgi?subdomain=%1 [L,QSA] | |
| 1083 | </<span class="k">IfModule</span>></code></pre> | |
| 1084 | ||
| 1085 | <h4 style="font-family:var(--font-display);font-size:15px;color:var(--col-text);margin-top:24px;margin-bottom:8px">Step 3 — Wildcard SSL certificate</h4> | |
| 1086 | <p>Plesk's default Let's Encrypt cert covers only <code>shop.3dshawn.com</code> and <code>www.shop.3dshawn.com</code>, so <code>grimforge.shop.3dshawn.com</code> would show a browser cert warning. Get a wildcard cert that covers <code>*.shop.3dshawn.com</code>:</p> | |
| 1087 | ||
| 1088 | <ol> | |
| 1089 | <li>Plesk panel → <strong>Websites & Domains</strong> → <code>shop.3dshawn.com</code> → <strong>SSL/TLS Certificates</strong> (or click <strong>SSL It!</strong> if that extension is installed)</li> | |
| 1090 | <li>Click <strong>Get it free</strong> (Let's Encrypt) or <strong>Install</strong> if SSL It! is already configured</li> | |
| 1091 | <li>Tick <strong>"Issue a wildcard SSL certificate"</strong> (or "Include a wildcard domain")</li> | |
| 1092 | <li>Tick <strong>"Secure www subdomain"</strong> as well</li> | |
| 1093 | <li>Click <strong>Get it free</strong> / <strong>Install</strong></li> | |
| 1094 | </ol> | |
| 1095 | ||
| 1096 | <div class="banner warning" style="margin-top:14px"> | |
| 1097 | <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z"/><line x1="12" y1="9" x2="12" y2="13"/></svg> | |
| 1098 | <div class="text-xs"><strong>Wildcard certificates require DNS-01 validation</strong> — Let's Encrypt won't issue them over the simpler HTTP-01. Plesk handles this automatically <strong>if Plesk manages the DNS for shop.3dshawn.com</strong>. If your DNS is somewhere else (Cloudflare, Route 53, etc.) you'll need to either: (a) move DNS to Plesk-managed nameservers; (b) use Plesk's "External DNS" extension if available for your provider; or (c) issue the cert by hand with <code>certbot --manual --preferred-challenges dns</code> and upload it to Plesk.</div> | |
| 1099 | </div> | |
| 1100 | ||
| 1101 | <h4 style="font-family:var(--font-display);font-size:15px;color:var(--col-text);margin-top:24px;margin-bottom:8px">Step 4 — Verify</h4> | |
| 1102 | <pre data-label="bash"><code><span class="p">$</span> <span class="k">curl</span> -I https://demo.shop.3dshawn.com/ | |
| 1103 | <span class="c"># Should return 200 OK, no cert warning. The body will be the demo storefront</span> | |
| 1104 | <span class="c"># (since "demo" doesn't match any real subdomain in the storefronts table).</span> | |
| 1105 | ||
| 1106 | <span class="p">$</span> <span class="k">curl</span> -I https://<your-real-subdomain>.shop.3dshawn.com/ | |
| 1107 | <span class="c"># Should return 200 OK with your real storefront's hero.</span></code></pre> | |
| 1108 | ||
| 1109 | <p>Once those return clean 200s, every existing and future creator on the platform automatically has a working <code><subdomain>.shop.3dshawn.com</code> URL — no per-creator config, no DNS work, no SSL provisioning. The wildcard handles them all.</p> | |
| 1110 | ||
| 1111 | <h4 style="font-family:var(--font-display);font-size:15px;color:var(--col-text);margin-top:24px;margin-bottom:8px">Custom domains (per creator)</h4> | |
| 1112 | <p>Wildcard subdomains are the platform-wide default. If a creator wants their own brand domain (<code>grimforge.studio</code>), they:</p> | |
| 1113 | <ol> | |
| 1114 | <li>Add an A-record at their domain registrar pointing <code>grimforge.studio</code> at your server IP</li> | |
| 1115 | <li>Enter <code>grimforge.studio</code> in the <strong>Domain & SSL</strong> tab on their storefront editor</li> | |
| 1116 | <li><code>store.cgi</code> resolves the storefront via <code>WHERE custom_domain = $HTTP_HOST</code> automatically</li> | |
| 1117 | </ol> | |
| 1118 | <p>SSL for custom domains is handled per-creator: the platform owner adds each domain as a Plesk "Domain Alias" or separate vhost and provisions a Let's Encrypt cert for it. (A future iteration of this app will automate that step via the Plesk API; for now it's a manual paste each time a creator adds a custom domain.)</p> | |
| 1119 | </section> | |
| 1120 | ||
| 1121 | <!-- 8. PERMISSIONS --> | |
| 1122 | <section id="permissions"> | |
| 1123 | <h2><span class="num">08</span> File permissions</h2> | |
| 1124 | <p>Apache must be able to read everything and execute the <code>.cgi</code> files.</p> | |
| 1125 | ||
| 1126 | <pre data-label="bash"><code><span class="p">$</span> <span class="k">cd</span> /var/www/vhosts/3dshawn.com/shop.3dshawn.com | |
| 1127 | ||
| 1128 | <span class="c"># Owner: your deploy user, group: web server</span> | |
| 1129 | <span class="p">$</span> <span class="k">chown</span> -R deploy:apache . <span class="c"># or :www-data on Debian</span> | |
| 1130 | ||
| 1131 | <span class="c"># Default: 750 dirs, 640 files</span> | |
| 1132 | <span class="p">$</span> <span class="k">find</span> . -type d -exec chmod 750 {} \; | |
| 1133 | <span class="p">$</span> <span class="k">find</span> . -type f -exec chmod 640 {} \; | |
| 1134 | ||
| 1135 | <span class="c"># CGI scripts must be executable</span> | |
| 1136 | <span class="p">$</span> <span class="k">chmod</span> 750 *.cgi | |
| 1137 | ||
| 1138 | <span class="c"># Schema file should not be web-readable</span> | |
| 1139 | <span class="p">$</span> <span class="k">chmod</span> 600 db_schema.sql | |
| 1140 | <span class="p">$</span> <span class="k">chmod</span> 600 MODS/DBConnect.pm <span class="c"># contains DB password</span> | |
| 1141 | ||
| 1142 | <span class="c"># DB error log must be writable by Apache</span> | |
| 1143 | <span class="p">$</span> <span class="k">touch</span> MODS/DB_ERRORS.log | |
| 1144 | <span class="p">$</span> <span class="k">chown</span> apache:apache MODS/DB_ERRORS.log | |
| 1145 | <span class="p">$</span> <span class="k">chmod</span> 660 MODS/DB_ERRORS.log</code></pre> | |
| 1146 | ||
| 1147 | <p>If SELinux is enforcing, the .cgi context must be <code>httpd_sys_script_exec_t</code>:</p> | |
| 1148 | <pre data-label="bash"><code><span class="p">$</span> <span class="k">restorecon</span> -Rv /var/www/vhosts/3dshawn.com/shop.3dshawn.com | |
| 1149 | <span class="p">$</span> <span class="k">setsebool</span> -P httpd_can_network_connect_db on <span class="c"># if DB is remote</span></code></pre> | |
| 1150 | </section> | |
| 1151 | ||
| 1152 | <!-- 9. SMOKE TEST --> | |
| 1153 | <section id="smoke-test"> | |
| 1154 | <h2><span class="num">09</span> Smoke test</h2> | |
| 1155 | <p>Before opening to the world, sanity-check from the command line.</p> | |
| 1156 | ||
| 1157 | <h3>9.1 — Perl syntax check</h3> | |
| 1158 | <pre data-label="bash"><code><span class="p">$</span> <span class="k">cd</span> /var/www/vhosts/3dshawn.com/shop.3dshawn.com | |
| 1159 | <span class="p">$</span> <span class="k">for</span> f <span class="k">in</span> *.cgi; <span class="k">do</span> <span class="k">perl</span> -I . -c <span class="s">"$f"</span>; <span class="k">done</span> | |
| 1160 | ||
| 1161 | <span class="c"># Each line should end with: ... syntax OK</span></code></pre> | |
| 1162 | ||
| 1163 | <h3>9.2 — Database reachability</h3> | |
| 1164 | <pre data-label="bash"><code><span class="p">$</span> <span class="k">perl</span> -I . -e <span class="s">'use MODS::DBConnect; my $dbh = MODS::DBConnect->new->db_connect; print $dbh ? "DB ok\n" : "DB FAIL\n"'</span></code></pre> | |
| 1165 | ||
| 1166 | <h3>9.3 — Browser check</h3> | |
| 1167 | <ul class="check-list"> | |
| 1168 | <li>Visit <code>https://shop.3dshawn.com/index.cgi</code> → marketing page loads</li> | |
| 1169 | <li>Visit <code>https://shop.3dshawn.com/dashboard.cgi</code> → bounces to <code>/login.cgi</code> (auth working)</li> | |
| 1170 | <li>Visit <code>https://shop.3dshawn.com/login.cgi</code> → form renders</li> | |
| 1171 | <li>View page source: assets load from <code>/assets/css/site.css</code> & <code>/assets/javascript/site.js</code></li> | |
| 1172 | <li>DevTools Network tab: no 404s on graphs.js, fonts, or images</li> | |
| 1173 | </ul> | |
| 1174 | </section> | |
| 1175 | ||
| 1176 | <!-- 10. FIRST USER --> | |
| 1177 | <section id="first-user"> | |
| 1178 | <h2><span class="num">10</span> Create the first user</h2> | |
| 1179 | <p>Easiest path: visit <code>/signup.cgi</code> in a browser, fill the form, submit. The handler hashes the password (Argon2id if installed, otherwise PBKDF2-SHA256), writes a row into <code>users</code>, and signs you in immediately.</p> | |
| 1180 | ||
| 1181 | <p>Want to be admin from the start? After creating the account, bump your <code>plan_tier</code> and <code>trust_level</code>:</p> | |
| 1182 | ||
| 1183 | <pre data-label="mysql"><code><span class="p">mysql></span> <span class="k">USE</span> SHOPCART; | |
| 1184 | <span class="p">mysql></span> <span class="k">UPDATE</span> users | |
| 1185 | <span class="k">SET</span> plan_tier = <span class="s">'studio'</span>, | |
| 1186 | trust_level = <span class="n">10</span>, | |
| 1187 | email_verified_at = <span class="k">NOW</span>() | |
| 1188 | <span class="k">WHERE</span> email = <span class="s">'you@shop.3dshawn.com'</span>;</code></pre> | |
| 1189 | ||
| 1190 | <p>Now <code>/admin.cgi</code> is available — that page exposes feature flags, integration health, system status, and the audit log.</p> | |
| 1191 | </section> | |
| 1192 | ||
| 1193 | <!-- 11. EMAIL --> | |
| 1194 | <section id="email"> | |
| 1195 | <h2><span class="num">11</span> Transactional email <span style="color:var(--col-text-3);font-weight:400;font-size:14px">(optional)</span></h2> | |
| 1196 | <p>ShopCart has no email plumbing wired in yet — that's deliberate. The spec calls for Postmark or AWS SES; pick one and add a small <code>MODS::ShopCart::Mail</code> wrapper. Hooks where you'll want to send mail:</p> | |
| 1197 | <ul> | |
| 1198 | <li>signup → "verify your email"</li> | |
| 1199 | <li>password reset</li> | |
| 1200 | <li>order paid → buyer receipt + creator notification</li> | |
| 1201 | <li>integration degraded → creator alert</li> | |
| 1202 | <li>weekly performance digest</li> | |
| 1203 | </ul> | |
| 1204 | <p>Until then, the signup flow auto-verifies (<code>email_verified_at = NOW()</code> on insert) so you can test end-to-end without a mail provider.</p> | |
| 1205 | </section> | |
| 1206 | ||
| 1207 | <!-- 12. STRIPE --> | |
| 1208 | <section id="stripe"> | |
| 1209 | <h2><span class="num">12</span> Stripe <span style="color:var(--col-text-3);font-weight:400;font-size:14px">(optional, for billing)</span></h2> | |
| 1210 | <p>The schema has columns ready for Stripe — <code>users.stripe_customer_id</code>, <code>users.stripe_subscription_id</code>, <code>storefronts.stripe_account_id</code>, <code>orders.stripe_payment_intent_id</code>, etc. To wire it up:</p> | |
| 1211 | <ol> | |
| 1212 | <li>Add your publishable key to <code>MODS/ShopCart/Config.pm</code> → <code>stripe_publishable</code></li> | |
| 1213 | <li>Store the secret key in an environment variable, not in code (<code>SetEnv STRIPE_SECRET_KEY ...</code> in the Apache vhost)</li> | |
| 1214 | <li>Build <code>MODS::ShopCart::Stripe</code> for SaaS subscriptions (Stripe Billing) and creator payouts (Stripe Connect Express)</li> | |
| 1215 | <li>Add a <code>webhook.cgi</code> to receive Stripe events into <code>orders</code> / <code>refunds</code> / <code>disputes</code></li> | |
| 1216 | </ol> | |
| 1217 | </section> | |
| 1218 | ||
| 1219 | <!-- 13. R2 --> | |
| 1220 | <section id="r2"> | |
| 1221 | <h2><span class="num">13</span> R2 / B2 file storage <span style="color:var(--col-text-3);font-weight:400;font-size:14px">(optional)</span></h2> | |
| 1222 | <p><code>model_files</code> has a <code>storage_provider ENUM('r2','b2','s3','local')</code> column and a <code>storage_key</code> string. For a pilot you can use <code>local</code> and store files under a non-web-readable directory (e.g. <code>/var/shopcart/files/</code>). When you're ready to scale:</p> | |
| 1223 | <ul> | |
| 1224 | <li>Sign up for Cloudflare R2 (zero egress fees, S3-compatible)</li> | |
| 1225 | <li>Create a bucket — match the name in <code>MODS/ShopCart/Config.pm → r2_bucket</code></li> | |
| 1226 | <li>Add <code>R2_ACCESS_KEY_ID</code> / <code>R2_SECRET_ACCESS_KEY</code> as environment variables</li> | |
| 1227 | <li>Build <code>MODS::ShopCart::Storage</code> with the same interface for both <code>local</code> and <code>r2</code> backends — the existing <code>storage_provider</code> column lets you migrate one model at a time</li> | |
| 1228 | </ul> | |
| 1229 | </section> | |
| 1230 | ||
| 1231 | <!-- 14. TROUBLESHOOTING --> | |
| 1232 | <section id="troubleshooting"> | |
| 1233 | <h2><span class="num">14</span> Troubleshooting</h2> | |
| 1234 | ||
| 1235 | <h3>Where do CGI errors actually go?</h3> | |
| 1236 | <p>NOT to <code>/var/log/httpd/error_log</code> — that's the server-wide log and it only has SSL/startup noise. The <strong>vhost-specific log</strong> is the one with your CGI errors:</p> | |
| 1237 | <pre data-label="bash"><code><span class="p">$</span> <span class="k">tail</span> -50 /var/www/vhosts/system/shop.3dshawn.com/logs/error_log <span class="c"># Plesk</span> | |
| 1238 | <span class="p">$</span> <span class="k">tail</span> -50 /var/log/httpd/shop.3dshawn.com-error.log <span class="c"># bare Apache</span></code></pre> | |
| 1239 | <p>If still no useful info, run the CGI from the shell — that bypasses Apache and prints any Perl error directly:</p> | |
| 1240 | <pre data-label="bash"><code><span class="p">$</span> <span class="k">cd</span> /var/www/vhosts/3dshawn.com/shop.3dshawn.com | |
| 1241 | <span class="p">$</span> <span class="k">perl</span> login.cgi</code></pre> | |
| 1242 | ||
| 1243 | <h3>500 on every page — ".htaccess: <Directory not allowed here"</h3> | |
| 1244 | <p>Caused by an <code>.htaccess</code> that contains <code><Directory></code> blocks. <code><Directory></code> and <code><VirtualHost></code> are <strong>illegal in .htaccess</strong> — they're for the main Apache config only. Apache rejects the entire .htaccess and 500s every request to that vhost. Use the .htaccess template in <a href="#apache">section 7</a> — it sticks to <code>Options</code>, <code>AddHandler</code>, <code>FilesMatch</code>, and <code>Header</code>, all of which are legal.</p> | |
| 1245 | ||
| 1246 | <h3>500 on every page — generic Perl compile error</h3> | |
| 1247 | <ul> | |
| 1248 | <li>Run <code>perl -I . -cw login.cgi</code> on the server — surfaces compile errors that the browser hides</li> | |
| 1249 | <li>Confirm <code>.cgi</code> shebang matches <code>which perl</code></li> | |
| 1250 | <li>Confirm file uploaded with Unix line endings, not CRLF: <code>dos2unix *.cgi MODS/*.pm MODS/ShopCart/*.pm TEMPLATES/shopcart_*.html .htaccess MODS/.htaccess TEMPLATES/.htaccess</code></li> | |
| 1251 | <li>Confirm <code>chmod 755 *.cgi</code> so they're executable</li> | |
| 1252 | </ul> | |
| 1253 | ||
| 1254 | <h3>"Can't locate MODS/Login.pm in @INC"</h3> | |
| 1255 | <p>The <code>use lib '/var/www/vhosts/3dshawn.com/shop.3dshawn.com';</code> line at the top of each .cgi must point at your actual web root. Verify with <code>head -15 dashboard.cgi</code>. Same goes for <code>MODS/ShopCart/Wrapper.pm</code> and <code>MODS/Template.pm</code>.</p> | |
| 1256 | ||
| 1257 | <h3>"Can't locate MODS/Urls.pm" or similar</h3> | |
| 1258 | <p>This appeared in a deploy when an old <code>MODS/Template.pm</code> tried to <code>use MODS::Urls</code> at compile time. Recent Template.pm uses <code>require</code> inside an <code>eval</code> so missing optional MODs are tolerated. If you see this, you have an outdated Template.pm — re-upload from the source bundle.</p> | |
| 1259 | ||
| 1260 | <h3>Signup says "That email is already in use, or an account error occurred"</h3> | |
| 1261 | <p>Three possible causes; debug from the shell:</p> | |
| 1262 | <pre data-label="bash"><code><span class="p">#</span> <span class="c"># Is the email actually a duplicate?</span> | |
| 1263 | <span class="p">$</span> <span class="k">perl</span> -I . -MMODS::DBConnect -e <span class="s">'</span> | |
| 1264 | my $dbh = MODS::DBConnect->new->db_connect; | |
| 1265 | my $rows = $dbh->selectall_arrayref(<span class="s">"SELECT id, email FROM `SHOPCART`.users"</span>, { Slice => {} }); | |
| 1266 | use Data::Dumper; print Dumper($rows); | |
| 1267 | <span class="s">'</span> | |
| 1268 | ||
| 1269 | <span class="p">#</span> <span class="c"># Is password hashing failing silently?</span> | |
| 1270 | <span class="p">$</span> <span class="k">perl</span> -e <span class="s">'use Digest::SHA qw(sha256_hex); print "sha256_hex ok\n"'</span> | |
| 1271 | ||
| 1272 | <span class="p">#</span> <span class="c"># Run signup directly — surfaces the actual DBI error if the INSERT fails</span> | |
| 1273 | <span class="p">$</span> REMOTE_ADDR=127.0.0.1 <span class="k">perl</span> -I . -MMODS::Login -e <span class="s">'</span> | |
| 1274 | my $login = MODS::Login->new; | |
| 1275 | my $id = $login->signup(<span class="s">"test\@example.com"</span>, <span class="s">"testpassword"</span>, <span class="s">"Test User"</span>, <span class="s">"free"</span>); | |
| 1276 | print defined $id ? <span class="s">"OK id=$id\n"</span> : <span class="s">"signup returned undef\n"</span>; | |
| 1277 | <span class="s">'</span></code></pre> | |
| 1278 | ||
| 1279 | <h3>"Access denied for user 'X'@'%' to database 'Y'"</h3> | |
| 1280 | <p>The DB user exists but lacks grants on the database — <strong>or</strong> you've got a case mismatch. If <code>MODS/DBConnect.pm</code> says <code>'SHOPCART'</code> but Plesk created <code>'SHOPCART'</code>, those are different databases on Linux.</p> | |
| 1281 | <pre data-label="bash"><code><span class="p">$</span> <span class="k">grep</span> -n WEBSTL MODS/DBConnect.pm MODS/ShopCart/Config.pm MODS/Config.pm | |
| 1282 | <span class="p">$</span> <span class="k">mysql</span> -u root -p -e <span class="s">"SHOW DATABASES LIKE '%ebstls%'"</span></code></pre> | |
| 1283 | <p>The case in the SQL output must match exactly what's in the three Config files.</p> | |
| 1284 | ||
| 1285 | <h3>SQL Error 1067, 1071, 1141, 1214, 1293 during schema load</h3> | |
| 1286 | <p>All of these are old-MariaDB compatibility issues that the shipped schema works around:</p> | |
| 1287 | <ul> | |
| 1288 | <li><strong>1067 "Invalid default value for 'created_at'"</strong> — pre-MySQL-5.6.5 doesn't allow <code>CURRENT_TIMESTAMP</code> default on <code>DATETIME</code>. Schema uses <code>TIMESTAMP</code>.</li> | |
| 1289 | <li><strong>1071 "Specified key was too long; max key length is 767 bytes"</strong> — old InnoDB index limit. Indexed VARCHARs are at 191 chars (191×4 bytes < 767).</li> | |
| 1290 | <li><strong>1141 "There is no such grant defined"</strong> — happens when running <code>SHOW GRANTS FOR 'X'@'localhost'</code> for a user that only exists at <code>'X'@'%'</code>. Plesk creates wildcard-host users only.</li> | |
| 1291 | <li><strong>1214 "The used table type doesn't support FULLTEXT"</strong> — MariaDB 5.5 InnoDB doesn't support FULLTEXT. Schema has no FULLTEXT indexes.</li> | |
| 1292 | <li><strong>1293 "Incorrect table definition; only one TIMESTAMP column with CURRENT_TIMESTAMP"</strong> — pre-MySQL-5.6.5 limit. Schema uses <code>BEFORE INSERT</code> / <code>BEFORE UPDATE</code> triggers instead.</li> | |
| 1293 | </ul> | |
| 1294 | <p>If you see one of these, your schema file is older than the deploy fixes. Re-export it from this bundle.</p> | |
| 1295 | ||
| 1296 | <h3>Login form posts but always says "Email or password is incorrect"</h3> | |
| 1297 | <ul> | |
| 1298 | <li>Hash format mismatch — if Crypt::Argon2 was installed when you signed up but isn't now (or vice versa), the stored hash format won't match what Login.pm's verify path can read. Re-create the user.</li> | |
| 1299 | <li>Cookie blocked: with <code>secure_cookies => 1</code> and plain HTTP the browser silently drops the Set-Cookie. Either enable HTTPS or set <code>secure_cookies => 0</code> in <code>MODS/ShopCart/Config.pm</code> for testing.</li> | |
| 1300 | <li>Account inactive — Login.pm only authenticates when <code>users.account_status = 'active'</code>. Plesk-created users get <code>'pending_verification'</code> by default; flip with <code>UPDATE users SET account_status='active' WHERE email=...</code>.</li> | |
| 1301 | </ul> | |
| 1302 | ||
| 1303 | <h3>Sidebar / topbar missing on logged-in pages</h3> | |
| 1304 | <p>That means Wrapper.pm is failing to load <code>TEMPLATES/shopcart_wrapper.html</code>. Check the file exists and Apache can read it (it shouldn't be web-accessible — that's what <code>TEMPLATES/.htaccess</code> with <code>Require all denied</code> is for — but the local Perl process needs read access).</p> | |
| 1305 | ||
| 1306 | <h3>Database errors not landing anywhere</h3> | |
| 1307 | <p>DB errors append to <code>MODS/DB_ERRORS.log</code> and a one-line summary goes to Apache's vhost <code>error_log</code> via STDERR. Make sure the log file exists and is writable by Apache (step 8). If the log is empty after a known failure, check <code>ls -l MODS/DB_ERRORS.log</code> permissions before anything else.</p> | |
| 1308 | ||
| 1309 | <h3>"Template eval error: …" in the page body</h3> | |
| 1310 | <p>MODS::Template generates Perl source and evals it. When the generated code is malformed, you'll see one of these in the rendered HTML:</p> | |
| 1311 | <ul> | |
| 1312 | <li><code>Template eval error: syntax error at (eval N) line M, near "…"</code></li> | |
| 1313 | <li><code>Template eval error: Unrecognized character \xE2; marked by <-- HERE</code></li> | |
| 1314 | </ul> | |
| 1315 | <p>The template engine has <strong>four known bugs</strong> we've worked around in this codebase:</p> | |
| 1316 | ||
| 1317 | <p><strong>1. <code>[if:]</code> blocks inside <code>[loop:]</code> blocks generate malformed Perl</strong>, especially when the body contains:</p> | |
| 1318 | <ul> | |
| 1319 | <li>negated conditions like <code>[if:!$loop1.field]</code></li> | |
| 1320 | <li>multi-byte UTF-8 characters (★ — → arrows, etc.)</li> | |
| 1321 | <li>complex HTML with both single and double quotes</li> | |
| 1322 | </ul> | |
| 1323 | <p><strong>Workaround:</strong> precompute the conditional HTML in the .cgi handler, pass it as a single template variable. Examples already in the codebase:</p> | |
| 1324 | ||
| 1325 | <pre data-label="storefront.cgi (Perl)"><code><span class="c"># Build status pill once, hand to template as ready-to-render HTML</span> | |
| 1326 | <span class="v">status_pill</span> => <span class="p">(</span><span class="v">$_</span><span class="p">->{</span><span class="v">status</span><span class="p">}</span> <span class="k">eq</span> <span class="s">'published'</span><span class="p">)</span> | |
| 1327 | <span class="p">?</span> <span class="s">'<span class="pill success">Published</span>'</span> | |
| 1328 | <span class="p">:</span> <span class="s">'<span class="pill warning">Draft</span>'</span><span class="p">,</span></code></pre> | |
| 1329 | ||
| 1330 | <pre data-label="shopcart_storefront.html (template)"><code><span class="c"><!-- Just one variable, no [if:] inside the [loop:] --></span> | |
| 1331 | <td><span class="v">$loop1.status_pill</span></td></code></pre> | |
| 1332 | ||
| 1333 | <p><strong>2. <code>[else]</code> tag pairing is unreliable.</strong> A pattern like <code>[if:cond]A[else]B[/if]</code> sometimes generates Perl that's missing a closing brace. Prefer two adjacent <code>[if:]</code> blocks (<code>[if:cond]A[/if][if:!cond]B[/if]</code>) when the if/else is at the top level — or, better, precompute as in workaround #1.</p> | |
| 1334 | ||
| 1335 | <p><strong>3. <code>[loop:@tvars.X]</code> generates malformed Perl when the array has rows.</strong> The outside-loop handler in <code>MODS/Template.pm</code> emits <code>@{$tvars->{tvars}}->{X}</code> instead of <code>@{$tvars->{X}}</code>. With an empty array nothing runs and you don't notice; the moment that array gets populated, the page dies with <em>"Can't use an undefined value as a HASH reference"</em>. <strong>Workaround:</strong> always write <code>[loop:@X]</code> (no <code>tvars.</code> prefix) — Template.pm prepends <code>$tvars-></code> on its own and the resulting Perl is correct.</p> | |
| 1336 | ||
| 1337 | <pre data-label="template — wrong vs right"><code><span class="c"><!-- WRONG: dies on populated arrays --></span> | |
| 1338 | [loop:<span class="o">@tvars.pages</span>] ... [/loop] | |
| 1339 | ||
| 1340 | <span class="c"><!-- RIGHT: works regardless of array size --></span> | |
| 1341 | [loop:<span class="o">@pages</span>] ... [/loop]</code></pre> | |
| 1342 | ||
| 1343 | <p><strong>4. Top-level scalars like <code>$store_id</code> get reinterpreted as <code>$loop1->{store_id}</code> inside <code>[loop:]</code> bodies</strong> — meaning they evaluate to <em>undef</em> (since <code>$loop1</code> is a row hash without that key) and you get URLs like <code>?id=&page=shop</code> with empty values. <strong>Workaround:</strong> use the explicit <code>$tvars.store_id</code> syntax inside the loop body. Template.pm has a global pre-pass that converts <code>$tvars.X</code> to <code>$tvars->{X}</code> regardless of context, so it survives the loop reinterpretation.</p> | |
| 1344 | ||
| 1345 | <pre data-label="template — wrong vs right"><code><span class="c"><!-- Inside [loop:@pages] --></span> | |
| 1346 | ||
| 1347 | <span class="c"><!-- WRONG: $store_id becomes $loop1->{store_id} = undef --></span> | |
| 1348 | <a href="/store.cgi?id=<span class="o">$store_id</span>&page=<span class="o">$loop1.slug</span>">...</a> | |
| 1349 | ||
| 1350 | <span class="c"><!-- RIGHT: $tvars.store_id stays bound to the top-level tvars --></span> | |
| 1351 | <a href="/store.cgi?id=<span class="o">$tvars.store_id</span>&page=<span class="o">$loop1.slug</span>">...</a></code></pre> | |
| 1352 | ||
| 1353 | <p><strong>Use this rule:</strong> inside any <code>[loop:]</code> body, <strong>always use <code>$tvars.X</code></strong> for variables that come from the top-level template context, and <strong>always use <code>$loopN.X</code></strong> for variables from the current row. Bare <code>$X</code> is ambiguous and will be reinterpreted as the loop row's field.</p> | |
| 1354 | ||
| 1355 | <p><strong>How to debug:</strong> the <code>(eval N) line M</code> in the error refers to the generated Perl source — you can't see that file directly, but the "near" snippet usually identifies the surrounding HTML. Look at the template's <code>[loop:]</code> bodies first; that's where 95% of template eval errors live.</p> | |
| 1356 | </section> | |
| 1357 | ||
| 1358 | <!-- 15. FLAGS --> | |
| 1359 | <section id="flags"> | |
| 1360 | <h2><span class="num">15</span> Feature flags & staged reveal</h2> | |
| 1361 | <p>ShopCart ships with all modules built but most reveal-gated. The default flag values in <code>feature_flags</code> control what the UI shows.</p> | |
| 1362 | ||
| 1363 | <table> | |
| 1364 | <thead><tr><th>Flag</th><th>Default</th><th>Effect</th></tr></thead> | |
| 1365 | <tbody> | |
| 1366 | <tr><td>module.publishing</td><td>ON</td><td>Cross-platform publishing module visible</td></tr> | |
| 1367 | <tr><td>module.storefront</td><td>OFF</td><td>Hosted storefront — turn on per-account when ready</td></tr> | |
| 1368 | <tr><td>module.optimization</td><td>OFF</td><td>A/B + price tools — Phase 4 reveal</td></tr> | |
| 1369 | <tr><td>module.pooled_data</td><td>OFF</td><td>Cross-store benchmarking — needs 100+ stores</td></tr> | |
| 1370 | <tr><td>module.audience</td><td>ON</td><td>Buyer accounts & comment inbox</td></tr> | |
| 1371 | <tr><td>module.team</td><td>OFF</td><td>Studio-tier team collaboration</td></tr> | |
| 1372 | <tr><td>feature.auto_render</td><td>OFF</td><td>AI marketing renders — Phase 2</td></tr> | |
| 1373 | <tr><td>feature.ai_descriptions</td><td>OFF</td><td>AI description generator — Phase 2</td></tr> | |
| 1374 | <tr><td>feature.slice_preview</td><td>OFF</td><td>Auto-slicing previews — Phase 2</td></tr> | |
| 1375 | <tr><td>killswitch.publishing</td><td>OFF</td><td>Emergency disable — flip to 1 to halt all publishing</td></tr> | |
| 1376 | </tbody> | |
| 1377 | </table> | |
| 1378 | ||
| 1379 | <p>To flip a flag globally:</p> | |
| 1380 | <pre data-label="mysql"><code><span class="p">mysql></span> <span class="k">UPDATE</span> feature_flags | |
| 1381 | <span class="k">SET</span> default_value = <span class="n">1</span> | |
| 1382 | <span class="k">WHERE</span> flag_key = <span class="s">'module.storefront'</span>;</code></pre> | |
| 1383 | ||
| 1384 | <p>To override per-user:</p> | |
| 1385 | <pre data-label="mysql"><code><span class="p">mysql></span> <span class="k">INSERT INTO</span> account_flag_overrides | |
| 1386 | (user_id, flag_key, override_value, set_by, set_at) | |
| 1387 | <span class="k">VALUES</span> (<span class="n">42</span>, <span class="s">'module.optimization'</span>, <span class="n">1</span>, <span class="k">NULL</span>, <span class="k">NOW</span>());</code></pre> | |
| 1388 | ||
| 1389 | <p>The Admin Console (<code>/admin.cgi</code>) has a UI for both — start there.</p> | |
| 1390 | </section> | |
| 1391 | ||
| 1392 | <div class="doc-foot"> | |
| 1393 | <p>That's everything. If a step blows up, the error log is your friend — Perl errors are usually obvious once you read them.</p> | |
| 1394 | <p>Last updated: with the deploy refactor that pushed page bodies into <code>TEMPLATES/shopcart_*.html</code> and split chart code into <code>graphs.js</code>.</p> | |
| 1395 | </div> | |
| 1396 | ||
| 1397 | </main> | |
| 1398 | </div> | |
| 1399 | </body> | |
| 1400 | </html> |