Diff -- /var/www/vhosts/webstls.com/httpdocs/TEMPLATES/webstls_admin_packages.html
Diff

/var/www/vhosts/webstls.com/httpdocs/TEMPLATES/webstls_admin_packages.html

added on WebSTLs (webstls.com) at 2026-07-01 22:26:56

Added
+415
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to a129d4326659
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
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 &middot; Catalogue</div>
6 <h1 style="font-family:var(--font-display);font-size:28px;color:#fff;margin:6px 0 2px">Pricing &amp; 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> panel.
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">&larr; 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 &amp; 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 &mdash; 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">&nbsp;</h3>
207 <div class="fc-slug" data-fc-modal-slug>&nbsp;</div>
208 <p data-fc-modal-blurb>&nbsp;</p>
209 <p data-fc-modal-details style="color:var(--col-text-2)">&nbsp;</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>&nbsp;</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>&nbsp;</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 &mdash; 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 &mdash; 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&ndash;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% &asymp; "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 &mdash; shown on pricing.cgi
385 </label>
386 <label class="checkbox">
387 <input type="checkbox" name="is_featured" value="1" $is_featured_checked> Featured &mdash; "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 &mdash; 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>
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help