added on WebSTLs (webstls.com) at 2026-07-01 22:27:05
| 1 | <!-- Seller-side subscription plan manager. Backed by /subscription_plans.cgi. --> | |
| 2 | ||
| 3 | <div class="page-head"> | |
| 4 | <div> | |
| 5 | <span class="page-eyebrow"><span class="dot"></span> Monetization · Recurring</span> | |
| 6 | <h1 class="page-title">Subscription plans</h1> | |
| 7 | <p class="page-subtitle">Recurring revenue. Two flavors: <strong>download quota</strong> (buyer gets N STL downloads per month from a pool) or <strong>ship monthly</strong> (you ship them the newest item each month). Plans publish to your storefront with a Subscribe button.</p> | |
| 8 | </div> | |
| 9 | [if:!$is_editing] | |
| 10 | <div class="page-actions"> | |
| 11 | <a href="/subscription_plans.cgi?id=new" class="btn btn-primary"> | |
| 12 | <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> | |
| 13 | New plan | |
| 14 | </a> | |
| 15 | </div> | |
| 16 | [/if] | |
| 17 | </div> | |
| 18 | ||
| 19 | [if:$has_flash] | |
| 20 | <div class="banner $flash_kind mb-3"> | |
| 21 | <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> | |
| 22 | <div class="text-sm fw-600">$flash_msg</div> | |
| 23 | </div> | |
| 24 | [/if] | |
| 25 | ||
| 26 | <style> | |
| 27 | .sp-list { display:grid; grid-template-columns:repeat(auto-fill, minmax(320px, 1fr)); gap:14px; } | |
| 28 | .sp-card { padding:16px 18px; background:var(--col-surface-1); border:1px solid var(--col-border); border-radius:12px; display:flex; flex-direction:column; gap:8px; } | |
| 29 | .sp-card .title { font-size:17px; font-weight:700; color:var(--col-text); } | |
| 30 | .sp-card .price { font-family:var(--font-mono,monospace); font-weight:700; font-size:22px; color:var(--col-accent-bright); } | |
| 31 | .sp-meta { font-size:12px; color:var(--col-text-3); } | |
| 32 | .sp-pill { font-size:10px; padding:3px 9px; border-radius:5px; letter-spacing:1px; font-weight:700; text-transform:uppercase; } | |
| 33 | .sp-pill.draft { background:rgba(156,163,175,0.18); color:#9ca3af; } | |
| 34 | .sp-pill.active { background:rgba(34,197,94,0.18); color:#86efac; } | |
| 35 | .sp-pill.paused { background:rgba(251,191,36,0.18); color:#fcd34d; } | |
| 36 | .sp-pill.archived { background:rgba(239,68,68,0.18); color:#fca5a5; } | |
| 37 | .sp-actions { display:flex; gap:6px; margin-top:auto; } | |
| 38 | .sp-empty { padding:60px 24px; text-align:center; border:1px dashed var(--col-border-2); border-radius:14px; color:var(--col-text-2); } | |
| 39 | .picker-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(220px, 1fr)); gap:8px; max-height:320px; overflow-y:auto; padding:6px; border:1px solid var(--col-border); border-radius:10px; background:var(--col-surface-2); } | |
| 40 | .picker-card { display:flex; align-items:center; gap:8px; padding:8px 10px; border:1px solid var(--col-border); border-radius:8px; background:var(--col-surface-1); cursor:pointer; } | |
| 41 | .picker-card.is-on { border-color:var(--col-accent); background:var(--grad-brand-soft); } | |
| 42 | .picker-title { flex:1; font-size:12px; font-weight:600; color:var(--col-text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } | |
| 43 | </style> | |
| 44 | ||
| 45 | [if:!$is_editing] | |
| 46 | [if:!$has_plans] | |
| 47 | <div class="sp-empty"> | |
| 48 | <div style="font-size:48px;margin-bottom:8px">~</div> | |
| 49 | <h2 style="color:#fff;font-family:var(--font-display,inherit);margin-bottom:8px">No subscription plans yet</h2> | |
| 50 | <p>Offer recurring deals: <strong>"5 downloads/month from my Miniatures collection"</strong> or <strong>"I ship you my newest print each month"</strong>. Buyers see a Subscribe button on your storefront.</p> | |
| 51 | <a href="/subscription_plans.cgi?id=new" class="btn btn-primary" style="margin-top:14px">Create your first plan</a> | |
| 52 | </div> | |
| 53 | [/if] | |
| 54 | ||
| 55 | [if:$has_plans] | |
| 56 | <div class="sp-list"> | |
| 57 | [loop:@plans] | |
| 58 | <div class="sp-card"> | |
| 59 | <div style="display:flex;align-items:center;gap:8px"> | |
| 60 | <span class="sp-pill $loop1.status">$loop1.status</span> | |
| 61 | <span style="font-size:11px;color:var(--col-text-3);text-transform:uppercase;letter-spacing:1px">$loop1.kind_label</span> | |
| 62 | </div> | |
| 63 | <div class="title">$loop1.name</div> | |
| 64 | <div class="sp-meta">$loop1.tagline</div> | |
| 65 | <div class="price">$loop1.price_label <span style="font-size:12px;font-weight:400;color:var(--col-text-3)">/ $loop1.cadence days</span></div> | |
| 66 | <div class="sp-meta">$loop1.quota items per period · $loop1.active_subs active subscriber(s)</div> | |
| 67 | <div class="sp-actions"> | |
| 68 | <a href="/subscription_plans.cgi?id=$loop1.id" class="btn btn-secondary btn-sm">Edit</a> | |
| 69 | [if:$loop1.is_draft] | |
| 70 | <form method="POST" action="/subscription_plans.cgi" style="display:inline"> | |
| 71 | <input type="hidden" name="_act" value="publish"><input type="hidden" name="id" value="$loop1.id"> | |
| 72 | <button class="btn btn-primary btn-sm" type="submit">Publish</button> | |
| 73 | </form> | |
| 74 | [/if] | |
| 75 | [if:$loop1.is_active] | |
| 76 | <form method="POST" action="/subscription_plans.cgi" style="display:inline"> | |
| 77 | <input type="hidden" name="_act" value="pause"><input type="hidden" name="id" value="$loop1.id"> | |
| 78 | <button class="btn btn-secondary btn-sm" type="submit">Pause</button> | |
| 79 | </form> | |
| 80 | [/if] | |
| 81 | [if:$loop1.is_paused] | |
| 82 | <form method="POST" action="/subscription_plans.cgi" style="display:inline"> | |
| 83 | <input type="hidden" name="_act" value="publish"><input type="hidden" name="id" value="$loop1.id"> | |
| 84 | <button class="btn btn-primary btn-sm" type="submit">Resume</button> | |
| 85 | </form> | |
| 86 | [/if] | |
| 87 | </div> | |
| 88 | </div> | |
| 89 | [/loop] | |
| 90 | </div> | |
| 91 | [/if] | |
| 92 | [/if] | |
| 93 | ||
| 94 | [if:$is_editing] | |
| 95 | <form method="POST" action="/subscription_plans.cgi"> | |
| 96 | [if:$is_new_form]<input type="hidden" name="_act" value="create">[/if] | |
| 97 | [if:!$is_new_form]<input type="hidden" name="_act" value="update">[/if] | |
| 98 | [if:!$is_new_form]<input type="hidden" name="id" value="$edit_id">[/if] | |
| 99 | <input type="hidden" name="model_ids" id="spModelIds" value=""> | |
| 100 | ||
| 101 | <div class="dash-grid" style="grid-template-columns:2fr 1fr;gap:16px"> | |
| 102 | <div style="display:flex;flex-direction:column;gap:16px"> | |
| 103 | ||
| 104 | <div class="module"> | |
| 105 | <div class="module-head"><div class="left"> | |
| 106 | <div class="module-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M16 12a4 4 0 1 1-8 0"/></svg></div> | |
| 107 | <div><div class="module-title">Plan basics</div><div class="module-sub">What buyers see on the Subscribe card.</div></div> | |
| 108 | </div></div> | |
| 109 | <div class="module-body"> | |
| 110 | <div class="form-group"> | |
| 111 | <label class="form-label">Name <span class="req">*</span></label> | |
| 112 | <input type="text" name="name" class="input" required maxlength="160" placeholder="e.g. Miniatures Monthly" value="$name"> | |
| 113 | </div> | |
| 114 | <div class="form-group"> | |
| 115 | <label class="form-label">Tagline</label> | |
| 116 | <input type="text" name="tagline" class="input" maxlength="255" placeholder="One-liner shown under the name" value="$tagline"> | |
| 117 | </div> | |
| 118 | <div class="form-group"> | |
| 119 | <label class="form-label">Description</label> | |
| 120 | <textarea name="description" class="textarea" rows="5" placeholder="Sell the plan. What's included? When are new items added?">$description</textarea> | |
| 121 | </div> | |
| 122 | </div> | |
| 123 | </div> | |
| 124 | ||
| 125 | <div class="module"> | |
| 126 | <div class="module-head"><div class="left"> | |
| 127 | <div class="module-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg></div> | |
| 128 | <div><div class="module-title">Item pool</div><div class="module-sub">Which of your listings count toward the subscriber's quota.</div></div> | |
| 129 | </div></div> | |
| 130 | <div class="module-body"> | |
| 131 | <div class="form-group"> | |
| 132 | <label class="form-label">Pool</label> | |
| 133 | <select name="pool_kind" id="spPoolKind" class="select"> | |
| 134 | <option value="all" $pool_all_sel>All published listings on this storefront</option> | |
| 135 | <option value="category" $pool_cat_sel>One of my categories</option> | |
| 136 | <option value="specific" $pool_spec_sel>A specific list I curate</option> | |
| 137 | </select> | |
| 138 | </div> | |
| 139 | <div class="form-group" id="spPoolCatWrap" style="display:none"> | |
| 140 | [if:$has_cat_options] | |
| 141 | <label class="form-label">Category</label> | |
| 142 | <select name="pool_category_id" class="select"> | |
| 143 | <option value="">— pick a category —</option> | |
| 144 | [loop:@cat_options] | |
| 145 | <option value="$loop1.id" $loop1.sel>$loop1.name</option> | |
| 146 | [/loop] | |
| 147 | </select> | |
| 148 | [/if] | |
| 149 | [if:!$has_cat_options] | |
| 150 | <p class="text-xs text-dim">No categories yet. <a href="/categories.cgi" target="_blank" class="text-brand">Build one →</a></p> | |
| 151 | [/if] | |
| 152 | </div> | |
| 153 | <div class="form-group" id="spPoolSpecWrap" style="display:none"> | |
| 154 | [if:!$has_candidates] | |
| 155 | <p class="text-xs text-dim">You have no listings yet. <a href="/upload_model.cgi" class="text-brand">Upload one →</a></p> | |
| 156 | [/if] | |
| 157 | [if:$has_candidates] | |
| 158 | <label class="form-label">Pick listings the subscriber can download from</label> | |
| 159 | <input type="search" id="spFilter" class="input" placeholder="Filter listings..." style="margin-bottom:10px"> | |
| 160 | <div class="picker-grid" id="spPicker"> | |
| 161 | [loop:@candidates] | |
| 162 | <label class="picker-card" data-id="$loop1.id" data-title="$loop1.title"> | |
| 163 | <input type="checkbox" value="$loop1.id" style="display:none"> | |
| 164 | <span class="picker-title">$loop1.title</span> | |
| 165 | </label> | |
| 166 | [/loop] | |
| 167 | </div> | |
| 168 | <p class="text-xs text-dim" style="margin-top:8px"><span id="spPickedCount">0</span> selected</p> | |
| 169 | [/if] | |
| 170 | </div> | |
| 171 | </div> | |
| 172 | </div> | |
| 173 | ||
| 174 | </div> | |
| 175 | ||
| 176 | <div style="display:flex;flex-direction:column;gap:16px"> | |
| 177 | <div class="module"> | |
| 178 | <div class="module-head"><div class="left"> | |
| 179 | <div class="module-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></div> | |
| 180 | <div><div class="module-title">Cadence & price</div><div class="module-sub">How often + how much.</div></div> | |
| 181 | </div></div> | |
| 182 | <div class="module-body"> | |
| 183 | <div class="form-group"> | |
| 184 | <label class="form-label">Kind <span class="req">*</span></label> | |
| 185 | <select name="kind" class="select" id="spKind"> | |
| 186 | <option value="download_quota" $kind_dl_sel>Download quota (digital files)</option> | |
| 187 | <option value="ship_monthly" $kind_ship_sel>Ship monthly (printed items)</option> | |
| 188 | </select> | |
| 189 | </div> | |
| 190 | <div class="form-group"> | |
| 191 | <label class="form-label">Price per period <span class="req">*</span></label> | |
| 192 | <div class="up-price-input"><span class="currency">\$</span> | |
| 193 | <input type="number" name="price" class="input" required min="0.01" step="0.01" placeholder="9.00" value="$price"></div> | |
| 194 | </div> | |
| 195 | <div class="dash-grid-2" style="gap:10px"> | |
| 196 | <div class="form-group"> | |
| 197 | <label class="form-label">Cadence (days)</label> | |
| 198 | <input type="number" name="cadence_days" class="input" min="1" value="$cadence_days"> | |
| 199 | </div> | |
| 200 | <div class="form-group"> | |
| 201 | <label class="form-label" id="spQuotaLabel">Quota / period</label> | |
| 202 | <input type="number" name="quota_per_period" class="input" min="1" value="$quota_per_period"> | |
| 203 | </div> | |
| 204 | </div> | |
| 205 | <div class="form-group"> | |
| 206 | <label class="form-label">Max subscribers <span class="text-xs text-dim">(0 = unlimited)</span></label> | |
| 207 | <input type="number" name="max_subscribers" class="input" min="0" value="$max_subscribers"> | |
| 208 | </div> | |
| 209 | </div> | |
| 210 | </div> | |
| 211 | ||
| 212 | <div class="module"> | |
| 213 | <div class="module-body" style="display:flex;flex-direction:column;gap:8px"> | |
| 214 | <button type="submit" class="btn btn-primary" style="width:100%">Save plan</button> | |
| 215 | <a href="/subscription_plans.cgi" class="btn btn-secondary" style="width:100%;text-align:center">Back to list</a> | |
| 216 | <p class="text-xs text-dim" style="margin-top:6px;line-height:1.5">After saving, click <strong>Publish</strong> from the list page to make the plan live. Buyers see a Subscribe button on your storefront.</p> | |
| 217 | </div> | |
| 218 | </div> | |
| 219 | </div> | |
| 220 | </div> | |
| 221 | </form> | |
| 222 | ||
| 223 | <script> | |
| 224 | (function(){ | |
| 225 | var poolSel = document.getElementById('spPoolKind'); | |
| 226 | var catWrap = document.getElementById('spPoolCatWrap'); | |
| 227 | var specWrap= document.getElementById('spPoolSpecWrap'); | |
| 228 | var kindSel = document.getElementById('spKind'); | |
| 229 | var quotaLbl= document.getElementById('spQuotaLabel'); | |
| 230 | function applyPool() { | |
| 231 | var p = poolSel.value; | |
| 232 | catWrap.style.display = (p === 'category') ? '' : 'none'; | |
| 233 | specWrap.style.display = (p === 'specific') ? '' : 'none'; | |
| 234 | } | |
| 235 | function applyKind() { | |
| 236 | quotaLbl.textContent = (kindSel.value === 'ship_monthly') | |
| 237 | ? 'Items shipped / period' : 'Downloads / period'; | |
| 238 | } | |
| 239 | if (poolSel) { poolSel.addEventListener('change', applyPool); applyPool(); } | |
| 240 | if (kindSel) { kindSel.addEventListener('change', applyKind); applyKind(); } | |
| 241 | ||
| 242 | // Specific-pool picker | |
| 243 | var picker = document.getElementById('spPicker'); | |
| 244 | var filter = document.getElementById('spFilter'); | |
| 245 | var hidden = document.getElementById('spModelIds'); | |
| 246 | var countEl= document.getElementById('spPickedCount'); | |
| 247 | var initial= ('$selected_model_ids' || '').split(',').filter(Boolean); | |
| 248 | var selected = {}; | |
| 249 | initial.forEach(function(id){ selected[id] = true; }); | |
| 250 | function sync() { | |
| 251 | if (!picker) return; | |
| 252 | picker.querySelectorAll('.picker-card').forEach(function(c){ | |
| 253 | var on = !!selected[c.dataset.id]; | |
| 254 | c.classList.toggle('is-on', on); | |
| 255 | var chk = c.querySelector('input'); | |
| 256 | if (chk) chk.checked = on; | |
| 257 | }); | |
| 258 | if (hidden) hidden.value = Object.keys(selected).join(','); | |
| 259 | if (countEl) countEl.textContent = Object.keys(selected).length; | |
| 260 | } | |
| 261 | if (picker) { | |
| 262 | picker.addEventListener('click', function(e){ | |
| 263 | var c = e.target.closest('.picker-card'); | |
| 264 | if (!c) return; | |
| 265 | e.preventDefault(); | |
| 266 | var id = c.dataset.id; | |
| 267 | if (selected[id]) delete selected[id]; else selected[id] = true; | |
| 268 | sync(); | |
| 269 | }); | |
| 270 | sync(); | |
| 271 | } | |
| 272 | if (filter && picker) { | |
| 273 | filter.addEventListener('input', function(){ | |
| 274 | var n = filter.value.toLowerCase(); | |
| 275 | picker.querySelectorAll('.picker-card').forEach(function(c){ | |
| 276 | c.style.display = c.dataset.title.toLowerCase().indexOf(n) !== -1 ? '' : 'none'; | |
| 277 | }); | |
| 278 | }); | |
| 279 | } | |
| 280 | })(); | |
| 281 | </script> | |
| 282 | [/if] | |
| 283 | ||
| 284 | <!-- trailing sentinel: MODS::Template silently returns empty if the | |
| 285 | file ends on a conditional. Keeps the page body non-empty. --> | |
| 286 | <div style="display:none" data-end-of-plans></div> |