@blamejs/blamejs-shop 0.1.37 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/lib/admin.js +205 -42
  3. package/lib/asset-manifest.json +5 -5
  4. package/lib/storefront.js +428 -51
  5. package/lib/vendor/MANIFEST.json +2 -2
  6. package/lib/vendor/blamejs/CHANGELOG.md +12 -0
  7. package/lib/vendor/blamejs/README.md +2 -2
  8. package/lib/vendor/blamejs/SECURITY.md +1 -0
  9. package/lib/vendor/blamejs/api-snapshot.json +2 -2
  10. package/lib/vendor/blamejs/lib/ai-disclosure.js +2 -3
  11. package/lib/vendor/blamejs/lib/archive-gz.js +5 -3
  12. package/lib/vendor/blamejs/lib/archive-read.js +101 -35
  13. package/lib/vendor/blamejs/lib/archive-tar-read.js +86 -31
  14. package/lib/vendor/blamejs/lib/archive-tar.js +2 -3
  15. package/lib/vendor/blamejs/lib/auth/fal.js +12 -0
  16. package/lib/vendor/blamejs/lib/auth/jwt-external.js +15 -11
  17. package/lib/vendor/blamejs/lib/auth/jwt.js +2 -2
  18. package/lib/vendor/blamejs/lib/auth/oauth.js +7 -6
  19. package/lib/vendor/blamejs/lib/auth/oid4vci.js +3 -3
  20. package/lib/vendor/blamejs/lib/auth/saml.js +15 -12
  21. package/lib/vendor/blamejs/lib/auth/sd-jwt-vc.js +3 -3
  22. package/lib/vendor/blamejs/lib/backup/index.js +2 -3
  23. package/lib/vendor/blamejs/lib/calendar.js +9 -2
  24. package/lib/vendor/blamejs/lib/circuit-breaker.js +5 -4
  25. package/lib/vendor/blamejs/lib/cose.js +4 -3
  26. package/lib/vendor/blamejs/lib/crypto-hpke.js +1 -1
  27. package/lib/vendor/blamejs/lib/crypto-oprf.js +2 -2
  28. package/lib/vendor/blamejs/lib/crypto.js +2 -2
  29. package/lib/vendor/blamejs/lib/framework-error.js +2 -1
  30. package/lib/vendor/blamejs/lib/guard-jwt.js +3 -2
  31. package/lib/vendor/blamejs/lib/guard-smtp-command.js +2 -2
  32. package/lib/vendor/blamejs/lib/mail-auth.js +2 -2
  33. package/lib/vendor/blamejs/lib/mail-crypto-pgp.js +1 -1
  34. package/lib/vendor/blamejs/lib/mail-crypto-smime.js +7 -7
  35. package/lib/vendor/blamejs/lib/mail-crypto.js +1 -1
  36. package/lib/vendor/blamejs/lib/mail-dav.js +5 -4
  37. package/lib/vendor/blamejs/lib/mail-deploy.js +3 -2
  38. package/lib/vendor/blamejs/lib/mail-server-imap.js +1 -1
  39. package/lib/vendor/blamejs/lib/mail-server-jmap.js +10 -10
  40. package/lib/vendor/blamejs/lib/mail-server-managesieve.js +1 -1
  41. package/lib/vendor/blamejs/lib/mail-server-mx.js +142 -47
  42. package/lib/vendor/blamejs/lib/mail-server-submission.js +6 -5
  43. package/lib/vendor/blamejs/lib/mail-store.js +2 -2
  44. package/lib/vendor/blamejs/lib/mail.js +2 -2
  45. package/lib/vendor/blamejs/lib/mdoc.js +14 -14
  46. package/lib/vendor/blamejs/lib/network-dnssec.js +10 -8
  47. package/lib/vendor/blamejs/lib/network-tls.js +10 -7
  48. package/lib/vendor/blamejs/lib/safe-decompress.js +8 -6
  49. package/lib/vendor/blamejs/lib/safe-ical.js +12 -12
  50. package/lib/vendor/blamejs/lib/safe-mime.js +6 -6
  51. package/lib/vendor/blamejs/lib/safe-sieve.js +1 -1
  52. package/lib/vendor/blamejs/lib/safe-smtp.js +1 -1
  53. package/lib/vendor/blamejs/package.json +1 -1
  54. package/lib/vendor/blamejs/release-notes/v0.13.10.json +44 -0
  55. package/lib/vendor/blamejs/release-notes/v0.13.11.json +27 -0
  56. package/lib/vendor/blamejs/release-notes/v0.13.12.json +36 -0
  57. package/lib/vendor/blamejs/release-notes/v0.13.7.json +27 -0
  58. package/lib/vendor/blamejs/release-notes/v0.13.8.json +27 -0
  59. package/lib/vendor/blamejs/release-notes/v0.13.9.json +27 -0
  60. package/lib/vendor/blamejs/test/layer-0-primitives/archive-read.test.js +82 -0
  61. package/lib/vendor/blamejs/test/layer-0-primitives/auth-jwt-defenses.test.js +4 -4
  62. package/lib/vendor/blamejs/test/layer-0-primitives/calendar.test.js +31 -0
  63. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +188 -6
  64. package/lib/vendor/blamejs/test/layer-0-primitives/fal.test.js +26 -0
  65. package/lib/vendor/blamejs/test/layer-0-primitives/mail-crypto-smime.test.js +5 -5
  66. package/lib/vendor/blamejs/test/layer-0-primitives/mail-dav.test.js +1 -1
  67. package/lib/vendor/blamejs/test/layer-0-primitives/mail-server-mx.test.js +166 -1
  68. package/lib/vendor/blamejs/test/layer-0-primitives/rate-limit-cluster.test.js +25 -18
  69. package/lib/vendor/blamejs/test/layer-0-primitives/safe-ical.test.js +2 -2
  70. package/lib/vendor/blamejs/test/layer-0-primitives/sandbox.test.js +12 -12
  71. package/lib/vendor/blamejs/test/layer-0-primitives/scheduler-exactly-once.test.js +15 -9
  72. package/lib/vendor/blamejs/test/layer-0-primitives/websocket-channels.test.js +0 -6
  73. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -6,8 +6,14 @@ Pre-1.0 the surface is intentionally evolving — every release may
6
6
  change something operators depend on. Read each entry before
7
7
  upgrading across more than a few patches at a time.
8
8
 
9
+ ## v0.2.x
10
+
11
+ - v0.2.0 (2026-05-27) — **A new storefront visual identity — a dark, violet brand theme with self-hosted typefaces and a redesigned product buy-box.** The default storefront theme has been redesigned around the project's actual brand: a dark, near-black canvas with a violet-to-magenta accent system that matches the blamejs shield-and-terminal mark, replacing the previous light theme and its unrelated orange accent. The stylesheet is restructured onto a semantic design-token layer (a purpose tier — surface, text, border, accent, price — sitting over the raw palette), so colour, contrast, and surfaces are now consistent and themeable rather than hard-coded per component. Two distinctive open-licensed typefaces are self-hosted in place of Inter: Hanken Grotesk for display and body, Space Mono for the terminal-style chrome (navigation, labels, SKUs, prices). The product page's variant selector is rebuilt from a data table into a proper buy-box: a prominent price, the variant(s) as selectable chips, a full-width add-to-cart, and a post-quantum-secured-checkout trust line — the add-to-cart, variant, and currency-conversion behaviour is unchanged. Accessibility is preserved (contrast targets met on the dark surfaces, focus-visible, reduced-motion guards, the consent banner restyled as legible dark glass). Operators on the default theme get the new look on upgrade; a custom theme passed via the theme primitive is unaffected. **Changed:** *Dark violet brand theme replaces the light theme* — The default theme is now a dark, near-black storefront with a violet→magenta accent family (matching the logo's shield/terminal mark) carried across the chrome, hero, cards, forms, badges, switchers, and footer. The previous light canvas and its off-brand orange accent are gone. Depth comes from hard-edged and soft violet shadows and a faint circuit-grid texture; motion (a staggered page-load reveal, a glitch treatment on the hero accent word, a terminal cursor) is CSS-only and disabled under `prefers-reduced-motion`. · *Semantic design-token layer + self-hosted typefaces* — The stylesheet now has a primitive→semantic token structure (`--surface`/`--surface-raised`, `--text`/`--text-muted`, `--border`, `--color-accent`, `--color-price`, glow tokens) so components reference purpose, not raw values — fixing the previously undefined `--font-sans`/`--danger-l` tokens and a duplicated button definition along the way. Hanken Grotesk (display/body) and Space Mono (mono chrome) are self-hosted as OFL woff2, replacing Inter; nothing is fetched from a font CDN, consistent with the strict `font-src 'self'` policy. · *Product buy-box redesign* — The product page's variant table is replaced by a buy-box: a large monospace price, each variant as a selectable pill chip (with a clear selected state, fully operable with JavaScript off), a full-width add-to-cart, and a trust line stating the checkout is post-quantum secured. The underlying add-to-cart form, multi-variant selection, and multi-currency price formatting are unchanged; products with many variants keep a compact fallback. The PDP trust badge wording "Ships from origin" is replaced with a real delivery estimate.
12
+
9
13
  ## v0.1.x
10
14
 
15
+ - v0.1.38 (2026-05-26) — **Security hardening + accessibility and UI completeness across the storefront and admin.** This release closes two data-exposure gaps, tightens admin input validation, and brings a broad accessibility and visual-completeness pass to both the storefront and the operator console. A freshly issued gift-card code is no longer placed in a redirect URL where it could persist in browser history or server logs — it's now revealed once in the page itself. Signed-in order pages now enforce ownership so a customer can only view their own orders. On accessibility: admin tables carry proper header scope, every admin page has its own title, a skip-to-content link and current-page navigation state are in place, search-filter selection is exposed correctly to assistive technology, status changes are announced, and destructive actions (refunds, plan archival, endpoint deletion, subscription cancellation, address removal) now require a confirmation step. Several surfaces that shipped without styling — the faceted-search sidebar, the subscriptions page, loyalty and referral badges, and the locale switcher — are now fully styled; product cards gain depth, accent-colored text meets contrast requirements, touch targets meet the 44px minimum on mobile, and success messages and empty states are consistent. **Changed:** *Confirmation step on destructive actions* — Irreversible or money-moving actions now route through a server-rendered confirmation page that states the consequence before proceeding: order refunds and subscription-plan archival and webhook deletion in the admin, and subscription cancellation (including the immediate-cancel option, which now spells out the forfeited time) and saved-address removal (with an undo link) in the customer account. · *Success confirmations and consistent empty states* — Account actions — saving or removing an address, toggling the wishlist, moving a saved item, requesting a return (which now echoes the RMA code) — confirm success with an announced status message instead of redirecting silently. Empty states across the account area now share one consistent treatment. **Fixed:** *Accessibility across storefront and admin* — Admin data tables now use `scope` on column and row headers; every admin page renders its own document title; a skip-to-content link and `aria-current` navigation state are in place. On the storefront, search-facet links expose their selected state correctly to screen readers (replacing an invalid attribute), and account status changes are announced via live regions. Accent-colored text (prices, labels, links) now uses a darker shade that meets the 4.5:1 contrast minimum, and interactive controls meet the 44px touch-target minimum on small screens. · *Styling for surfaces that shipped unstyled* — The faceted-search sidebar, the subscriptions management page, loyalty and referral status badges, and the footer locale switcher now render with full styling instead of unstyled browser defaults. Product and collection cards gain a resting elevation, a duplicate button definition was consolidated to one consistent style, and disabled-button and placeholder text now meet contrast requirements. **Security:** *Gift-card one-time code is no longer exposed in a URL* — Issuing a gift card from the admin console previously redirected to a URL containing the plaintext code, which could persist in browser history, the redirect `Location` header, and access logs. The code is now shown once directly in the response body (the same pattern used for webhook signing secrets) and never placed in a URL. · *Order pages enforce ownership for signed-in customers* — `GET /orders/:id` now verifies that a signed-in customer owns the order before rendering it (returning 404 otherwise), closing a gap where an order's contents could be viewed by anyone with the order id. The guest post-purchase confirmation path is unchanged. · *Strict validation on admin money inputs* — Gift-card amounts and subscription-plan amounts/intervals/trials are now parsed with strict integer validation, so malformed input (e.g. `50abc`) is rejected rather than silently coerced.
16
+
11
17
  - v0.1.37 (2026-05-26) — **Localized storefront — the UI chrome renders in the visitor's language, with a locale switcher and right-to-left support.** The storefront's interface chrome — the navigation, search controls, newsletter band, and footer — now renders in the visitor's language. The locale is resolved the same way at the edge and in the container: an explicit `?lang=` choice first, then a saved preference cookie, then the browser's Accept-Language header, then the operator's default. A locale switcher in the footer lets the visitor change it; the choice persists in a first-party cookie. Right-to-left languages (Arabic, Hebrew, Persian, …) flip the document direction automatically. Strings come from the operator's locale policy and translation rows, and any key without a translation falls back to the English baseline rather than showing a raw placeholder — so a partially translated catalog never renders broken text. The default locale renders at the edge for cookie-less visitors (so cached pages stay fast); a non-default choice is served by the container. Everything works with JavaScript off, and the localized markup is byte-identical across the edge and the container. **Added:** *Localized UI chrome + footer locale switcher* — Navigation, search labels, the newsletter band, and the footer render in the visitor's locale. A locale switcher in the footer lists the languages the operator has enabled (shown by their own names — "Deutsch", "Français"); selecting one persists the choice in a first-party cookie and returns the visitor to the page they were on. The locale resolves identically at the edge and in the container — `?lang=`, then the cookie, then `Accept-Language`, then the operator default — and a right-to-left language sets the document's `dir` so the layout mirrors correctly. The switcher and selection work without JavaScript. · *Operator locale policy with English-baseline fallback* — Enable languages by setting a locale policy (a default locale plus the supported list) and supplying translation rows for the chrome strings; the storefront resolves each string against the active policy, layered over the operator's translations, and falls back to the built-in English baseline for any key still missing — never a raw key or empty label. With no policy seeded the storefront renders the English baseline and shows no switcher, so the feature is opt-in and a fresh install is unaffected. `SHOP_DEFAULT_LOCALE` sets the default the edge serves to cookie-less visitors; set `SHOP_LOCALES` (the supported list) so the edge forwards a first-time visitor whose `Accept-Language` prefers a supported non-default locale to the container instead of caching the default. An explicit cookie or `?lang=` choice is always served by the container so edge caching stays simple.
12
18
 
13
19
  - v0.1.36 (2026-05-26) — **Multi-currency display — shoppers browse prices in their own currency while you still charge in your base currency.** Shoppers can now pick a display currency from a switcher in the footer; every price on the storefront — product pages, the grid, search, and the cart — re-renders in that currency using the foreign-exchange rates you set, formatted with the right symbol, grouping, and decimal places for the locale. Conversion is display-only: the cart line and the eventual charge stay in your base currency, and a "Prices shown in EUR; you'll be charged in USD" disclosure makes that explicit whenever a conversion is in effect. The choice rides in a sealed first-party cookie and survives navigation; a per-currency rounding rule can snap converted prices to a tidy increment (for example Swiss 0.05 Rappenrundung). Every failure mode falls back to the base currency rather than showing a broken price: a currency with no rate on file, a rate that has expired, a currency you've removed from the allow-list, or a tampered cookie all render in your base currency. The switcher and all conversions render identically from the edge and the container, and the whole flow works with JavaScript off. **Added:** *Currency switcher + on-page price conversion* — A currency switcher in the footer lists the currencies you've enabled; choosing one `POST`s to `/currency`, stores the choice in a sealed cookie, and redirects back to the page the shopper was on. From then on product pages, the product grid, search results, and the cart display prices converted into that currency, each formatted for its locale (symbol, thousands separator, decimal places). When a conversion is active a "Prices shown in <display>; you'll be charged in <base>" note appears so the shopper always knows the charge currency. The switcher submits without JavaScript — the small enhancement script only auto-submits on change. · *Operator-set FX rates, display rounding, and a base-currency guarantee* — Set your base currency and the enabled list with `SHOP_BASE_CURRENCY` / `SHOP_CURRENCIES` (or the `shop.base_currency` / `shop.currencies` config keys), and supply foreign-exchange rates yourself from whatever source you trust — the framework never reaches out to a rate feed. Rates are stored as integer basis points and conversion uses banker's rounding; an optional per-currency rounding rule snaps the result to a display increment (e.g. nearest 0.05). Conversion is strictly for display: the cart line's currency and amount, and therefore the amount charged at checkout, always stay in the base currency. Any currency without a usable rate — missing, expired, de-listed, or a tampered cookie — falls back to base-currency display instead of a broken or `NaN` price, so a stale or absent rate feed never breaks a priced page.
package/lib/admin.js CHANGED
@@ -117,6 +117,27 @@ function _htmlEscape(s) {
117
117
  return b.template.escapeHtml(String(s));
118
118
  }
119
119
 
120
+ // Strict integer coercion for money / count form fields. Refuses
121
+ // "50abc" / "" / floats, unlike parseInt's loose prefix match (which
122
+ // silently turns "50abc" into 50). Accepts an already-numeric value
123
+ // (must itself be a safe integer). Throws a TypeError whose message
124
+ // carries the caller's prefix so the browser form path surfaces it as
125
+ // a 400 notice. Same shape as the collections-rule _strictInt nested
126
+ // helper, hoisted to module scope so the gift-card + subscription-plan
127
+ // create paths share it.
128
+ function _strictMinorInt(value, prefix, label) {
129
+ if (typeof value === "number") {
130
+ if (!Number.isSafeInteger(value)) throw new TypeError(prefix + ": " + label + " out of range");
131
+ return value;
132
+ }
133
+ if (typeof value !== "string" || !/^-?\d+$/.test(value.trim())) {
134
+ throw new TypeError(prefix + ": " + label + " must be an integer");
135
+ }
136
+ var n = Number(value.trim());
137
+ if (!Number.isSafeInteger(n)) throw new TypeError(prefix + ": " + label + " out of range");
138
+ return n;
139
+ }
140
+
120
141
  // ---- bearer auth --------------------------------------------------------
121
142
 
122
143
  function _readBearer(req) {
@@ -163,6 +184,26 @@ function _setAdminCookie(res) {
163
184
  function _clearAdminCookie(res) {
164
185
  _adminJar().clear(res, ADMIN_COOKIE_NAME);
165
186
  }
187
+
188
+ // One-time gift-card code reveal. The issue POST stashes the freshly-issued
189
+ // plaintext code in a sealed, HttpOnly, /admin-scoped cookie and 303-redirects
190
+ // (Post/Redirect/Get — so a refresh of the detail page can't re-issue a card),
191
+ // WITHOUT placing the code in the URL / Location header / browser history /
192
+ // access log. The detail GET reads it exactly once and clears it, so a reload
193
+ // after the reveal shows the normal card with no code.
194
+ var GC_REVEAL_COOKIE = "gc_reveal";
195
+ function _stashGiftCardReveal(res, id, code) {
196
+ _adminJar().writeSealed(res, GC_REVEAL_COOKIE, JSON.stringify({ id: id, code: code }),
197
+ { expires: new Date(Date.now() + b.constants.TIME.hours(1)) });
198
+ }
199
+ function _takeGiftCardReveal(req, res, id) {
200
+ var raw = _adminJar().readSealed(req, GC_REVEAL_COOKIE);
201
+ if (raw === null) return null;
202
+ _adminJar().clear(res, GC_REVEAL_COOKIE);
203
+ var env;
204
+ try { env = JSON.parse(raw); } catch (_e) { return null; }
205
+ return (env && env.id === id && typeof env.code === "string") ? env.code : null;
206
+ }
166
207
  function _adminCookieValid(req) {
167
208
  var raw = _adminJar().readSealed(req, ADMIN_COOKIE_NAME);
168
209
  if (raw === null) return false;
@@ -855,6 +896,35 @@ function mount(router, deps) {
855
896
  return { refund: refund, order: await order.get(o.id) };
856
897
  }
857
898
 
899
+ // Browser confirmation interstitial for the full refund — it moves
900
+ // money via the provider and advances the FSM, so the console makes
901
+ // the operator confirm (the CSP forbids a client confirm() dialog).
902
+ // Bearer clients never reach here; they keep POSTing /refund directly.
903
+ router.post("/admin/orders/:id/refund/confirm", _pageOrApi(false,
904
+ R(async function (_req, res) {
905
+ return _problem(res, 405, "use-canonical-endpoint", "POST /admin/orders/:id/refund directly for the JSON API");
906
+ }),
907
+ async function (req, res) {
908
+ var id = req.params.id;
909
+ var o;
910
+ try { o = await order.get(id); }
911
+ catch (e) { if (!(e instanceof TypeError)) throw e; o = null; }
912
+ if (!o || !o.payment_intent_id) {
913
+ return _redirect(res, "/admin/orders/" + encodeURIComponent(id) + "?err=1");
914
+ }
915
+ var amount = pricing.format(o.grand_total_minor, o.currency);
916
+ _sendHtml(res, 200, renderAdminConfirm({
917
+ shop_name: deps.shop_name, nav_available: navAvailable, active: "orders",
918
+ heading: "Refund order " + o.id.slice(0, 8) + "?",
919
+ consequence: "This issues a full refund of " + amount + " through the payment provider and cannot be undone.",
920
+ detail: "The provider refund runs first; only if it succeeds does the order move to a refunded state.",
921
+ action: "/admin/orders/" + _htmlEscape(o.id) + "/refund",
922
+ confirm_label: "Refund " + amount,
923
+ cancel_href: "/admin/orders/" + encodeURIComponent(id),
924
+ }));
925
+ },
926
+ ));
927
+
858
928
  router.post("/admin/orders/:id/refund", _pageOrApi(false,
859
929
  W("order.refund", async function (req, res) {
860
930
  var o = await order.get(req.params.id);
@@ -1549,6 +1619,31 @@ function mount(router, deps) {
1549
1619
  },
1550
1620
  ));
1551
1621
 
1622
+ // Browser confirmation interstitial for delete — removing an
1623
+ // endpoint is irreversible (its signing secret is gone for good and
1624
+ // deliveries stop), so the console confirms before the real DELETE.
1625
+ router.post("/admin/webhooks/:id/delete/confirm", _pageOrApi(false,
1626
+ R(async function (_req, res) {
1627
+ return _problem(res, 405, "use-canonical-endpoint", "DELETE /admin/webhooks/:id (or POST .../delete) directly for the JSON API");
1628
+ }),
1629
+ async function (req, res) {
1630
+ var id = req.params.id;
1631
+ var ep = null;
1632
+ try { ep = await webhooks.endpoints.get(id); }
1633
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
1634
+ if (!ep) return _redirect(res, "/admin/webhooks?err=1");
1635
+ _sendHtml(res, 200, renderAdminConfirm({
1636
+ shop_name: deps.shop_name, nav_available: navAvailable, active: "webhooks",
1637
+ heading: "Delete this endpoint?",
1638
+ consequence: "Deleting the endpoint is permanent — its signing secret is destroyed and no further deliveries are sent.",
1639
+ detail: ep.url ? "Endpoint: " + ep.url : "This endpoint will be removed.",
1640
+ action: "/admin/webhooks/" + _htmlEscape(id) + "/delete",
1641
+ confirm_label: "Delete endpoint",
1642
+ cancel_href: "/admin/webhooks",
1643
+ }));
1644
+ },
1645
+ ));
1646
+
1552
1647
  router.post("/admin/webhooks/:id/delete", _pageOrApi(false,
1553
1648
  W("webhook.delete", async function (req, res) {
1554
1649
  var ok = await webhooks.endpoints.delete(req.params.id);
@@ -2087,9 +2182,9 @@ function mount(router, deps) {
2087
2182
  interval: body.interval,
2088
2183
  currency: typeof body.currency === "string" ? body.currency.trim().toLowerCase() : body.currency,
2089
2184
  };
2090
- if (body.amount_minor != null && body.amount_minor !== "") input.amount_minor = parseInt(body.amount_minor, 10);
2091
- if (body.interval_count != null && body.interval_count !== "") input.interval_count = parseInt(body.interval_count, 10);
2092
- if (body.trial_days != null && body.trial_days !== "") input.trial_days = parseInt(body.trial_days, 10);
2185
+ if (body.amount_minor != null && body.amount_minor !== "") input.amount_minor = _strictMinorInt(body.amount_minor, "subscriptions", "amount_minor (minor units)");
2186
+ if (body.interval_count != null && body.interval_count !== "") input.interval_count = _strictMinorInt(body.interval_count, "subscriptions", "interval_count");
2187
+ if (body.trial_days != null && body.trial_days !== "") input.trial_days = _strictMinorInt(body.trial_days, "subscriptions", "trial_days");
2093
2188
  if (body.variant_id) input.variant_id = String(body.variant_id).trim();
2094
2189
  try {
2095
2190
  await subscriptions.plans.create(input);
@@ -2147,6 +2242,31 @@ function mount(router, deps) {
2147
2242
  return p;
2148
2243
  }));
2149
2244
 
2245
+ // Browser confirmation interstitial for archive — terminal from the
2246
+ // console (a retired plan is replaced by creating a new one against a
2247
+ // fresh Stripe price id, never reactivated in place), so confirm it.
2248
+ router.post("/admin/subscription-plans/:id/archive/confirm", _pageOrApi(false,
2249
+ R(async function (_req, res) {
2250
+ return _problem(res, 405, "use-canonical-endpoint", "POST /admin/subscription-plans/:id/archive directly for the JSON API");
2251
+ }),
2252
+ async function (req, res) {
2253
+ var id = req.params.id;
2254
+ var p = null;
2255
+ try { p = await subscriptions.plans.get(id); }
2256
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
2257
+ if (!p) return _redirect(res, "/admin/subscription-plans?err=1");
2258
+ _sendHtml(res, 200, renderAdminConfirm({
2259
+ shop_name: deps.shop_name, nav_available: navAvailable, active: "subscriptions",
2260
+ heading: "Archive this plan?",
2261
+ consequence: "Archiving is terminal — this plan cannot be reactivated. To offer it again you create a new plan against a fresh Stripe price id.",
2262
+ detail: "Existing subscriptions on this plan are unaffected; new sign-ups can no longer select it.",
2263
+ action: "/admin/subscription-plans/" + _htmlEscape(id) + "/archive",
2264
+ confirm_label: "Archive plan",
2265
+ cancel_href: "/admin/subscription-plans",
2266
+ }));
2267
+ },
2268
+ ));
2269
+
2150
2270
  // Archive content-negotiates: bearer → JSON; browser form → archive,
2151
2271
  // then PRG. An unknown / malformed id is a no-op notice (?err=1),
2152
2272
  // never a 500.
@@ -2215,7 +2335,11 @@ function mount(router, deps) {
2215
2335
  if (giftcards) {
2216
2336
  // Issue a card. Bearer → JSON (the issued plaintext code is
2217
2337
  // returned ONCE for the operator to deliver); browser form →
2218
- // issue, then PRG to the detail page so the code shows once.
2338
+ // issue, then render the detail page DIRECTLY from this POST 200
2339
+ // with the plaintext code passed in-body. We deliberately do NOT
2340
+ // redirect with the code on the query string — the one-time bearer
2341
+ // code must never land in a URL / Location header / browser
2342
+ // history / access log (mirrors renderAdminWebhookSecret).
2219
2343
  router.post("/admin/gift-cards", _pageOrApi(false,
2220
2344
  W("gift_card.issue", async function (req, res) {
2221
2345
  var issued = await _issueGiftCard(req.body || {});
@@ -2235,7 +2359,11 @@ function mount(router, deps) {
2235
2359
  }));
2236
2360
  }
2237
2361
  b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".gift_card.issue", outcome: "success", metadata: { id: issued.id } });
2238
- _redirect(res, "/admin/gift-cards/" + issued.id + "?issued=" + encodeURIComponent(issued.code));
2362
+ // Stash the one-time code in a sealed cookie and 303-redirect to the
2363
+ // card's detail page (PRG: a refresh can't re-issue). The detail GET
2364
+ // reveals + clears the code; it never travels in the URL.
2365
+ _stashGiftCardReveal(res, issued.id, issued.code);
2366
+ _redirect(res, "/admin/gift-cards/" + encodeURIComponent(issued.id));
2239
2367
  },
2240
2368
  ));
2241
2369
 
@@ -2271,13 +2399,16 @@ function mount(router, deps) {
2271
2399
  }),
2272
2400
  async function (req, res) {
2273
2401
  var card = await giftcards.getById(req.params.id);
2274
- var url = req.url ? new URL(req.url, "http://localhost") : null;
2275
- var issuedCode = url && url.searchParams.get("issued");
2276
2402
  if (!card) {
2277
2403
  return _sendHtml(res, 404, renderAdminGiftCard({
2278
2404
  shop_name: deps.shop_name, nav_available: navAvailable, card: null,
2279
2405
  }));
2280
2406
  }
2407
+ // One-time reveal: if this GET follows a fresh issue (sealed
2408
+ // `gc_reveal` cookie present for this id), show the plaintext code
2409
+ // once and clear the cookie. A subsequent reload reveals nothing —
2410
+ // the code never lands in the URL / history / access log.
2411
+ var issuedCode = _takeGiftCardReveal(req, res, req.params.id);
2281
2412
  var history = [];
2282
2413
  if (giftCardLedger) {
2283
2414
  try { history = (await giftCardLedger.history(req.params.id, { limit: 500 })).rows; }
@@ -2285,7 +2416,7 @@ function mount(router, deps) {
2285
2416
  }
2286
2417
  _sendHtml(res, 200, renderAdminGiftCard({
2287
2418
  shop_name: deps.shop_name, nav_available: navAvailable, card: card,
2288
- ledger: history, issued_code: issuedCode,
2419
+ ledger: history, issued_code: issuedCode || undefined,
2289
2420
  }));
2290
2421
  },
2291
2422
  ));
@@ -2298,15 +2429,11 @@ function mount(router, deps) {
2298
2429
  async function _issueGiftCard(body) {
2299
2430
  var input = { currency: typeof body.currency === "string" ? body.currency.trim().toUpperCase() : body.currency };
2300
2431
  if (body.amount_minor != null && body.amount_minor !== "") {
2301
- var n = typeof body.amount_minor === "number" ? body.amount_minor : parseInt(String(body.amount_minor), 10);
2302
- if (!Number.isInteger(n)) throw new TypeError("giftcards: amount_minor must be an integer (minor units)");
2303
- input.amount_minor = n;
2432
+ input.amount_minor = _strictMinorInt(body.amount_minor, "giftcards", "amount_minor (minor units)");
2304
2433
  }
2305
2434
  if (body.issued_to_email) input.issued_to_email = String(body.issued_to_email).trim();
2306
2435
  if (body.expires_at != null && body.expires_at !== "") {
2307
- var ex = typeof body.expires_at === "number" ? body.expires_at : parseInt(String(body.expires_at), 10);
2308
- if (!Number.isInteger(ex)) throw new TypeError("giftcards: expires_at must be an integer epoch-ms");
2309
- input.expires_at = ex;
2436
+ input.expires_at = _strictMinorInt(body.expires_at, "giftcards", "expires_at (epoch-ms)");
2310
2437
  }
2311
2438
  var issued = await giftcards.issue(input);
2312
2439
  if (giftCardLedger) {
@@ -2470,10 +2597,11 @@ var DASHBOARD_LAYOUT =
2470
2597
  " <meta charset=\"utf-8\">\n" +
2471
2598
  " <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n" +
2472
2599
  " <meta name=\"robots\" content=\"noindex,nofollow\">\n" +
2473
- " <title>Admin dashboard — {{shop_name}}</title>\n" +
2600
+ " <title>{{page_title}} — {{shop_name}}</title>\n" +
2474
2601
  " RAW_ADMIN_CSS\n" +
2475
2602
  "</head>\n" +
2476
2603
  "<body>\n" +
2604
+ " <a class=\"skip-link\" href=\"#admin-main\">Skip to content</a>\n" +
2477
2605
  " <header class=\"admin-header\">\n" +
2478
2606
  " <div class=\"admin-header__inner\">\n" +
2479
2607
  " <h1>{{shop_name}} <span class=\"brand-accent\">/ admin</span></h1>\n" +
@@ -2481,7 +2609,7 @@ var DASHBOARD_LAYOUT =
2481
2609
  " </div>\n" +
2482
2610
  " </header>\n" +
2483
2611
  " {{nav}}\n" +
2484
- " <main>{{body}}</main>\n" +
2612
+ " <main id=\"admin-main\">{{body}}</main>\n" +
2485
2613
  "</body>\n" +
2486
2614
  "</html>\n";
2487
2615
 
@@ -2565,7 +2693,7 @@ function renderDashboard(opts) {
2565
2693
  }).join("");
2566
2694
  otherCurrencies =
2567
2695
  "<section><h2>Other currencies in window</h2><div class=\"panel\">" +
2568
- "<table><thead><tr><th>Currency</th><th class=\"num\">Orders</th><th class=\"num\">Revenue</th></tr></thead><tbody>" + rows + "</tbody></table>" +
2696
+ "<table><thead><tr><th scope=\"col\">Currency</th><th scope=\"col\" class=\"num\">Orders</th><th scope=\"col\" class=\"num\">Revenue</th></tr></thead><tbody>" + rows + "</tbody></table>" +
2569
2697
  "</div></section>";
2570
2698
  }
2571
2699
 
@@ -2599,11 +2727,11 @@ function renderDashboard(opts) {
2599
2727
  "<section><h2>Catalog + activity</h2><div class=\"two-col\">" +
2600
2728
  " <div class=\"panel\">" +
2601
2729
  " <h3 class=\"subhead\">Top SKUs by units sold</h3>" +
2602
- " <table><thead><tr><th>SKU</th><th class=\"num\">Units</th><th class=\"num\">Revenue</th></tr></thead><tbody>" + topRows + "</tbody></table>" +
2730
+ " <table><thead><tr><th scope=\"col\">SKU</th><th scope=\"col\" class=\"num\">Units</th><th scope=\"col\" class=\"num\">Revenue</th></tr></thead><tbody>" + topRows + "</tbody></table>" +
2603
2731
  " </div>" +
2604
2732
  " <div class=\"panel\">" +
2605
2733
  " <h3 class=\"subhead\">Recent orders</h3>" +
2606
- " <table><thead><tr><th>Order</th><th>Status</th><th class=\"num\">Total</th></tr></thead><tbody>" + recentRows + "</tbody></table>" +
2734
+ " <table><thead><tr><th scope=\"col\">Order</th><th scope=\"col\">Status</th><th scope=\"col\" class=\"num\">Total</th></tr></thead><tbody>" + recentRows + "</tbody></table>" +
2607
2735
  " </div>" +
2608
2736
  "</div></section>";
2609
2737
 
@@ -2657,7 +2785,7 @@ function _adminNav(active, available) {
2657
2785
  var links = ADMIN_NAV_ITEMS.filter(function (it) {
2658
2786
  return !it.requires || !available || available[it.requires];
2659
2787
  }).map(function (it) {
2660
- return "<a href=\"" + it.href + "\"" + (it.key === active ? " class=\"active\"" : "") + ">" +
2788
+ return "<a href=\"" + it.href + "\"" + (it.key === active ? " class=\"active\" aria-current=\"page\"" : "") + ">" +
2661
2789
  _htmlEscape(it.label) + "</a>";
2662
2790
  }).join("");
2663
2791
  return "<nav class=\"admin-nav\"><div class=\"admin-nav__inner\">" + links + "</div></nav>";
@@ -2666,6 +2794,7 @@ function _adminNav(active, available) {
2666
2794
  function _renderAdminShell(shopName, subtitle, bodyHtml, active, available) {
2667
2795
  return _renderTemplate(DASHBOARD_LAYOUT, {
2668
2796
  shop_name: shopName || "blamejs.shop",
2797
+ page_title: subtitle || "Admin",
2669
2798
  window_label: subtitle || "",
2670
2799
  nav: "RAW_NAV",
2671
2800
  body: "RAW_BODY",
@@ -2674,6 +2803,35 @@ function _renderAdminShell(shopName, subtitle, bodyHtml, active, available) {
2674
2803
  .replace("RAW_BODY", bodyHtml);
2675
2804
  }
2676
2805
 
2806
+ // Server-rendered confirmation interstitial for irreversible /
2807
+ // money-moving actions. The CSP forbids inline script, so a client
2808
+ // `confirm()` dialog is impossible — instead the action button POSTs
2809
+ // here, this page states the consequence, and its Confirm button POSTs
2810
+ // to the real endpoint (Cancel is a plain link back). `opts.fields` is
2811
+ // an optional map of hidden form inputs forwarded to the real endpoint.
2812
+ function renderAdminConfirm(opts) {
2813
+ opts = opts || {};
2814
+ var fields = opts.fields || {};
2815
+ var hidden = Object.keys(fields).map(function (k) {
2816
+ return "<input type=\"hidden\" name=\"" + _htmlEscape(k) + "\" value=\"" + _htmlEscape(fields[k]) + "\">";
2817
+ }).join("");
2818
+ var body =
2819
+ "<section class=\"mw-42\">" +
2820
+ "<h2>" + _htmlEscape(opts.heading || "Confirm action") + "</h2>" +
2821
+ "<div class=\"banner banner--warn\">" + _htmlEscape(opts.consequence || "This action cannot be undone.") + "</div>" +
2822
+ "<div class=\"panel\">" +
2823
+ "<p>" + _htmlEscape(opts.detail || "Are you sure you want to continue?") + "</p>" +
2824
+ "<div class=\"actions-row\">" +
2825
+ "<form method=\"post\" action=\"" + _htmlEscape(opts.action) + "\" class=\"form-inline\">" + hidden +
2826
+ "<button class=\"btn btn--danger\" type=\"submit\">" + _htmlEscape(opts.confirm_label || "Confirm") + "</button>" +
2827
+ "</form>" +
2828
+ "<a class=\"btn btn--ghost\" href=\"" + _htmlEscape(opts.cancel_href) + "\">Cancel</a>" +
2829
+ "</div>" +
2830
+ "</div>" +
2831
+ "</section>";
2832
+ return _renderAdminShell(opts.shop_name, opts.heading || "Confirm", body, opts.active, opts.nav_available);
2833
+ }
2834
+
2677
2835
  function renderAdminLogin(opts) {
2678
2836
  opts = opts || {};
2679
2837
  var err = opts.error
@@ -2793,7 +2951,7 @@ function renderAdminIntegrations(opts) {
2793
2951
  "<h2>Integrations</h2>" +
2794
2952
  "<p class=\"meta\">Every integration is off until you supply its credentials — set them as deployment secrets, then redeploy. Nothing is enabled without your keys.</p>" +
2795
2953
  "<div class=\"panel\"><table>" +
2796
- "<thead><tr><th>Integration</th><th>Status</th><th>To enable</th></tr></thead>" +
2954
+ "<thead><tr><th scope=\"col\">Integration</th><th scope=\"col\">Status</th><th scope=\"col\">To enable</th></tr></thead>" +
2797
2955
  "<tbody>" + rows + "</tbody>" +
2798
2956
  "</table></div>" +
2799
2957
  "<p class=\"meta mt-125\">Sign in with Apple and PayPal are planned. “Sign in with Shop” / Shop Pay isn't available to a self-hosted store. See the README “Optional integrations” section for full setup steps.</p>" +
@@ -2818,7 +2976,7 @@ function renderAdminProducts(opts) {
2818
2976
  "<td>" + action + "</td></tr>";
2819
2977
  }).join("");
2820
2978
  var table = products.length
2821
- ? "<div class=\"panel\"><table><thead><tr><th>Title</th><th>Slug</th><th>Status</th><th>Action</th></tr></thead><tbody>" + rows + "</tbody></table></div>"
2979
+ ? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Title</th><th scope=\"col\">Slug</th><th scope=\"col\">Status</th><th scope=\"col\">Action</th></tr></thead><tbody>" + rows + "</tbody></table></div>"
2822
2980
  : "<p class=\"empty\">No products yet — create your first one below.</p>";
2823
2981
  var body =
2824
2982
  "<section><h2>Products</h2>" + created + notice + table +
@@ -2874,7 +3032,7 @@ function renderAdminOrders(opts) {
2874
3032
  }).join("");
2875
3033
 
2876
3034
  var table = orders.length
2877
- ? "<div class=\"panel\"><table><thead><tr><th>Order</th><th>Status</th><th class=\"num\">Items</th><th class=\"num\">Total</th><th>Placed</th></tr></thead><tbody>" + rows + "</tbody></table></div>"
3035
+ ? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Order</th><th scope=\"col\">Status</th><th scope=\"col\" class=\"num\">Items</th><th scope=\"col\" class=\"num\">Total</th><th scope=\"col\">Placed</th></tr></thead><tbody>" + rows + "</tbody></table></div>"
2878
3036
  : "<p class=\"empty\">No orders" + (active ? " with status “" + _htmlEscape(active) + "”" : " yet") + ".</p>";
2879
3037
 
2880
3038
  var body = "<section><h2>Orders</h2>" + notice + chips + table + "</section>";
@@ -2897,7 +3055,7 @@ function renderAdminOrder(opts) {
2897
3055
  "</tr>";
2898
3056
  }).join("");
2899
3057
  var linesTable = (o.lines && o.lines.length)
2900
- ? "<table><thead><tr><th>SKU</th><th class=\"num\">Qty</th><th class=\"num\">Unit</th><th class=\"num\">Line</th></tr></thead><tbody>" + lineRows + "</tbody></table>"
3058
+ ? "<table><thead><tr><th scope=\"col\">SKU</th><th scope=\"col\" class=\"num\">Qty</th><th scope=\"col\" class=\"num\">Unit</th><th scope=\"col\" class=\"num\">Line</th></tr></thead><tbody>" + lineRows + "</tbody></table>"
2901
3059
  : "<p class=\"empty\">No line items recorded.</p>";
2902
3060
 
2903
3061
  function _total(label, minor, strong) {
@@ -2927,7 +3085,9 @@ function renderAdminOrder(opts) {
2927
3085
  var actionForms = transitions.map(function (t) {
2928
3086
  if (t.on === "refund") {
2929
3087
  if (!opts.can_refund) return ""; // no payment intent — nothing to refund here
2930
- return "<form method=\"post\" action=\"/admin/orders/" + _htmlEscape(o.id) + "/refund\" class=\"form-inline\">" +
3088
+ // POSTs to the confirm interstitial (states the amount + warns
3089
+ // it's irreversible), whose Confirm button POSTs the real refund.
3090
+ return "<form method=\"post\" action=\"/admin/orders/" + _htmlEscape(o.id) + "/refund/confirm\" class=\"form-inline\">" +
2931
3091
  "<button class=\"btn btn--danger\" type=\"submit\">" + _htmlEscape(t.label) + "</button>" +
2932
3092
  "</form>";
2933
3093
  }
@@ -3000,7 +3160,7 @@ function renderAdminCustomers(opts) {
3000
3160
  }).join("");
3001
3161
 
3002
3162
  var table = customers.length
3003
- ? "<div class=\"panel\"><table><thead><tr><th>Name</th><th>ID</th><th>Joined</th><th>Sign-in method</th><th class=\"num\">Orders</th></tr></thead><tbody>" + rows + "</tbody></table></div>"
3163
+ ? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Name</th><th scope=\"col\">ID</th><th scope=\"col\">Joined</th><th scope=\"col\">Sign-in method</th><th scope=\"col\" class=\"num\">Orders</th></tr></thead><tbody>" + rows + "</tbody></table></div>"
3004
3164
  : "<p class=\"empty\">No customers yet.</p>";
3005
3165
 
3006
3166
  // Cursor pager — a Next link when the page filled and more rows remain.
@@ -3056,7 +3216,7 @@ function renderAdminReturns(opts) {
3056
3216
  }).join("");
3057
3217
 
3058
3218
  var table = rmas.length
3059
- ? "<div class=\"panel\"><table><thead><tr><th>RMA</th><th>Order</th><th>Reason</th><th>Status</th><th class=\"num\">Items</th><th class=\"num\">Refund</th><th>Requested</th></tr></thead><tbody>" + rows + "</tbody></table></div>"
3219
+ ? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">RMA</th><th scope=\"col\">Order</th><th scope=\"col\">Reason</th><th scope=\"col\">Status</th><th scope=\"col\" class=\"num\">Items</th><th scope=\"col\" class=\"num\">Refund</th><th scope=\"col\">Requested</th></tr></thead><tbody>" + rows + "</tbody></table></div>"
3060
3220
  : "<p class=\"empty\">No “" + _htmlEscape(active) + "” returns.</p>";
3061
3221
 
3062
3222
  var body = "<section><h2>Returns</h2>" + notice + chips + table + "</section>";
@@ -3076,7 +3236,7 @@ function renderAdminReturn(opts) {
3076
3236
  "<td>" + _htmlEscape(l.reason || "—") + "</td></tr>";
3077
3237
  }).join("");
3078
3238
  var linesTable = (r.lines && r.lines.length)
3079
- ? "<table><thead><tr><th>SKU</th><th class=\"num\">Qty</th><th>Reason</th></tr></thead><tbody>" + lineRows + "</tbody></table>"
3239
+ ? "<table><thead><tr><th scope=\"col\">SKU</th><th scope=\"col\" class=\"num\">Qty</th><th scope=\"col\">Reason</th></tr></thead><tbody>" + lineRows + "</tbody></table>"
3080
3240
  : "<p class=\"empty\">No line items recorded.</p>";
3081
3241
 
3082
3242
  function _field(label, value) {
@@ -3313,7 +3473,9 @@ function renderAdminQuestion(opts) {
3313
3473
 
3314
3474
  var answerForm = "<div class=\"panel\"><h3>Post the seller answer</h3>" +
3315
3475
  "<form method=\"post\" action=\"/admin/questions/" + _htmlEscape(q.id) + "/answer\">" +
3316
- "<textarea name=\"body\" maxlength=\"4000\" rows=\"4\" required></textarea>" +
3476
+ "<label class=\"form-field\"><span>Your answer</span>" +
3477
+ "<textarea name=\"body\" maxlength=\"4000\" rows=\"4\" required></textarea>" +
3478
+ "</label>" +
3317
3479
  "<div class=\"order-actions\"><button class=\"btn\" type=\"submit\">Submit answer</button></div>" +
3318
3480
  "</form>" +
3319
3481
  "<p class=\"meta\">The answer lands pending — approve it above to publish it on the product page.</p>" +
@@ -3346,7 +3508,7 @@ function renderAdminInventory(opts) {
3346
3508
  var isLow = th != null && available <= th;
3347
3509
  var thVal = th == null ? "" : String(th);
3348
3510
  return "<tr" + (isLow ? " class=\"row--low\"" : "") + ">" +
3349
- "<td><code class=\"order-id\">" + _htmlEscape(r.sku) + "</code>" + (isLow ? " <span class=\"status-pill pending\">low</span>" : "") + "</td>" +
3511
+ "<th scope=\"row\"><code class=\"order-id\">" + _htmlEscape(r.sku) + "</code>" + (isLow ? " <span class=\"status-pill pending\">low</span>" : "") + "</th>" +
3350
3512
  "<td class=\"num\">" + _htmlEscape(String(r.stock_on_hand)) + "</td>" +
3351
3513
  "<td class=\"num\">" + _htmlEscape(String(r.stock_held)) + "</td>" +
3352
3514
  "<td class=\"num\"><strong>" + _htmlEscape(String(available)) + "</strong></td>" +
@@ -3360,7 +3522,7 @@ function renderAdminInventory(opts) {
3360
3522
  }).join("");
3361
3523
 
3362
3524
  var table = rows.length
3363
- ? "<div class=\"panel\"><table><thead><tr><th>SKU</th><th class=\"num\">On hand</th><th class=\"num\">Held</th><th class=\"num\">Available</th><th>Restock / threshold</th></tr></thead><tbody>" + body + "</tbody></table></div>"
3525
+ ? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">SKU</th><th scope=\"col\" class=\"num\">On hand</th><th scope=\"col\" class=\"num\">Held</th><th scope=\"col\" class=\"num\">Available</th><th scope=\"col\">Restock / threshold</th></tr></thead><tbody>" + body + "</tbody></table></div>"
3364
3526
  : "<p class=\"empty\">No inventory rows" + (opts.low ? " below threshold" : " yet") + ".</p>";
3365
3527
 
3366
3528
  var createForm =
@@ -3402,7 +3564,7 @@ function renderAdminSubscriptionPlans(opts) {
3402
3564
  var price = pricing.format(p.amount_minor, String(p.currency || "").toUpperCase()) + " / " + every;
3403
3565
  var isActive = p.active === 1 || p.active === true;
3404
3566
  var archiveCell = isActive
3405
- ? "<form method=\"post\" action=\"/admin/subscription-plans/" + _htmlEscape(p.id) + "/archive\" class=\"form-inline\">" +
3567
+ ? "<form method=\"post\" action=\"/admin/subscription-plans/" + _htmlEscape(p.id) + "/archive/confirm\" class=\"form-inline\">" +
3406
3568
  "<button class=\"btn btn--ghost\" type=\"submit\">Archive</button></form>"
3407
3569
  : "<span class=\"meta\">—</span>";
3408
3570
  return "<tr>" +
@@ -3416,7 +3578,7 @@ function renderAdminSubscriptionPlans(opts) {
3416
3578
  }).join("");
3417
3579
 
3418
3580
  var table = rows.length
3419
- ? "<div class=\"panel\"><table><thead><tr><th>Price / interval</th><th>Stripe price</th><th>Variant</th><th>Status</th><th>Actions</th></tr></thead><tbody>" + bodyRows + "</tbody></table></div>"
3581
+ ? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Price / interval</th><th scope=\"col\">Stripe price</th><th scope=\"col\">Variant</th><th scope=\"col\">Status</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + bodyRows + "</tbody></table></div>"
3420
3582
  : "<p class=\"empty\">No subscription plans" + (af === "0" ? " archived" : af === "1" ? " active" : " yet") + ".</p>";
3421
3583
 
3422
3584
  var intervalOpts = ["month", "year", "week", "day"].map(function (iv) {
@@ -3474,7 +3636,7 @@ function renderAdminGiftCards(opts) {
3474
3636
  var issued = pricing.format(gc.issued_minor, cur);
3475
3637
  var remaining = pricing.format(gc.balance_minor, cur);
3476
3638
  return "<tr>" +
3477
- "<td><code class=\"order-id\">••••" + _htmlEscape(gc.code_hint) + "</code></td>" +
3639
+ "<th scope=\"row\"><code class=\"order-id\">••••" + _htmlEscape(gc.code_hint) + "</code></th>" +
3478
3640
  "<td class=\"num\">" + _htmlEscape(issued) + "</td>" +
3479
3641
  "<td class=\"num\"><strong>" + _htmlEscape(remaining) + "</strong></td>" +
3480
3642
  "<td>" + _pill(gc.status) + "</td>" +
@@ -3484,7 +3646,7 @@ function renderAdminGiftCards(opts) {
3484
3646
  }).join("");
3485
3647
 
3486
3648
  var table = rows.length
3487
- ? "<div class=\"panel\"><table><thead><tr><th>Code</th><th class=\"num\">Issued</th><th class=\"num\">Remaining</th><th>Status</th><th>Issued on</th><th>Actions</th></tr></thead><tbody>" + bodyRows + "</tbody></table></div>"
3649
+ ? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Code</th><th scope=\"col\" class=\"num\">Issued</th><th scope=\"col\" class=\"num\">Remaining</th><th scope=\"col\">Status</th><th scope=\"col\">Issued on</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + bodyRows + "</tbody></table></div>"
3488
3650
  : "<p class=\"empty\">No gift cards" + (sf ? " " + _htmlEscape(sf) : " yet") + ".</p>";
3489
3651
 
3490
3652
  // The issue form composes the giftcards primitive's issue() — the
@@ -3549,7 +3711,7 @@ function renderAdminGiftCard(opts) {
3549
3711
  "</tr>";
3550
3712
  }).join("");
3551
3713
  var ledgerTable = ledger.length
3552
- ? "<div class=\"panel\"><table><thead><tr><th>Type</th><th class=\"num\">Amount</th><th class=\"num\">Balance after</th><th>Detail</th><th>When</th></tr></thead><tbody>" + ledgerRows + "</tbody></table></div>"
3714
+ ? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Type</th><th scope=\"col\" class=\"num\">Amount</th><th scope=\"col\" class=\"num\">Balance after</th><th scope=\"col\">Detail</th><th scope=\"col\">When</th></tr></thead><tbody>" + ledgerRows + "</tbody></table></div>"
3553
3715
  : "<p class=\"empty\">No ledger transactions recorded for this card yet.</p>";
3554
3716
 
3555
3717
  var body = "<section><h2>Gift card</h2>" + issuedBanner + summary +
@@ -3579,12 +3741,12 @@ function renderAdminWebhooks(opts) {
3579
3741
  "<td>" +
3580
3742
  "<a class=\"btn btn--ghost\" href=\"/admin/webhooks/" + _htmlEscape(e.id) + "/deliveries\">Deliveries</a> " +
3581
3743
  "<form method=\"post\" action=\"/admin/webhooks/" + _htmlEscape(e.id) + "/toggle\" class=\"form-inline\"><button class=\"btn btn--ghost\" type=\"submit\">" + (isActive ? "Disable" : "Enable") + "</button></form> " +
3582
- "<form method=\"post\" action=\"/admin/webhooks/" + _htmlEscape(e.id) + "/delete\" class=\"form-inline\"><button class=\"btn btn--danger\" type=\"submit\">Delete</button></form>" +
3744
+ "<form method=\"post\" action=\"/admin/webhooks/" + _htmlEscape(e.id) + "/delete/confirm\" class=\"form-inline\"><button class=\"btn btn--danger\" type=\"submit\">Delete</button></form>" +
3583
3745
  "</td></tr>";
3584
3746
  }).join("");
3585
3747
 
3586
3748
  var table = rows.length
3587
- ? "<div class=\"panel\"><table><thead><tr><th>URL</th><th>Events</th><th>Status</th><th class=\"num\">Rate</th><th>Actions</th></tr></thead><tbody>" + bodyRows + "</tbody></table></div>"
3749
+ ? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">URL</th><th scope=\"col\">Events</th><th scope=\"col\">Status</th><th scope=\"col\" class=\"num\">Rate</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + bodyRows + "</tbody></table></div>"
3588
3750
  : "<p class=\"empty\">No webhook endpoints yet.</p>";
3589
3751
 
3590
3752
  var eventChecks = known.map(function (ev) {
@@ -3661,7 +3823,7 @@ function renderAdminWebhookDeliveries(opts) {
3661
3823
  }).join("");
3662
3824
 
3663
3825
  var table = rows.length
3664
- ? "<div class=\"panel\"><table><thead><tr><th>Event</th><th>Status</th><th class=\"num\">Code</th><th class=\"num\">Attempts</th><th>Last error</th><th>Created</th><th></th></tr></thead><tbody>" + bodyRows + "</tbody></table></div>"
3826
+ ? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Event</th><th scope=\"col\">Status</th><th scope=\"col\" class=\"num\">Code</th><th scope=\"col\" class=\"num\">Attempts</th><th scope=\"col\">Last error</th><th scope=\"col\">Created</th><th scope=\"col\"></th></tr></thead><tbody>" + bodyRows + "</tbody></table></div>"
3665
3827
  : "<p class=\"empty\">No deliveries recorded for this endpoint yet.</p>";
3666
3828
 
3667
3829
  var head = "<p class=\"meta\">Endpoint <code class=\"order-id\">" + _htmlEscape(e.url) + "</code></p>";
@@ -3705,7 +3867,7 @@ function renderAdminCollections(opts) {
3705
3867
  }).join("");
3706
3868
 
3707
3869
  var table = rows.length
3708
- ? "<div class=\"panel\"><table><thead><tr><th>Title</th><th>Slug</th><th>Type</th><th>Status</th><th class=\"num\">Products</th><th>Actions</th></tr></thead><tbody>" + bodyRows + "</tbody></table></div>"
3870
+ ? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Title</th><th scope=\"col\">Slug</th><th scope=\"col\">Type</th><th scope=\"col\">Status</th><th scope=\"col\" class=\"num\">Products</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + bodyRows + "</tbody></table></div>"
3709
3871
  : "<p class=\"empty\">No collections" + (af === "0" ? " archived" : af === "1" ? " active" : " yet") + ".</p>";
3710
3872
 
3711
3873
  // The create form toggles between a manual and a smart shape. Manual
@@ -3821,7 +3983,7 @@ function renderAdminCollection(opts) {
3821
3983
  "</tr>";
3822
3984
  }).join("");
3823
3985
  var memberTable = members.length
3824
- ? "<div class=\"panel\"><table><thead><tr><th class=\"num\">#</th><th>Product id</th><th></th></tr></thead><tbody>" + memberRows + "</tbody></table></div>"
3986
+ ? "<div class=\"panel\"><table><thead><tr><th scope=\"col\" class=\"num\">#</th><th scope=\"col\">Product id</th><th scope=\"col\"></th></tr></thead><tbody>" + memberRows + "</tbody></table></div>"
3825
3987
  : "<p class=\"empty\">No members yet — add a product below.</p>";
3826
3988
 
3827
3989
  // Reorder: a single field of the current ids, comma-joined, that the
@@ -3861,7 +4023,7 @@ function renderAdminCollection(opts) {
3861
4023
  "</tr>";
3862
4024
  }).join("");
3863
4025
  var previewTable = preview.length
3864
- ? "<div class=\"panel\"><table><thead><tr><th>Title</th><th>Slug</th><th>Status</th></tr></thead><tbody>" + previewCards + "</tbody></table></div>"
4026
+ ? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Title</th><th scope=\"col\">Slug</th><th scope=\"col\">Status</th></tr></thead><tbody>" + previewCards + "</tbody></table></div>"
3865
4027
  : "<p class=\"empty\">No products match these rules yet.</p>";
3866
4028
  detailBody = "<section class=\"mt\"><h3 class=\"fs-105\">Matched products (live preview)</h3>" +
3867
4029
  "<p class=\"meta\">The first " + _htmlEscape(String(preview.length)) + " products the rules match right now.</p>" +
@@ -3901,4 +4063,5 @@ module.exports = {
3901
4063
  renderAdminCollection: renderAdminCollection,
3902
4064
  renderAdminGiftCards: renderAdminGiftCards,
3903
4065
  renderAdminGiftCard: renderAdminGiftCard,
4066
+ renderAdminConfirm: renderAdminConfirm,
3904
4067
  };
@@ -1,13 +1,13 @@
1
1
  {
2
- "version": "0.1.37",
2
+ "version": "0.2.0",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
- "integrity": "sha384-rvjL1QySBgNJGEUDusbbMCkL3fsjmzcwUvTp9pljo/0Nn/lErxq2AbY8EYvDF9Ch",
6
- "fingerprinted": "css/admin.4dc78dfc45d0736d.css"
5
+ "integrity": "sha384-oUi6NkXA0ULSy8+8+LG0FV6jsgr7Y11Xf3VdnwUESnvUPaN7anYEC+QOAUXwgsap",
6
+ "fingerprinted": "css/admin.7b692a8965624a0c.css"
7
7
  },
8
8
  "css/main.css": {
9
- "integrity": "sha384-oCNOJjwj7/EHXCrMnkhOTDY/5iFDeIvpWFmMyeX1g73CE4usg4i4q/dGYx4Ko258",
10
- "fingerprinted": "css/main.0cbb632d5418155e.css"
9
+ "integrity": "sha384-4RH2PIC1FVxNFfphco1poLi1gMXa8MkvmOK8tXLSSwUKfhbGi0VYXlWtWBXu4Msd",
10
+ "fingerprinted": "css/main.f61829cdefb48dda.css"
11
11
  },
12
12
  "js/consent.js": {
13
13
  "integrity": "sha384-KKMQ0og8HPOykRRPpUyxX7dMhTvKySfVtpGX/jGWzZwNaN/c4OykvRvXpqBHcQST",