added on WebSTLs (webstls.com) at 2026-07-01 22:26:57
| 1 | <!-- Seller-side bundle builder. Backed by /bundles.cgi. --> | |
| 2 | ||
| 3 | <div class="page-head"> | |
| 4 | <div> | |
| 5 | <span class="page-eyebrow"><span class="dot"></span> Studio · Packages</span> | |
| 6 | <h1 class="page-title">Bundles</h1> | |
| 7 | <p class="page-subtitle">Group several of your listings into one combo with a discounted price. Buyers see the bundle as its own card on your storefront; the individual items keep selling on their own too.</p> | |
| 8 | </div> | |
| 9 | [if:!$is_editing] | |
| 10 | <div class="page-actions"> | |
| 11 | <a href="/bundles.cgi?id=new" class="btn btn-primary" id="bunNew"> | |
| 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 bundle | |
| 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 | .bun-list { display:grid; gap:12px; } | |
| 28 | .bun-row { display:flex; align-items:center; gap:14px; padding:14px 18px; background:var(--col-surface-1); border:1px solid var(--col-border); border-radius:12px; } | |
| 29 | .bun-row .info { flex:1; min-width:0; } | |
| 30 | .bun-row .title { font-size:16px; font-weight:700; color:var(--col-text); } | |
| 31 | .bun-row .meta { font-size:12px; color:var(--col-text-3); margin-top:3px; } | |
| 32 | .bun-row .price { font-family:var(--font-mono,monospace); font-weight:700; color:var(--col-accent-bright); } | |
| 33 | .bun-row .actions { display:flex; gap:6px; } | |
| 34 | .bun-pill { font-size:10px; padding:3px 9px; border-radius:5px; letter-spacing:1px; font-weight:700; text-transform:uppercase; } | |
| 35 | .bun-pill.draft { background:rgba(156,163,175,0.18); color:#9ca3af; } | |
| 36 | .bun-pill.published { background:rgba(34,197,94,0.18); color:#86efac; } | |
| 37 | .bun-pill.archived { background:rgba(239,68,68,0.18); color:#fca5a5; } | |
| 38 | .bun-kind { font-size:10px; padding:3px 9px; border-radius:5px; letter-spacing:1px; font-weight:700; text-transform:uppercase; background:rgba(59,130,246,0.15); color:#60a5fa; } | |
| 39 | .bun-kind.physical { background:rgba(217,119,6,0.15); color:#f59e0b; } | |
| 40 | .bun-kind.both { background:rgba(168,85,247,0.15); color:#c084fc; } | |
| 41 | ||
| 42 | /* Picker */ | |
| 43 | .picker-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(220px, 1fr)); gap:8px; max-height:380px; overflow-y:auto; padding:6px; border:1px solid var(--col-border); border-radius:10px; background:var(--col-surface-2); } | |
| 44 | .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; transition:all .15s; } | |
| 45 | .picker-card:hover { border-color:var(--col-accent); } | |
| 46 | .picker-card.is-on { border-color:var(--col-accent); background:var(--grad-brand-soft); } | |
| 47 | .picker-card input { display:none; } | |
| 48 | .picker-title { flex:1; font-size:12px; font-weight:600; color:var(--col-text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } | |
| 49 | .picker-price { font-family:var(--font-mono,monospace); font-size:11px; color:var(--col-accent-bright); } | |
| 50 | </style> | |
| 51 | ||
| 52 | [if:!$is_editing] | |
| 53 | [if:!$has_bundles] | |
| 54 | <div style="padding:60px 24px;text-align:center;border:1px dashed var(--col-border-2);border-radius:14px;color:var(--col-text-2)"> | |
| 55 | <div style="font-size:48px;margin-bottom:8px">[]</div> | |
| 56 | <h2 style="color:#fff;font-family:var(--font-display,inherit);margin-bottom:8px">No bundles yet</h2> | |
| 57 | <p>Bundle 2 or more of your listings into a single combo deal — discount the price, give buyers a "buy them all" path. Works for digital downloads, printed boxes, or both.</p> | |
| 58 | <a href="/bundles.cgi?id=new" class="btn btn-primary" style="margin-top:14px">Create your first bundle</a> | |
| 59 | </div> | |
| 60 | [/if] | |
| 61 | ||
| 62 | [if:$has_bundles] | |
| 63 | <div class="bun-list"> | |
| 64 | [loop:@bundles] | |
| 65 | <div class="bun-row"> | |
| 66 | <div class="info"> | |
| 67 | <div class="title">$loop1.title</div> | |
| 68 | <div class="meta"> | |
| 69 | <span class="bun-pill $loop1.status">$loop1.status</span> | |
| 70 | <span class="bun-kind $loop1.kind">$loop1.kind</span> | |
| 71 | <span style="margin-left:6px">$loop1.item_count item(s)</span> | |
| 72 | [if:$loop1.tagline] · <span>$loop1.tagline</span>[/if] | |
| 73 | </div> | |
| 74 | </div> | |
| 75 | <div class="price">$loop1.price_label</div> | |
| 76 | <div class="actions"> | |
| 77 | <a href="/bundles.cgi?id=$loop1.id" class="btn btn-secondary btn-sm">Edit</a> | |
| 78 | [if:!$loop1.is_published] | |
| 79 | <form method="POST" action="/bundles.cgi" style="display:inline"> | |
| 80 | <input type="hidden" name="_act" value="publish"> | |
| 81 | <input type="hidden" name="id" value="$loop1.id"> | |
| 82 | <button class="btn btn-primary btn-sm" type="submit">Publish</button> | |
| 83 | </form> | |
| 84 | [/if] | |
| 85 | [if:$loop1.is_published] | |
| 86 | <form method="POST" action="/bundles.cgi" style="display:inline"> | |
| 87 | <input type="hidden" name="_act" value="archive"> | |
| 88 | <input type="hidden" name="id" value="$loop1.id"> | |
| 89 | <button class="btn btn-secondary btn-sm" type="submit">Archive</button> | |
| 90 | </form> | |
| 91 | [/if] | |
| 92 | </div> | |
| 93 | </div> | |
| 94 | [/loop] | |
| 95 | </div> | |
| 96 | [/if] | |
| 97 | [/if] | |
| 98 | ||
| 99 | [if:$is_editing] | |
| 100 | <form id="bunForm" method="POST" action="/bundles.cgi"> | |
| 101 | [if:$is_new_form]<input type="hidden" name="_act" value="create">[/if] | |
| 102 | [if:!$is_new_form]<input type="hidden" name="_act" value="update">[/if] | |
| 103 | [if:!$is_new_form]<input type="hidden" name="id" value="$edit_id">[/if] | |
| 104 | <input type="hidden" name="item_ids" id="bunItemIds" value=""> | |
| 105 | ||
| 106 | <div class="dash-grid" style="grid-template-columns:2fr 1fr;gap:16px"> | |
| 107 | <div style="display:flex;flex-direction:column;gap:16px"> | |
| 108 | ||
| 109 | <div class="module"> | |
| 110 | <div class="module-head"><div class="left"> | |
| 111 | <div class="module-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg></div> | |
| 112 | <div><div class="module-title">Bundle details</div><div class="module-sub">What buyers see on the storefront card.</div></div> | |
| 113 | </div></div> | |
| 114 | <div class="module-body"> | |
| 115 | <div class="form-group"> | |
| 116 | <label class="form-label">Title <span class="req">*</span></label> | |
| 117 | <input type="text" name="title" class="input" required maxlength="200" placeholder="e.g. Sci-Fi Mega Pack" value="$title"> | |
| 118 | </div> | |
| 119 | <div class="form-group"> | |
| 120 | <label class="form-label">Tagline</label> | |
| 121 | <input type="text" name="tagline" class="input" maxlength="180" placeholder="One-line summary on listing cards" value="$tagline"> | |
| 122 | </div> | |
| 123 | <div class="form-group"> | |
| 124 | <label class="form-label">Description</label> | |
| 125 | <textarea name="description" class="textarea" rows="5" placeholder="Sell the bundle. Why does this set hang together? What does the buyer save by buying it as a pack?">$description</textarea> | |
| 126 | </div> | |
| 127 | </div> | |
| 128 | </div> | |
| 129 | ||
| 130 | <div class="module"> | |
| 131 | <div class="module-head"><div class="left"> | |
| 132 | <div class="module-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/></svg></div> | |
| 133 | <div><div class="module-title">Bundle items</div><div class="module-sub">Pick 2+ of your listings. The buyer gets all of them when they purchase the bundle.</div></div> | |
| 134 | </div></div> | |
| 135 | <div class="module-body"> | |
| 136 | [if:!$has_candidates] | |
| 137 | <p class="text-xs text-dim">Upload at least one model from <a href="/models.cgi" class="text-brand">My Models → Upload new model</a> before building a bundle.</p> | |
| 138 | [/if] | |
| 139 | [if:$has_candidates] | |
| 140 | <input type="search" id="bunFilter" class="input" placeholder="Filter your listings..." style="margin-bottom:10px"> | |
| 141 | <div class="picker-grid" id="bunPicker"> | |
| 142 | [loop:@candidates] | |
| 143 | <label class="picker-card" data-id="$loop1.id" data-title="$loop1.title"> | |
| 144 | <input type="checkbox" value="$loop1.id"> | |
| 145 | <span class="picker-title">$loop1.title</span> | |
| 146 | <span class="picker-price">$loop1.price</span> | |
| 147 | </label> | |
| 148 | [/loop] | |
| 149 | </div> | |
| 150 | <p class="text-xs text-dim" style="margin-top:8px"><span id="bunPickedCount">0</span> selected</p> | |
| 151 | [/if] | |
| 152 | </div> | |
| 153 | </div> | |
| 154 | ||
| 155 | </div> | |
| 156 | ||
| 157 | <div style="display:flex;flex-direction:column;gap:16px"> | |
| 158 | ||
| 159 | <div class="module"> | |
| 160 | <div class="module-head"><div class="left"> | |
| 161 | <div class="module-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/></svg></div> | |
| 162 | <div><div class="module-title">Kind & price</div><div class="module-sub">Digital files, printed boxes, or both.</div></div> | |
| 163 | </div></div> | |
| 164 | <div class="module-body"> | |
| 165 | <div class="form-group"> | |
| 166 | <label class="form-label">Kind</label> | |
| 167 | <select name="product_kind" class="select" id="bunKind"> | |
| 168 | <option value="digital" $kind_digital_sel>Digital download (zip of all files)</option> | |
| 169 | <option value="physical" $kind_physical_sel>3D-printed box (you ship everything together)</option> | |
| 170 | <option value="both" $kind_both_sel>Both (buyer picks)</option> | |
| 171 | </select> | |
| 172 | </div> | |
| 173 | <div class="form-group" id="bunDigitalPriceWrap"> | |
| 174 | <label class="form-label">Digital price <span class="req">*</span></label> | |
| 175 | <div class="up-price-input"><span class="currency">\$</span> | |
| 176 | <input type="number" name="base_price" class="input" placeholder="0.00" min="0" step="0.01" value="$base_price"></div> | |
| 177 | </div> | |
| 178 | <div class="form-group" id="bunPhysicalPriceWrap" style="display:none"> | |
| 179 | <label class="form-label">Printed price <span class="req">*</span></label> | |
| 180 | <div class="up-price-input"><span class="currency">\$</span> | |
| 181 | <input type="number" name="physical_price" class="input" placeholder="0.00" min="0" step="0.01" value="$physical_price"></div> | |
| 182 | </div> | |
| 183 | </div> | |
| 184 | </div> | |
| 185 | ||
| 186 | <div class="module" id="bunPhysicalBox" style="display:none"> | |
| 187 | <div class="module-head"><div class="left"> | |
| 188 | <div class="module-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="15" height="8" rx="1"/><polygon points="16 11 22 11 22 17 16 17 16 11"/></svg></div> | |
| 189 | <div><div class="module-title">Physical bundle details</div><div class="module-sub">Stock + shipping for the printed box.</div></div> | |
| 190 | </div></div> | |
| 191 | <div class="module-body"> | |
| 192 | <div class="dash-grid-2" style="gap:10px"> | |
| 193 | <div class="form-group"> | |
| 194 | <label class="form-label">Stock</label> | |
| 195 | <input type="number" name="inventory_qty" class="input" min="0" placeholder="Blank = unlimited" value="$inventory_qty"> | |
| 196 | </div> | |
| 197 | <div class="form-group"> | |
| 198 | <label class="form-label">Production days</label> | |
| 199 | <input type="number" name="production_time_days" class="input" min="0" placeholder="e.g. 7" value="$production_time_days"> | |
| 200 | </div> | |
| 201 | <div class="form-group"> | |
| 202 | <label class="form-label">Weight (g)</label> | |
| 203 | <input type="number" name="weight_grams" class="input" min="0" value="$weight_grams"> | |
| 204 | </div> | |
| 205 | <div class="form-group"> | |
| 206 | <label class="form-label">Ships from</label> | |
| 207 | <input type="text" name="ship_from_country" class="input" maxlength="2" placeholder="US" value="$ship_from_country" style="text-transform:uppercase"> | |
| 208 | </div> | |
| 209 | </div> | |
| 210 | <div class="form-group"> | |
| 211 | <label class="form-label">Shipping rates</label> | |
| 212 | <textarea name="shipping_options_json" class="textarea" rows="3" placeholder="domestic=900 worldwide=2200" style="font-family:var(--font-mono,monospace);font-size:12px">$shipping_options_json</textarea> | |
| 213 | </div> | |
| 214 | </div> | |
| 215 | </div> | |
| 216 | ||
| 217 | <div class="module"> | |
| 218 | <div class="module-body" style="display:flex;flex-direction:column;gap:8px"> | |
| 219 | <button type="submit" class="btn btn-primary" style="width:100%">Save bundle</button> | |
| 220 | <a href="/bundles.cgi" class="btn btn-secondary" style="width:100%;text-align:center">Back to list</a> | |
| 221 | </div> | |
| 222 | </div> | |
| 223 | ||
| 224 | </div> | |
| 225 | </div> | |
| 226 | </form> | |
| 227 | ||
| 228 | <script> | |
| 229 | (function(){ | |
| 230 | /* Reveal the picker rows that match a filter substring. */ | |
| 231 | var filter = document.getElementById('bunFilter'); | |
| 232 | var picker = document.getElementById('bunPicker'); | |
| 233 | if (filter && picker) { | |
| 234 | filter.addEventListener('input', function(){ | |
| 235 | var needle = filter.value.toLowerCase(); | |
| 236 | picker.querySelectorAll('.picker-card').forEach(function(card){ | |
| 237 | var hit = card.dataset.title.toLowerCase().indexOf(needle) !== -1; | |
| 238 | card.style.display = hit ? '' : 'none'; | |
| 239 | }); | |
| 240 | }); | |
| 241 | } | |
| 242 | ||
| 243 | /* Track selections, mirror to hidden field for submit. Initialize from | |
| 244 | the comma-separated server-rendered selected_ids. */ | |
| 245 | var initial = ('$selected_ids' || '').split(',').filter(Boolean); | |
| 246 | var hidden = document.getElementById('bunItemIds'); | |
| 247 | var countEl = document.getElementById('bunPickedCount'); | |
| 248 | var selected = {}; | |
| 249 | initial.forEach(function(id){ selected[id] = true; }); | |
| 250 | function sync() { | |
| 251 | if (picker) picker.querySelectorAll('.picker-card').forEach(function(card){ | |
| 252 | var on = !!selected[card.dataset.id]; | |
| 253 | card.classList.toggle('is-on', on); | |
| 254 | var chk = card.querySelector('input'); | |
| 255 | if (chk) chk.checked = on; | |
| 256 | }); | |
| 257 | if (hidden) hidden.value = Object.keys(selected).join(','); | |
| 258 | if (countEl) countEl.textContent = Object.keys(selected).length; | |
| 259 | } | |
| 260 | if (picker) { | |
| 261 | picker.addEventListener('click', function(e){ | |
| 262 | var card = e.target.closest('.picker-card'); | |
| 263 | if (!card) return; | |
| 264 | e.preventDefault(); | |
| 265 | var id = card.dataset.id; | |
| 266 | if (selected[id]) delete selected[id]; else selected[id] = true; | |
| 267 | sync(); | |
| 268 | }); | |
| 269 | } | |
| 270 | sync(); | |
| 271 | ||
| 272 | /* Kind switch: show/hide digital + physical price + physical details box. */ | |
| 273 | var kindSel = document.getElementById('bunKind'); | |
| 274 | var digWrap = document.getElementById('bunDigitalPriceWrap'); | |
| 275 | var phyWrap = document.getElementById('bunPhysicalPriceWrap'); | |
| 276 | var phyBox = document.getElementById('bunPhysicalBox'); | |
| 277 | function applyKind() { | |
| 278 | var k = kindSel.value; | |
| 279 | digWrap.style.display = (k === 'physical') ? 'none' : ''; | |
| 280 | phyWrap.style.display = (k === 'digital') ? 'none' : ''; | |
| 281 | phyBox.style.display = (k === 'digital') ? 'none' : ''; | |
| 282 | } | |
| 283 | if (kindSel) { | |
| 284 | kindSel.addEventListener('change', applyKind); | |
| 285 | applyKind(); | |
| 286 | } | |
| 287 | })(); | |
| 288 | </script> | |
| 289 | [/if] | |
| 290 | ||
| 291 | <!-- trailing sentinel: MODS::Template needs non-conditional content at the end | |
| 292 | of the file or the qq~~ eval returns empty for the whole page. --> | |
| 293 | <div style="display:none" data-end-of-bundles></div> |