added on local at 2026-07-01 13:47:58
| 1 | <div class="page" style="padding:4px 0"> | |
| 2 | ||
| 3 | <div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;margin-bottom:14px"> | |
| 4 | <div> | |
| 5 | <div class="text-xs text-dim" style="letter-spacing:1.5px;text-transform:uppercase;font-weight:700">Admin · Catalogue</div> | |
| 6 | <h1 style="font-family:var(--font-display);font-size:28px;color:#fff;margin:6px 0 2px">Pricing & Promotions</h1> | |
| 7 | <p class="text-sm text-secondary" style="margin:0;max-width:640px"> | |
| 8 | Packages, pricing, feature entitlements, and platform-wide promotions in one place. | |
| 9 | Catalogue changes flow out to <a href="/pricing.cgi" class="text-brand">pricing.cgi</a>, | |
| 10 | the landing page, and every user's <a href="/preferences.cgi" class="text-brand">Available Features</a> tab. | |
| 11 | </p> | |
| 12 | </div> | |
| 13 | [if:$is_view_list] | |
| 14 | <a href="/admin_packages.cgi?new=1" class="btn btn-primary">+ New package</a> | |
| 15 | [/if] | |
| 16 | [if:$is_view_form] | |
| 17 | <a href="/admin_packages.cgi" class="btn btn-secondary">← Back to catalogue</a> | |
| 18 | [/if] | |
| 19 | </div> | |
| 20 | ||
| 21 | <!-- Shared tab nav with admin_promotions.cgi. Two URLs, one visual | |
| 22 | surface -- both pages set page_key='admin_packages' so the | |
| 23 | sidebar entry "Pricing & Promotions" stays highlighted across | |
| 24 | either tab. The .pp-tab classes are scoped to this pair of | |
| 25 | pages so we don't conflict with site.css's generic .tabs. --> | |
| 26 | <style> | |
| 27 | .pp-tabs { display:flex; gap:4px; padding:4px; background:var(--col-surface-2); border:1px solid var(--col-border); border-radius:10px; margin-bottom:22px; width:fit-content; } | |
| 28 | .pp-tab { padding:8px 18px; font-size:13px; font-weight:600; color:var(--col-text-2); border-radius:7px; text-decoration:none; transition:background 0.12s, color 0.12s; white-space:nowrap; } | |
| 29 | .pp-tab:hover { color:var(--col-text); } | |
| 30 | .pp-tab.is-active { background:var(--col-surface-3); color:#fff; box-shadow:inset 0 0 0 1px var(--col-border-2); } | |
| 31 | </style> | |
| 32 | <nav class="pp-tabs"> | |
| 33 | <a class="pp-tab is-active" href="/admin_packages.cgi">Packages</a> | |
| 34 | <a class="pp-tab" href="/admin_promotions.cgi">Promotions</a> | |
| 35 | </nav> | |
| 36 | ||
| 37 | [if:$has_flash] | |
| 38 | <div class="banner $flash_kind" style="margin-bottom:18px"> | |
| 39 | <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"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg> | |
| 40 | <div class="text-xs">$flash_msg</div> | |
| 41 | </div> | |
| 42 | [/if] | |
| 43 | ||
| 44 | [if:$schema_missing] | |
| 45 | <div class="banner danger" style="margin-bottom:18px"> | |
| 46 | <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> | |
| 47 | <div class="text-xs">The billing tables haven't been created yet. Run the schema before using this page.</div> | |
| 48 | </div> | |
| 49 | [/if] | |
| 50 | ||
| 51 | [if:$migration_pending] | |
| 52 | <div class="banner warn" style="margin-bottom:18px"> | |
| 53 | <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> | |
| 54 | <div class="text-xs">Packages migration not run yet. Apply the <strong>DATABASE CHANGES TO RUN</strong> block from installation_instructions.html so admin edits stick.</div> | |
| 55 | </div> | |
| 56 | [/if] | |
| 57 | ||
| 58 | ||
| 59 | [if:$is_view_list] | |
| 60 | <!-- =============== LIST VIEW =============== --> | |
| 61 | <div class="card" style="padding:0;overflow:hidden"> | |
| 62 | <div class="flex items-center justify-between" style="padding:14px 20px;border-bottom:1px solid var(--col-border)"> | |
| 63 | <div> | |
| 64 | <div class="text-sm fw-600" style="color:#fff">$plan_count package[if:$plan_count ne '1']s[/if] in the catalogue</div> | |
| 65 | <div class="text-xs text-dim">Inactive packages stay in the catalogue so historical invoices keep their plan name & price.</div> | |
| 66 | </div> | |
| 67 | </div> | |
| 68 | ||
| 69 | [if:$has_plans] | |
| 70 | <div class="table-wrap"> | |
| 71 | <table class="table"> | |
| 72 | <thead> | |
| 73 | <tr> | |
| 74 | <th>Plan</th> | |
| 75 | <th>Tier</th> | |
| 76 | <th class="num">Monthly</th> | |
| 77 | <th class="num">Yearly</th> | |
| 78 | <th>Features included</th> | |
| 79 | <th>Status</th> | |
| 80 | <th></th> | |
| 81 | </tr> | |
| 82 | </thead> | |
| 83 | <tbody> | |
| 84 | [loop:@plans] | |
| 85 | <tr> | |
| 86 | <td> | |
| 87 | <div class="text-sm fw-600" style="color:#fff"> | |
| 88 | [$loop1.display_name] | |
| 89 | [if:$loop1.is_featured]<span class="adm-pill" style="margin-left:6px;background:linear-gradient(130deg,#3b82f6,#7c3aed);color:#fff">Featured</span>[/if] | |
| 90 | </div> | |
| 91 | [if:$loop1.tagline]<div class="text-xs text-dim" style="max-width:380px">[$loop1.tagline]</div>[/if] | |
| 92 | </td> | |
| 93 | <td> | |
| 94 | <div class="text-sm">[$loop1.tier_label]</div> | |
| 95 | </td> | |
| 96 | <td class="num"> | |
| 97 | <div class="text-sm fw-600" style="color:#fff">[$loop1.price_display]</div> | |
| 98 | </td> | |
| 99 | <td class="num"> | |
| 100 | [if:$loop1.has_yearly]<div class="text-sm fw-600" style="color:#fff">[$loop1.yearly_display]</div><div class="text-xs text-dim">[$loop1.yearly_mode]</div>[/if] | |
| 101 | [if:!$loop1.has_yearly]<div class="text-xs text-dim">[$loop1.yearly_display]</div>[/if] | |
| 102 | </td> | |
| 103 | <td> | |
| 104 | <div class="text-xs text-dim" style="max-width:320px;line-height:1.6">[$loop1.entitled_summary]</div> | |
| 105 | </td> | |
| 106 | <td> | |
| 107 | [if:$loop1.is_active]<span class="adm-pill" style="background:rgba(74,222,128,0.15);color:#4ade80">Active</span>[/if] | |
| 108 | [if:$loop1.is_inactive]<span class="adm-pill" style="background:rgba(148,163,184,0.15);color:#94a3b8">Retired</span>[/if] | |
| 109 | </td> | |
| 110 | <td class="num" style="white-space:nowrap"> | |
| 111 | <a href="/admin_packages.cgi?edit_id=[$loop1.id]" class="btn btn-secondary btn-sm">Edit</a> | |
| 112 | </td> | |
| 113 | </tr> | |
| 114 | [/loop] | |
| 115 | </tbody> | |
| 116 | </table> | |
| 117 | </div> | |
| 118 | [/if] | |
| 119 | ||
| 120 | [if:!$has_plans] | |
| 121 | <div style="padding:48px 20px;text-align:center"> | |
| 122 | <div class="text-sm fw-600" style="color:#fff;margin-bottom:6px">No packages yet</div> | |
| 123 | <div class="text-xs text-dim" style="margin-bottom:14px">Create your first package to start charging.</div> | |
| 124 | <a href="/admin_packages.cgi?new=1" class="btn btn-primary">+ New package</a> | |
| 125 | </div> | |
| 126 | [/if] | |
| 127 | </div> | |
| 128 | ||
| 129 | <!-- Feature catalogue. These cards are the canonical list of | |
| 130 | entitlement keys an admin can attach to a plan via the Edit | |
| 131 | form above. Clicking a card opens a details overlay so the | |
| 132 | admin can see exactly where the feature is enforced and which | |
| 133 | plans currently include it. --> | |
| 134 | <div class="card" style="padding:20px;margin-top:20px"> | |
| 135 | <div class="text-sm fw-600" style="color:#fff;margin-bottom:4px">Feature catalogue</div> | |
| 136 | <div class="text-xs text-dim" style="margin-bottom:6px;max-width:720px;line-height:1.55"> | |
| 137 | Every package above is built from these <strong>entitlement keys</strong>. Each key gates a chunk of seller-facing functionality — sidebar links, CGI endpoints, storefront pages. | |
| 138 | Open the Edit form on any package to tick which features that tier unlocks; the same checks are read by <code>has_feature()</code> across the app to allow/block actions. | |
| 139 | </div> | |
| 140 | <div class="text-xs text-dim" style="margin-bottom:12px"> | |
| 141 | <strong>Click any card</strong> for what it controls, where it's enforced, and which packages currently include it. New keys are added in <code>MODS/WebSTLs/Billing.pm</code>. | |
| 142 | </div> | |
| 143 | <style> | |
| 144 | .fc-card { | |
| 145 | padding:12px 14px;border:1px solid var(--col-border);border-radius:10px; | |
| 146 | background:var(--col-surface-2);cursor:pointer; | |
| 147 | transition:border-color 0.12s, background 0.12s, transform 0.08s; | |
| 148 | } | |
| 149 | .fc-card:hover { border-color:rgba(99,102,241,0.55); background:rgba(99,102,241,0.06); } | |
| 150 | .fc-card:active { transform:translateY(1px); } | |
| 151 | .fc-info-pill { | |
| 152 | display:inline-flex;align-items:center;gap:4px; | |
| 153 | margin-top:8px;padding:2px 8px;border-radius:999px; | |
| 154 | background:rgba(99,102,241,0.15);color:#a78bfa; | |
| 155 | font-size:10px;font-weight:700;letter-spacing:1px;text-transform:uppercase; | |
| 156 | } | |
| 157 | .fc-modal { | |
| 158 | position:fixed;inset:0;background:rgba(0,0,0,0.7); | |
| 159 | backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px); | |
| 160 | display:none;align-items:center;justify-content:center; | |
| 161 | z-index:1000;padding:20px; | |
| 162 | } | |
| 163 | .fc-modal.is-open { display:flex; } | |
| 164 | .fc-modal-card { | |
| 165 | background:linear-gradient(180deg,#161b34,#0b0f24); | |
| 166 | border:1px solid rgba(99,102,241,0.35); | |
| 167 | border-radius:16px;padding:28px 30px; | |
| 168 | max-width:560px;width:100%; | |
| 169 | box-shadow:0 24px 60px rgba(0,0,0,0.6),0 0 40px rgba(99,102,241,0.18); | |
| 170 | } | |
| 171 | .fc-modal-eyebrow { font-size:10px;font-weight:800;letter-spacing:1.8px;text-transform:uppercase;color:#a78bfa;margin-bottom:6px; } | |
| 172 | .fc-modal h3 { margin:0 0 4px;font-size:22px;color:#fff;font-family:var(--font-display,inherit); } | |
| 173 | .fc-modal .fc-slug { font-family:var(--font-mono,monospace);font-size:12px;color:var(--col-text-3);margin-bottom:14px; } | |
| 174 | .fc-modal p { color:var(--col-text-2);line-height:1.6;margin:0 0 14px;font-size:13.5px; } | |
| 175 | .fc-modal .fc-row { display:flex;gap:10px;align-items:flex-start;padding:10px 12px;border-radius:10px;background:rgba(255,255,255,0.04);margin-bottom:8px; } | |
| 176 | .fc-modal .fc-row-label { font-size:10px;font-weight:800;letter-spacing:1.2px;text-transform:uppercase;color:var(--col-text-3);min-width:96px;padding-top:2px; } | |
| 177 | .fc-modal .fc-row-val { color:var(--col-text);font-size:13px;flex:1;line-height:1.5; } | |
| 178 | .fc-modal .fc-row code { background:rgba(255,255,255,0.08);padding:1px 6px;border-radius:4px;font-size:12px; } | |
| 179 | .fc-modal-close { display:block;width:100%;margin-top:14px;padding:10px;border:0;border-radius:10px;background:rgba(255,255,255,0.06);color:#fff;font-weight:700;cursor:pointer; } | |
| 180 | .fc-modal-close:hover { background:rgba(255,255,255,0.10); } | |
| 181 | </style> | |
| 182 | <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px"> | |
| 183 | [loop:@feature_catalog] | |
| 184 | <div class="fc-card" data-fc-card | |
| 185 | data-fc-name="[$loop1.name]" | |
| 186 | data-fc-blurb="[$loop1.blurb]" | |
| 187 | data-fc-details="[$loop1.details]" | |
| 188 | data-fc-enforced="[$loop1.enforced_in]" | |
| 189 | data-fc-plans="[$loop1.plans_included]" | |
| 190 | data-fc-key="[$loop1.key]"> | |
| 191 | <div class="text-sm fw-600" style="color:#fff">[$loop1.name]</div> | |
| 192 | <div class="text-xs text-dim" style="margin-top:2px">[$loop1.blurb]</div> | |
| 193 | <div class="text-xs" style="color:var(--col-text-3);margin-top:6px;font-family:var(--font-mono,monospace)">[$loop1.key]</div> | |
| 194 | <span class="fc-info-pill"> | |
| 195 | <svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg> | |
| 196 | Click for details | |
| 197 | </span> | |
| 198 | </div> | |
| 199 | [/loop] | |
| 200 | </div> | |
| 201 | ||
| 202 | <!-- Detail modal. Populated by JS from the clicked card's data-* attrs. --> | |
| 203 | <div class="fc-modal" id="fc-modal" role="dialog" aria-modal="true" aria-labelledby="fc-modal-title"> | |
| 204 | <div class="fc-modal-card"> | |
| 205 | <div class="fc-modal-eyebrow">Entitlement key</div> | |
| 206 | <h3 id="fc-modal-title"> </h3> | |
| 207 | <div class="fc-slug" data-fc-modal-slug> </div> | |
| 208 | <p data-fc-modal-blurb> </p> | |
| 209 | <p data-fc-modal-details style="color:var(--col-text-2)"> </p> | |
| 210 | <div class="fc-row"> | |
| 211 | <div class="fc-row-label">Enforced in</div> | |
| 212 | <div class="fc-row-val" data-fc-modal-enforced> </div> | |
| 213 | </div> | |
| 214 | <div class="fc-row"> | |
| 215 | <div class="fc-row-label">Included in</div> | |
| 216 | <div class="fc-row-val" data-fc-modal-plans> </div> | |
| 217 | </div> | |
| 218 | <button type="button" class="fc-modal-close" data-fc-modal-close>Close</button> | |
| 219 | </div> | |
| 220 | </div> | |
| 221 | ||
| 222 | <script> | |
| 223 | (function () { | |
| 224 | var modal = document.getElementById('fc-modal'); | |
| 225 | var elTitle = document.getElementById('fc-modal-title'); | |
| 226 | var elSlug = modal.querySelector('[data-fc-modal-slug]'); | |
| 227 | var elBlurb = modal.querySelector('[data-fc-modal-blurb]'); | |
| 228 | var elDetails = modal.querySelector('[data-fc-modal-details]'); | |
| 229 | var elEnforced = modal.querySelector('[data-fc-modal-enforced]'); | |
| 230 | var elPlans = modal.querySelector('[data-fc-modal-plans]'); | |
| 231 | function open(card) { | |
| 232 | elTitle.textContent = card.getAttribute('data-fc-name') || ''; | |
| 233 | elSlug.innerHTML = 'slug: <code style="background:rgba(255,255,255,0.08);padding:1px 6px;border-radius:4px">' + (card.getAttribute('data-fc-key') || '') + '</code>'; | |
| 234 | elBlurb.textContent = card.getAttribute('data-fc-blurb') || ''; | |
| 235 | elDetails.textContent = card.getAttribute('data-fc-details') || ''; | |
| 236 | elEnforced.textContent = card.getAttribute('data-fc-enforced') || ''; | |
| 237 | elPlans.textContent = card.getAttribute('data-fc-plans') || ''; | |
| 238 | modal.classList.add('is-open'); | |
| 239 | } | |
| 240 | function close() { modal.classList.remove('is-open'); } | |
| 241 | document.querySelectorAll('[data-fc-card]').forEach(function (c) { | |
| 242 | c.addEventListener('click', function () { open(c); }); | |
| 243 | }); | |
| 244 | modal.querySelector('[data-fc-modal-close]').addEventListener('click', close); | |
| 245 | modal.addEventListener('click', function (e) { if (e.target === modal) close(); }); | |
| 246 | document.addEventListener('keydown', function (e) { | |
| 247 | if (e.key === 'Escape' && modal.classList.contains('is-open')) close(); | |
| 248 | }); | |
| 249 | })(); | |
| 250 | </script> | |
| 251 | </div> | |
| 252 | [/if] | |
| 253 | ||
| 254 | ||
| 255 | [if:$is_view_form] | |
| 256 | <!-- =============== EDIT / NEW VIEW =============== --> | |
| 257 | <form method="POST" action="/admin_packages_action.cgi"> | |
| 258 | [if:$is_view_edit]<input type="hidden" name="plan_id" value="$id">[/if] | |
| 259 | ||
| 260 | <div style="display:grid;grid-template-columns:1fr 360px;gap:20px;align-items:flex-start"> | |
| 261 | ||
| 262 | <!-- LEFT column: identity + pricing + marketing copy --> | |
| 263 | <div style="display:flex;flex-direction:column;gap:20px"> | |
| 264 | ||
| 265 | <div class="card" style="padding:20px"> | |
| 266 | <div class="text-sm fw-600" style="color:#fff;margin-bottom:4px">Identity</div> | |
| 267 | <div class="text-xs text-dim" style="margin-bottom:14px">One row per tier. Monthly price is the headline; yearly is an optional discount configured below.</div> | |
| 268 | ||
| 269 | <div class="form-group"> | |
| 270 | <label class="form-label">Tier slug</label> | |
| 271 | <input class="input" type="text" name="tier" value="$tier" placeholder="pro" required> | |
| 272 | <div class="text-xs text-dim" style="margin-top:6px">Lowercase letters, digits, dash/underscore.</div> | |
| 273 | </div> | |
| 274 | ||
| 275 | <div class="form-group"> | |
| 276 | <label class="form-label">Display name</label> | |
| 277 | <input class="input" type="text" name="display_name" value="$display_name" placeholder="Pro" required> | |
| 278 | </div> | |
| 279 | ||
| 280 | <div class="form-group"> | |
| 281 | <label class="form-label">Tagline (one line under the price)</label> | |
| 282 | <input class="input" type="text" name="tagline" value="$tagline" placeholder="Storefront + optimization for creators going independent."> | |
| 283 | </div> | |
| 284 | ||
| 285 | <div class="form-group"> | |
| 286 | <label class="form-label">CTA button label</label> | |
| 287 | <input class="input" type="text" name="cta_label" value="$cta_label" placeholder="Start 14-day free trial"> | |
| 288 | </div> | |
| 289 | </div> | |
| 290 | ||
| 291 | <div class="card" style="padding:20px"> | |
| 292 | <div class="text-sm fw-600" style="color:#fff;margin-bottom:4px">Pricing</div> | |
| 293 | <div class="text-xs text-dim" style="margin-bottom:14px">Stored as cents. Enter dollars; we round to the nearest cent.</div> | |
| 294 | <div style="display:grid;grid-template-columns:160px 120px 120px;gap:14px"> | |
| 295 | <div class="form-group"> | |
| 296 | <label class="form-label">Price (dollars)</label> | |
| 297 | <input class="input" type="number" step="0.01" min="0" name="price_dollars" value="$price_dollars" required> | |
| 298 | </div> | |
| 299 | <div class="form-group"> | |
| 300 | <label class="form-label">Currency</label> | |
| 301 | <input class="input" type="text" name="currency" value="$currency" maxlength="3" pattern="[A-Za-z]{3}" required> | |
| 302 | </div> | |
| 303 | <div class="form-group"> | |
| 304 | <label class="form-label">Sort order</label> | |
| 305 | <input class="input" type="number" name="sort_order" value="$sort_order"> | |
| 306 | </div> | |
| 307 | </div> | |
| 308 | </div> | |
| 309 | ||
| 310 | <div class="card" style="padding:20px"> | |
| 311 | <div class="text-sm fw-600" style="color:#fff;margin-bottom:4px">Yearly pricing</div> | |
| 312 | <div class="text-xs text-dim" style="margin-bottom:14px">Offer an annual discount on this plan. Pick one mode — only its input is used. Effective yearly price: <strong style="color:#fff">$yearly_computed</strong></div> | |
| 313 | ||
| 314 | <div class="form-group"> | |
| 315 | <label class="form-label">Mode</label> | |
| 316 | <select class="input" name="yearly_mode" id="ym-select" onchange="ymToggleInputs()"> | |
| 317 | <option value="none"$yearly_mode_none_sel>None — monthly only</option> | |
| 318 | <option value="percent_off"$yearly_mode_percent_sel>Percent off the 12-month total</option> | |
| 319 | <option value="dollars_off"$yearly_mode_dollars_sel>Dollars off the 12-month total</option> | |
| 320 | <option value="explicit"$yearly_mode_explicit_sel>Explicit yearly price</option> | |
| 321 | </select> | |
| 322 | </div> | |
| 323 | ||
| 324 | <div class="form-group" data-ym-input="percent_off"> | |
| 325 | <label class="form-label">Percent off (1–90)</label> | |
| 326 | <input class="input" type="number" min="0" max="90" step="1" name="yearly_pct_off" value="$yearly_pct_off"> | |
| 327 | <div class="text-xs text-dim" style="margin-top:6px">17% ≈ "2 months free". 20% is a clean round discount.</div> | |
| 328 | </div> | |
| 329 | ||
| 330 | <div class="form-group" data-ym-input="dollars_off"> | |
| 331 | <label class="form-label">Dollars off the 12-month total</label> | |
| 332 | <input class="input" type="number" min="0" step="0.01" name="yearly_off_dollars" value="$yearly_off_dollars"> | |
| 333 | <div class="text-xs text-dim" style="margin-top:6px">e.g. enter <code>58.00</code> to take $58 off the annual total.</div> | |
| 334 | </div> | |
| 335 | ||
| 336 | <div class="form-group" data-ym-input="explicit"> | |
| 337 | <label class="form-label">Explicit yearly price (dollars)</label> | |
| 338 | <input class="input" type="number" min="0" step="0.01" name="yearly_price_dollars" value="$yearly_price_dollars"> | |
| 339 | <div class="text-xs text-dim" style="margin-top:6px">Whatever you type is the exact yearly price. Use this for marketing-friendly numbers like $290 / yr.</div> | |
| 340 | </div> | |
| 341 | ||
| 342 | <script> | |
| 343 | function ymToggleInputs() { | |
| 344 | var mode = document.getElementById('ym-select').value; | |
| 345 | document.querySelectorAll('[data-ym-input]').forEach(function (el) { | |
| 346 | el.style.display = (el.getAttribute('data-ym-input') === mode) ? '' : 'none'; | |
| 347 | }); | |
| 348 | } | |
| 349 | // Hide non-selected inputs on first render. | |
| 350 | ymToggleInputs(); | |
| 351 | </script> | |
| 352 | </div> | |
| 353 | ||
| 354 | <div class="card" style="padding:20px"> | |
| 355 | <div class="text-sm fw-600" style="color:#fff;margin-bottom:4px">Marketing bullets (JSON)</div> | |
| 356 | <div class="text-xs text-dim" style="margin-bottom:14px">Free-text bullets shown on the pricing card. Use this JSON shape: <code>[{"name":"Bullet text","included":true}, ...]</code>. <code>included:false</code> renders as a struck-through line. Leave blank to derive bullets from the entitlement checklist below.</div> | |
| 357 | <textarea class="input" name="features_raw" rows="8" style="font-family:var(--font-mono,monospace);font-size:12px">$features_raw</textarea> | |
| 358 | </div> | |
| 359 | ||
| 360 | </div> | |
| 361 | ||
| 362 | <!-- RIGHT column: entitlements + flags --> | |
| 363 | <div style="display:flex;flex-direction:column;gap:20px"> | |
| 364 | ||
| 365 | <div class="card" style="padding:20px"> | |
| 366 | <div class="text-sm fw-600" style="color:#fff;margin-bottom:4px">Feature entitlements</div> | |
| 367 | <div class="text-xs text-dim" style="margin-bottom:14px">These boxes gate the toggles on preferences.cgi. Users on this plan can switch on the checked features; unchecked features show as locked with an Upgrade CTA.</div> | |
| 368 | <div style="display:flex;flex-direction:column;gap:8px"> | |
| 369 | [loop:@edit_features] | |
| 370 | <label class="flex items-center" style="gap:10px;padding:10px 12px;border:1px solid var(--col-border);border-radius:10px;background:var(--col-surface-2);cursor:pointer"> | |
| 371 | <input type="checkbox" name="feature_keys" value="[$loop1.key]" [$loop1.checked]> | |
| 372 | <span style="flex:1"> | |
| 373 | <span class="text-sm fw-600" style="color:#fff">[$loop1.name]</span> | |
| 374 | <span class="text-xs text-dim" style="display:block">[$loop1.blurb]</span> | |
| 375 | </span> | |
| 376 | </label> | |
| 377 | [/loop] | |
| 378 | </div> | |
| 379 | </div> | |
| 380 | ||
| 381 | <div class="card" style="padding:20px"> | |
| 382 | <div class="text-sm fw-600" style="color:#fff;margin-bottom:14px">Visibility flags</div> | |
| 383 | <label class="checkbox" style="margin-bottom:10px"> | |
| 384 | <input type="checkbox" name="is_active" value="1" $is_active_checked> Active — shown on pricing.cgi | |
| 385 | </label> | |
| 386 | <label class="checkbox"> | |
| 387 | <input type="checkbox" name="is_featured" value="1" $is_featured_checked> Featured — "Most popular" badge on pricing.cgi | |
| 388 | </label> | |
| 389 | </div> | |
| 390 | ||
| 391 | <div class="card" style="padding:16px 20px;background:rgba(59,130,246,0.06);border-color:rgba(59,130,246,0.25)"> | |
| 392 | <div class="text-xs text-dim" style="line-height:1.6"> | |
| 393 | Changes take effect immediately on pricing.cgi, the landing page pricing block, and every user's settings page. Existing subscribers keep the plan they're on — renaming or repricing doesn't retroactively re-bill them. | |
| 394 | </div> | |
| 395 | </div> | |
| 396 | </div> | |
| 397 | </div> | |
| 398 | ||
| 399 | <div style="display:flex;gap:10px;justify-content:flex-end;margin-top:22px"> | |
| 400 | [if:$is_view_edit] | |
| 401 | [if:$is_active] | |
| 402 | <button type="submit" formaction="/admin_packages_action.cgi" name="action" value="retire" class="btn btn-danger" formnovalidate>Retire plan</button> | |
| 403 | [/if] | |
| 404 | [if:!$is_active] | |
| 405 | <button type="submit" formaction="/admin_packages_action.cgi" name="action" value="restore" class="btn btn-secondary" formnovalidate>Restore plan</button> | |
| 406 | [/if] | |
| 407 | [/if] | |
| 408 | <a href="/admin_packages.cgi" class="btn btn-secondary">Cancel</a> | |
| 409 | [if:$is_view_new]<button type="submit" name="action" value="create" class="btn btn-primary">Create package</button>[/if] | |
| 410 | [if:$is_view_edit]<button type="submit" name="action" value="save" class="btn btn-primary">Save changes</button>[/if] | |
| 411 | </div> | |
| 412 | </form> | |
| 413 | [/if] | |
| 414 | ||
| 415 | </div> |