@blamejs/blamejs-shop 0.4.3 → 0.4.5

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 (28) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +8 -7
  3. package/lib/admin.js +376 -0
  4. package/lib/asset-manifest.json +5 -5
  5. package/lib/storefront.js +296 -9
  6. package/lib/vendor/MANIFEST.json +23 -23
  7. package/lib/vendor/blamejs/.pinact.yaml +1 -1
  8. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  9. package/lib/vendor/blamejs/SECURITY.md +1 -1
  10. package/lib/vendor/blamejs/api-snapshot.json +15 -2
  11. package/lib/vendor/blamejs/index.js +5 -1
  12. package/lib/vendor/blamejs/lib/auth/jar.js +190 -28
  13. package/lib/vendor/blamejs/lib/auth/jwt-external.js +213 -0
  14. package/lib/vendor/blamejs/lib/auth/oauth.js +115 -101
  15. package/lib/vendor/blamejs/lib/http-client.js +3 -4
  16. package/lib/vendor/blamejs/lib/lro.js +3 -4
  17. package/lib/vendor/blamejs/lib/middleware/deny-response.js +2 -10
  18. package/lib/vendor/blamejs/lib/middleware/health.js +1 -4
  19. package/lib/vendor/blamejs/lib/middleware/trace-log-correlation.js +3 -6
  20. package/lib/vendor/blamejs/lib/validate-opts.js +34 -0
  21. package/lib/vendor/blamejs/package.json +1 -1
  22. package/lib/vendor/blamejs/release-notes/v0.14.22.json +91 -0
  23. package/lib/vendor/blamejs/test/layer-0-primitives/auth-jar.test.js +226 -6
  24. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +122 -14
  25. package/lib/vendor/blamejs/test/layer-0-primitives/jwt-external.test.js +104 -2
  26. package/lib/vendor/blamejs/test/layer-0-primitives/oauth-callback.test.js +127 -0
  27. package/package.json +1 -1
  28. package/lib/vendor/blamejs/memory/specs/node-26-map-getorinsert-migration.md +0 -165
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.4.x
10
10
 
11
+ - v0.4.5 (2026-06-05) — **One sign-in screen: passkey first, email sign-in link as the built-in fallback.** The sign-in page previously offered the passkey ceremony with the email magic-link alternative a page away behind a link. Both now live on one screen: passkey stays the primary action, and an "Email me a sign-in link" form sits inline beneath it — a plain server-rendered form that works with JavaScript disabled. The page meets failure gracefully: a browser without WebAuthn support disables the passkey button and points to the email form, and a failed or cancelled passkey ceremony scrolls the shopper to the fallback with their typed email carried over, one tap from a sign-in link instead of a dead end. Responses are identical whether or not an account exists for the address, the trigger is rate-limited, and a visitor who is already signed in is redirected to their account instead of seeing a login form. **Changed:** *Sign-in page unifies passkey and email-link paths* — Passkey remains the primary action; the email sign-in link form renders inline on the same screen when transactional email is configured, works without JavaScript, and inherits the existing magic-link token semantics unchanged (single-use, expiring). Browsers without WebAuthn support are steered to the email form up front, and any passkey ceremony failure lands the shopper on the fallback with their email pre-filled. The email form's responses do not reveal whether an address has an account, the trigger endpoint is tightly rate-limited, and signed-in visitors are redirected away from the login form.
12
+
13
+ - v0.4.4 (2026-06-05) — **Placement-targeted promo banners with scheduling, audiences, and click tracking.** Operators can now run placement-specific marketing banners alongside the existing sitewide announcement bar. A banner targets one of six placements — the top strip, the homepage hero, the product-page side, the cart side, the empty-search state, or the footer — with a schedule window, an audience (everyone, signed-in, or guests), one of four visual themes, a priority for choosing among overlapping banners, and a call-to-action whose clicks and impressions are counted. Banners are authored, edited, archived, and restored at /admin/promo-banners, render identically on edge-cached and container-rendered pages, and the click counter works on both. Separately, the edge's Permissions-Policy header now denies the same full directive set as the container's, closing a drift where nine newer denials were missing from edge-served pages, and a parity test keeps the two substrates locked together from here on. The vendored blamejs framework is refreshed from v0.14.21 to v0.14.22. **Added:** *Promo banners* — Define a banner with a slug, placement, headline, optional body, call-to-action, audience, schedule window, theme, and priority; the highest-priority active banner per placement renders. Operator-authored text is escaped at render on both substrates. Clicks route through a counting redirect that works whether the page came from the edge cache or the container; impressions count on container renders. The admin screen covers the full lifecycle including archive and restore, and the same operations are available as JSON under the admin bearer token. The sitewide announcement bar is unchanged and remains the simpler always-on notice strip — if you define a top-strip promo banner while an announcement is active, both render, so pick one for the top of the page. **Fixed:** *Edge pages deny the same Permissions-Policy directives as container pages* — The edge-served pages' Permissions-Policy was missing nine newer directives the container already denied (among them shared-storage, run-ad-auction, join-ad-interest-group, and smartcard). Both substrates now send the identical deny-all list, and a test fails the build if a framework update ever grows one list without the other.
14
+
11
15
  - v0.4.3 (2026-06-05) — **Passkey sign-in works again: WebAuthn is permitted on the pages that host it.** The framework's deny-all Permissions-Policy disabled the browser's WebAuthn API everywhere — including the sign-in page's own top-level document — so attempting a passkey sign-in failed with the browser reporting that publickey-credentials-get is not enabled. The policy now permits exactly the WebAuthn capability each ceremony page needs: credential assertion on the sign-in page, credential creation on the registration and passkey-management pages, both scoped to the page's own origin. Every other page keeps the strict deny-all policy, and every other feature (camera, microphone, geolocation, payment outside the payment page) remains denied on the ceremony pages too. **Fixed:** *Passkey ceremonies are no longer blocked by Permissions-Policy* — Sign-in carries publickey-credentials-get=(self); registration and passkey management carry publickey-credentials-create=(self). The allowance is scoped per route following the same pattern the payment page uses, grants apply only to the page's own origin (no cross-origin delegation), and unrecognized feature requests relax nothing. Tests assert the exact header tokens per route and that unrelated pages still deny both WebAuthn features.
12
16
 
13
17
  - v0.4.2 (2026-06-05) — **Abandoned checkouts release their stock holds, and five more inventory and admin hardening fixes.** The inventory enforcement introduced in 0.4.0 placed a stock hold at checkout but had no path to free it when a buyer abandoned without paying or cancelling — each abandoned checkout permanently subtracted from sellable stock until an operator intervened. A scheduled reaper now cancels pending orders older than a configurable age (default two hours), cancelling the payment intent first so a late payment can never complete against a reaped order, and releasing the held stock through the existing cancellation path. Around it, five more fixes harden the same surface: settlement failures during payment confirmation no longer strand holds silently (each item settles independently and failures land in the operator error log with the exact item and quantity), a rollback path no longer releases holds belonging to an order that was successfully created, pre-order campaigns whose launch date has passed now enforce real stock limits instead of remaining exempt, the admin activity timeline rejects protocol-relative link targets, and activity reads are bounded instead of scanning a customer's full history per page view. **Fixed:** *Stock holds from abandoned checkouts are reclaimed* — A pending order that never completes payment now has its stock hold released automatically. The scheduled reaper cancels pending orders older than CHECKOUT_PENDING_TTL_MINUTES (default 120, minimum 5; invalid values refuse to boot). For card payments the payment intent is cancelled at the processor before the order is touched — if the processor reports the payment already succeeded, the order is left alone for the webhook to settle. Each sweep reports counts of reaped, skipped, and errored orders. · *Payment settlement is crash-safe per item* — When an order is marked paid, each line's stock decrement now settles independently: one item's database failure no longer blocks the others, no longer fails the payment webhook, and is captured to the operator error log naming the item, quantity, and order so the operator can reconcile stock from the existing adjustment screen. · *Checkout rollback no longer releases holds it does not own* — If checkout fails after the order record was created, the error path previously released all of the attempt's stock holds — which could free units belonging to the order itself or, on a shared item, a concurrent shopper's reservation. Holds are now released on failure only when no order was created; once an order exists it owns its holds, and cancellation or the reaper frees them. The same correction applies to the PayPal order-creation path. · *Pre-order campaigns enforce stock after their launch date* — An active pre-order campaign exempts its product from stock holds by design — pre-orders sell beyond the shelf. That exemption now ends when the campaign's launch date passes: a launched product sells from real inventory even if the campaign has not yet been moved out of its pre-order state in the console. · *Admin activity links and reads hardened* — The customer activity timeline's internal-link guard now rejects protocol-relative targets, and each activity source is read with a bound matching the requested page instead of scanning the customer's entire history. Pagination, ordering, and the summary counts are unchanged.
package/README.md CHANGED
@@ -67,6 +67,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
67
67
  | **`lib/tax.js`** | Operator-table adapter. Country / state / postal_prefix → rate_bps. Most-specific-first match, banker's rounding. Pluggable adapter shape for future Stripe Tax / TaxJar / Avalara. |
68
68
  | **`lib/shipping.js`** | Operator-table adapter. Services with zones (flat or per-gram + base + min/max), free-over-threshold, `digital_only` flag. |
69
69
  | **`lib/delivery-estimate.js`** | "Get it by <date>" promises. Operators define carrier transit times, warehouse cutoffs, holidays, and postal-prefix zones at `/admin/delivery-estimates`; the product and cart pages then show a signed-in customer a delivery date computed against their saved shipping address and the configured origin (`SHOP_ESTIMATE_ORIGIN` or the `shop.estimate_origin` config row). Anonymous, edge-cached pages deliberately render no date — estimates are destination-specific and never bake into the shared cache. Unconfigured stores render nothing, never an error. |
70
+ | **`lib/promo-banners.js`** | Placement-targeted marketing banners (top strip, homepage hero, PDP-side, cart-side, empty-search, footer) with schedule windows, audience targeting, themes, priorities, and click/impression counts. Authored at `/admin/promo-banners`; rendered on both substrates with edge-cache-safe resolution. |
70
71
  | **`lib/payment.js`** | Payment adapters — **Stripe** (verify webhook HMAC-SHA256 via upstream `b.webhook.verify`, create / retrieve / confirm / cancel PaymentIntent, refund, register / list payment-method domains for Apple/Google Pay) and **PayPal** (`adapter: "paypal"` — OAuth2 client-credentials token, create / capture / get / refund Orders v2, webhook verify via PayPal's verify-webhook-signature API). No `stripe` / `paypal` npm dep — outbound through `b.httpClient` (SSRF-gated, retried, circuit-broken). |
71
72
  | **`lib/order.js`** | FSM-driven post-checkout record via upstream `b.fsm`. States: pending → paid → fulfilling → shipped → delivered (+ refunded / cancelled). Every transition appends to `order_transitions`. |
72
73
  | **`lib/checkout.js`** | Orchestrator. `quote()` returns priced quote; `confirm()` validates the ship-to address (real ISO 3166-1 country; US/CA state + ZIP/postal formats; lenient elsewhere) and the customer email shape, then creates a Stripe PaymentIntent + persists order pending; `handleStripeEvent()` verifies webhook + fires the FSM transition. PayPal path: `createPaypalOrder()` opens a PayPal order + persists pending, `capturePaypalOrder()` captures → paid, `handlePaypalEvent()` is the webhook backstop. All idempotent on re-delivery. |
@@ -235,13 +236,13 @@ variables. A signed-in operator can see the live on/off status of each at
235
236
  *Re-opens* as a real build (a `requires_age_check` product attribute + a
236
237
  server-enforced edge + container interstitial) if an age-restricted category
237
238
  enters the catalog.
238
- - **Placement-specific promo banners** — the sitewide promo/notice strip is the
239
- **announcement bar** (managed at `/admin/announcements`); it is the single
240
- source for the top-of-page strip. Additional marketing *placements*
241
- (homepage hero, PDP-side, cart-side, empty-search, footer) are a future
242
- additive surface. *Re-opens* when you want placement-specific marketing beyond
243
- the sitewide strip; the top strip stays the announcement bar's (running two
244
- competing sitewide strips is avoided by design).
239
+ - **Placement-specific promo banners** — managed at `/admin/promo-banners`:
240
+ scheduled, audience-targeted banners per placement (top strip, homepage
241
+ hero, PDP-side, cart-side, empty-search, footer) with themes, priorities,
242
+ and click/impression counts. The sitewide **announcement bar**
243
+ (`/admin/announcements`) remains the simpler always-on notice strip; both
244
+ can target the top of the page, so pick one for the top strip defining a
245
+ `top_strip` promo banner while an announcement is active stacks the two.
245
246
 
246
247
  ## Vendoring blamejs
247
248
 
package/lib/admin.js CHANGED
@@ -6200,6 +6200,156 @@ function mount(router, deps) {
6200
6200
  ));
6201
6201
  }
6202
6202
 
6203
+ // ---- promo banners --------------------------------------------------
6204
+ // Operator-authored marketing banners at six fixed storefront placements.
6205
+ // Content-negotiated like the other console screens: bearer → the JSON
6206
+ // contract; signed-in browser → the HTML table + create form. The console
6207
+ // exposes the all / guest / logged_in audiences; the primitive's segment
6208
+ // audience needs an isMember handle that isn't wired (see server.js), so it
6209
+ // isn't offered here. Lifecycle: define → list / detail → update → archive /
6210
+ // unarchive (the primitive exposes unarchive, so the console offers a
6211
+ // restore action alongside archive — matching the full lib surface).
6212
+ if (deps.promoBanners) {
6213
+ var promos = deps.promoBanners;
6214
+
6215
+ router.get("/admin/promo-banners", _pageOrApi(true,
6216
+ R(async function (req, res) {
6217
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
6218
+ var activeS = url && url.searchParams.get("active");
6219
+ var rows = await promos.listAll(activeS === "1" ? { active_only: true } : {});
6220
+ _json(res, 200, { rows: rows });
6221
+ }),
6222
+ async function (req, res) {
6223
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
6224
+ var activeS = url && url.searchParams.get("active");
6225
+ var rows = await promos.listAll(activeS === "1" ? { active_only: true } : {});
6226
+ _sendHtml(res, 200, renderAdminPromoBanners({
6227
+ shop_name: deps.shop_name, nav_available: navAvailable, banners: rows,
6228
+ active_filter: activeS,
6229
+ created: url && url.searchParams.get("created"),
6230
+ archived: url && url.searchParams.get("archived"),
6231
+ restored: url && url.searchParams.get("restored"),
6232
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the banner." : null,
6233
+ }));
6234
+ },
6235
+ ));
6236
+
6237
+ router.post("/admin/promo-banners", _pageOrApi(false,
6238
+ W("promo_banner.define", async function (req, res) {
6239
+ var row = await promos.defineBanner(req.body || {});
6240
+ _json(res, 201, row);
6241
+ return { id: row.slug };
6242
+ }),
6243
+ async function (req, res) {
6244
+ try {
6245
+ await promos.defineBanner(_promoBannerFromForm(req.body || {}));
6246
+ } catch (e) {
6247
+ var n = _safeNotice(e, "promo_banner.define");
6248
+ var rows = await promos.listAll({});
6249
+ return _sendHtml(res, n.status, renderAdminPromoBanners({
6250
+ shop_name: deps.shop_name, nav_available: navAvailable, banners: rows,
6251
+ notice: n.message.replace(/^promoBanners[.:]\s*/, ""),
6252
+ }));
6253
+ }
6254
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".promo_banner.define", outcome: "success" });
6255
+ _redirect(res, "/admin/promo-banners?created=1");
6256
+ },
6257
+ ));
6258
+
6259
+ // Detail screen: the single banner + its edit form. Content-negotiates
6260
+ // like the list — bearer → the JSON row; browser cookie → the rendered
6261
+ // edit page. A bad / unknown slug is a 404 page (browser) or 404 problem
6262
+ // (bearer), never a 500.
6263
+ router.get("/admin/promo-banners/:slug", _pageOrApi(true,
6264
+ R(async function (req, res) {
6265
+ var row = await promos.getBanner(req.params.slug);
6266
+ if (!row) return _problem(res, 404, "promo-banner-not-found");
6267
+ _json(res, 200, row);
6268
+ }),
6269
+ async function (req, res) {
6270
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
6271
+ var row = null;
6272
+ try { row = await promos.getBanner(req.params.slug); }
6273
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
6274
+ if (!row) return _sendHtml(res, 404, renderAdminPromoBanners({
6275
+ shop_name: deps.shop_name, nav_available: navAvailable, banners: [], notice: "Banner not found.",
6276
+ }));
6277
+ _sendHtml(res, 200, renderAdminPromoBanner({
6278
+ shop_name: deps.shop_name, nav_available: navAvailable, banner: row,
6279
+ updated: url && url.searchParams.get("updated"),
6280
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the banner." : null,
6281
+ }));
6282
+ },
6283
+ ));
6284
+
6285
+ // Edit content-negotiates: bearer POST /edit (JSON) + browser POST /edit
6286
+ // (HTML forms can't PATCH). Both forward the full editable column set into
6287
+ // updateBanner, preserving the slug + accumulated counters (archive-and-
6288
+ // recreate would discard both). PRG to ?updated=1; a bad shape is a clean
6289
+ // 400 (bearer) / err notice (browser).
6290
+ router.post("/admin/promo-banners/:slug/edit", _pageOrApi(false,
6291
+ W("promo_banner.update", async function (req, res) {
6292
+ var row;
6293
+ try { row = await promos.updateBanner(req.params.slug, _promoBannerPatchFromForm(req.body || {})); }
6294
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
6295
+ if (!row) return _problem(res, 404, "promo-banner-not-found");
6296
+ _json(res, 200, row);
6297
+ return { id: row.slug };
6298
+ }),
6299
+ async function (req, res) {
6300
+ var slug = req.params.slug;
6301
+ var enc = encodeURIComponent(slug);
6302
+ try { await promos.updateBanner(slug, _promoBannerPatchFromForm(req.body || {})); }
6303
+ catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/promo-banners/" + enc + "?err=1"); }
6304
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".promo_banner.update", outcome: "success", metadata: { slug: slug } });
6305
+ _redirect(res, "/admin/promo-banners/" + enc + "?updated=1");
6306
+ },
6307
+ ));
6308
+
6309
+ router.post("/admin/promo-banners/:slug/archive", _pageOrApi(false,
6310
+ W("promo_banner.archive", async function (req, res) {
6311
+ var row;
6312
+ try { row = await promos.archive(req.params.slug); }
6313
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
6314
+ if (!row) return _problem(res, 404, "promo-banner-not-found");
6315
+ _json(res, 200, row);
6316
+ return { id: row.slug };
6317
+ }),
6318
+ async function (req, res) {
6319
+ var slug = req.params.slug;
6320
+ var row = null;
6321
+ try { row = await promos.archive(slug); }
6322
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
6323
+ if (!row) return _redirect(res, "/admin/promo-banners?err=1");
6324
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".promo_banner.archive", outcome: "success", metadata: { slug: slug } });
6325
+ _redirect(res, "/admin/promo-banners?archived=1");
6326
+ },
6327
+ ));
6328
+
6329
+ // Restore an archived banner — the primitive exposes unarchive, so the
6330
+ // console surfaces it (the announcement bar has no unarchive, so it has no
6331
+ // restore action; here the full lifecycle is offered).
6332
+ router.post("/admin/promo-banners/:slug/unarchive", _pageOrApi(false,
6333
+ W("promo_banner.unarchive", async function (req, res) {
6334
+ var row;
6335
+ try { row = await promos.unarchive(req.params.slug); }
6336
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
6337
+ if (!row) return _problem(res, 404, "promo-banner-not-found");
6338
+ _json(res, 200, row);
6339
+ return { id: row.slug };
6340
+ }),
6341
+ async function (req, res) {
6342
+ var slug = req.params.slug;
6343
+ var row = null;
6344
+ try { row = await promos.unarchive(slug); }
6345
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
6346
+ if (!row) return _redirect(res, "/admin/promo-banners?err=1");
6347
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".promo_banner.unarchive", outcome: "success", metadata: { slug: slug } });
6348
+ _redirect(res, "/admin/promo-banners?restored=1");
6349
+ },
6350
+ ));
6351
+ }
6352
+
6203
6353
  // ---- search ranking -------------------------------------------------
6204
6354
  // Operator-tunable storefront search ranking: named weight sets (one
6205
6355
  // active at a time), per-query manual pins, and a per-set metrics rollup.
@@ -11724,6 +11874,7 @@ var ADMIN_NAV_ITEMS = [
11724
11874
  { key: "shipping-labels", href: "/admin/shipping-labels", label: "Shipping labels", requires: "shippingLabels" },
11725
11875
  { key: "pick-lists", href: "/admin/pick-lists", label: "Pick lists", requires: "pickLists" },
11726
11876
  { key: "announcements", href: "/admin/announcements", label: "Announcements", requires: "announcementBar" },
11877
+ { key: "promo-banners", href: "/admin/promo-banners", label: "Promo banners", requires: "promoBanners" },
11727
11878
  { key: "blog", href: "/admin/blog", label: "Blog", requires: "blog" },
11728
11879
  { key: "help", href: "/admin/help", label: "Help center", requires: "knowledgeBase" },
11729
11880
  { key: "pages", href: "/admin/pages", label: "Pages", requires: "storefrontPages" },
@@ -16752,6 +16903,231 @@ function renderAdminAnnouncements(opts) {
16752
16903
  return _renderAdminShell(opts.shop_name, "Announcements", bodyHtml, "announcements", opts.nav_available);
16753
16904
  }
16754
16905
 
16906
+ // ---- promo-banner form coercion -------------------------------------
16907
+
16908
+ var _PROMO_PLACEMENT_LABELS = {
16909
+ top_strip: "Top strip (sitewide, above the nav)",
16910
+ homepage_hero: "Homepage hero",
16911
+ pdp_side: "Product page sidebar",
16912
+ cart_side: "Cart page",
16913
+ search_empty: "Search — no results",
16914
+ footer: "Footer (sitewide)",
16915
+ };
16916
+
16917
+ // A priority form field → integer, or NaN when blank/non-numeric so the
16918
+ // primitive's validator (not this coercion) is the one that reports it.
16919
+ function _promoPriorityField(v) {
16920
+ if (v == null || v === "") return NaN;
16921
+ var n = parseInt(v, 10);
16922
+ return Number.isFinite(n) ? n : NaN;
16923
+ }
16924
+
16925
+ // Coerce the create form into the shape promoBanners.defineBanner expects:
16926
+ // trimmed slug, the enum selects (placement / audience / theme), integer
16927
+ // priority, the optional body / image_url, and the two epoch schedule bounds.
16928
+ // defineBanner requires priority + starts_at + expires_at, so the form
16929
+ // collects all three; a blank optional (body / image_url) is dropped so the
16930
+ // primitive applies its own absence handling. The primitive is authoritative
16931
+ // on every field (URL scheme via safeUrl, slug/headline/label shape, window
16932
+ // monotonicity) and throws a TypeError on a bad shape, which both surfaces
16933
+ // degrade to a clean 400 / err notice.
16934
+ function _promoBannerFromForm(body) {
16935
+ body = body || {};
16936
+ var out = {
16937
+ slug: typeof body.slug === "string" ? body.slug.trim() : body.slug,
16938
+ placement: body.placement,
16939
+ headline: body.headline,
16940
+ cta_label: body.cta_label,
16941
+ cta_url: typeof body.cta_url === "string" ? body.cta_url.trim() : body.cta_url,
16942
+ audience: body.audience,
16943
+ theme: body.theme,
16944
+ priority: _promoPriorityField(body.priority),
16945
+ starts_at: _epochFromForm(body.starts_at),
16946
+ expires_at: _epochFromForm(body.expires_at),
16947
+ };
16948
+ if (typeof body.body === "string" && body.body.length) out.body = body.body;
16949
+ var img = typeof body.image_url === "string" ? body.image_url.trim() : "";
16950
+ if (img) out.image_url = img;
16951
+ return out;
16952
+ }
16953
+
16954
+ // Coerce the edit form into an updateBanner patch. Each editable column rides
16955
+ // only when its hidden presence marker says so, so a partial edit doesn't
16956
+ // clear an unrelated column. The body / image_url presence markers map a
16957
+ // blank to null (clear it); the schedule markers map a blank bound to NaN so
16958
+ // the primitive (not this coercion) reports an invalid window. The primitive
16959
+ // validates the result and throws a TypeError on a bad shape, which both
16960
+ // surfaces degrade to a clean 400 / err notice.
16961
+ function _promoBannerPatchFromForm(body) {
16962
+ body = body || {};
16963
+ var patch = {};
16964
+ if (body.placement != null && body.placement !== "") patch.placement = body.placement;
16965
+ if (body.headline != null && body.headline !== "") patch.headline = body.headline;
16966
+ if (body.cta_label != null && body.cta_label !== "") patch.cta_label = body.cta_label;
16967
+ if (body.audience != null && body.audience !== "") patch.audience = body.audience;
16968
+ if (body.theme != null && body.theme !== "") patch.theme = body.theme;
16969
+ if (body.cta_url != null && body.cta_url !== "") patch.cta_url = String(body.cta_url).trim();
16970
+ if (body.priority != null && body.priority !== "") patch.priority = _promoPriorityField(body.priority);
16971
+ if (body.body_present === "1") {
16972
+ var bd = typeof body.body === "string" ? body.body : "";
16973
+ patch.body = bd.length ? bd : null;
16974
+ }
16975
+ if (body.image_present === "1") {
16976
+ var im = typeof body.image_url === "string" ? body.image_url.trim() : "";
16977
+ patch.image_url = im.length ? im : null;
16978
+ }
16979
+ if (body.starts_present === "1") patch.starts_at = _epochFromForm(body.starts_at);
16980
+ if (body.expires_present === "1") patch.expires_at = _epochFromForm(body.expires_at);
16981
+ return patch;
16982
+ }
16983
+
16984
+ // Single-banner detail + edit screen. Prefills every editable column and
16985
+ // posts to /edit; the hidden presence markers tell the patch coercion which
16986
+ // optional columns the form is authoritative for (so a blank body / image /
16987
+ // schedule clears rather than being ignored). The slug + placement are shown
16988
+ // but not re-editable here (placement IS editable via the select; the slug is
16989
+ // the stable id). An archived banner shows a restore action instead of the
16990
+ // edit form.
16991
+ function renderAdminPromoBanner(opts) {
16992
+ opts = opts || {};
16993
+ var p = opts.banner;
16994
+ if (!p) {
16995
+ var nf = "<section><h2>Promo banner</h2><p class=\"empty\">Banner not found.</p>" +
16996
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/promo-banners\">Back to banners</a></div></section>";
16997
+ return _renderAdminShell(opts.shop_name, "Promo banner", nf, "promo-banners", opts.nav_available);
16998
+ }
16999
+ var updated = opts.updated ? "<div class=\"banner banner--ok\">Banner updated.</div>" : "";
17000
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
17001
+ var isArchived = p.archived_at != null;
17002
+
17003
+ var placementOpts = Object.keys(_PROMO_PLACEMENT_LABELS).map(function (pl) {
17004
+ return "<option value=\"" + pl + "\"" + (pl === p.placement ? " selected" : "") + ">" + _htmlEscape(_PROMO_PLACEMENT_LABELS[pl]) + "</option>";
17005
+ }).join("");
17006
+ var themeOpts = ["urgency", "promo", "info", "success"].map(function (t) {
17007
+ return "<option value=\"" + t + "\"" + (t === p.theme ? " selected" : "") + ">" + t + "</option>";
17008
+ }).join("");
17009
+ // segment audience isn't offered (no isMember handle wired); an existing
17010
+ // segment row keeps its value selected so an edit doesn't silently re-target.
17011
+ var audValues = ["all", "guest", "logged_in"];
17012
+ if (p.audience === "segment") audValues = ["segment"].concat(audValues);
17013
+ var audienceOpts = audValues.map(function (au) {
17014
+ return "<option value=\"" + au + "\"" + (au === p.audience ? " selected" : "") + ">" + au + "</option>";
17015
+ }).join("");
17016
+
17017
+ var restoreForm =
17018
+ "<form method=\"post\" action=\"/admin/promo-banners/" + _htmlEscape(encodeURIComponent(p.slug)) + "/unarchive\" class=\"form-inline\">" +
17019
+ "<button class=\"btn\" type=\"submit\">Restore banner</button></form>";
17020
+
17021
+ var editForm = isArchived
17022
+ ? "<p class=\"empty\">This banner is archived. Restore it to edit and show it again.</p>" + restoreForm
17023
+ : "<div class=\"panel mw-40\">" +
17024
+ "<h3 class=\"subhead\">Edit banner</h3>" +
17025
+ "<form method=\"post\" action=\"/admin/promo-banners/" + _htmlEscape(encodeURIComponent(p.slug)) + "/edit\">" +
17026
+ "<input type=\"hidden\" name=\"body_present\" value=\"1\">" +
17027
+ "<input type=\"hidden\" name=\"image_present\" value=\"1\">" +
17028
+ "<input type=\"hidden\" name=\"starts_present\" value=\"1\">" +
17029
+ "<input type=\"hidden\" name=\"expires_present\" value=\"1\">" +
17030
+ "<label class=\"form-field\"><span>Placement</span><select name=\"placement\">" + placementOpts + "</select></label>" +
17031
+ "<label class=\"form-field\"><span>Headline</span><input type=\"text\" name=\"headline\" maxlength=\"200\" value=\"" + _htmlEscape(p.headline) + "\" required></label>" +
17032
+ "<label class=\"form-field\"><span>Body (optional)</span><textarea name=\"body\" maxlength=\"1000\">" + _htmlEscape(p.body || "") + "</textarea></label>" +
17033
+ _setupField("CTA label", "cta_label", p.cta_label || "", "text", "", " maxlength=\"80\" required") +
17034
+ _setupField("CTA URL", "cta_url", p.cta_url || "", "text", "https:// or a /-rooted path.", " maxlength=\"2048\" required") +
17035
+ _setupField("Image URL (optional)", "image_url", p.image_url || "", "text", "https:// or a /-rooted path. Clear to remove.", " maxlength=\"2048\"") +
17036
+ "<label class=\"form-field\"><span>Theme</span><select name=\"theme\">" + themeOpts + "</select></label>" +
17037
+ "<label class=\"form-field\"><span>Audience</span><select name=\"audience\">" + audienceOpts + "</select></label>" +
17038
+ _setupField("Priority", "priority", String(p.priority), "number", "Higher wins when banners overlap a placement.", " min=\"0\" max=\"1000000\"") +
17039
+ "<label class=\"form-field\"><span>Starts at</span><input type=\"datetime-local\" name=\"starts_at\" value=\"" + _htmlEscape(_datetimeLocalValue(p.starts_at)) + "\"></label>" +
17040
+ "<label class=\"form-field\"><span>Expires at</span><input type=\"datetime-local\" name=\"expires_at\" value=\"" + _htmlEscape(_datetimeLocalValue(p.expires_at)) + "\"></label>" +
17041
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Save changes</button></div>" +
17042
+ "</form>" +
17043
+ "</div>";
17044
+
17045
+ var bodyHtml = "<section><h2>Promo banner</h2>" + updated + notice +
17046
+ "<div class=\"panel\"><dl class=\"detail-grid\">" +
17047
+ "<div><dt>Slug</dt><dd><code class=\"order-id\">" + _htmlEscape(p.slug) + "</code></dd></div>" +
17048
+ "<div><dt>Status</dt><dd><span class=\"status-pill " + (isArchived ? "cancelled" : "paid") + "\">" + (isArchived ? "archived" : "active") + "</span></dd></div>" +
17049
+ "<div><dt>Impressions</dt><dd>" + _htmlEscape(String(p.impression_count)) + "</dd></div>" +
17050
+ "<div><dt>Clicks</dt><dd>" + _htmlEscape(String(p.click_count)) + "</dd></div>" +
17051
+ "</dl></div>" +
17052
+ editForm +
17053
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/promo-banners\">Back to banners</a></div>" +
17054
+ "</section>";
17055
+ return _renderAdminShell(opts.shop_name, "Promo banner", bodyHtml, "promo-banners", opts.nav_available);
17056
+ }
17057
+
17058
+ function renderAdminPromoBanners(opts) {
17059
+ opts = opts || {};
17060
+ var rows = opts.banners || [];
17061
+ var created = opts.created ? "<div class=\"banner banner--ok\">Banner saved.</div>" : "";
17062
+ var archived = opts.archived ? "<div class=\"banner banner--ok\">Banner archived.</div>" : "";
17063
+ var restored = opts.restored ? "<div class=\"banner banner--ok\">Banner restored.</div>" : "";
17064
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
17065
+
17066
+ var af = opts.active_filter;
17067
+ var chips = "<div class=\"order-filters\">" +
17068
+ "<a class=\"chip" + (af == null ? " chip--on" : "") + "\" href=\"/admin/promo-banners\">All</a>" +
17069
+ "<a class=\"chip" + (af === "1" ? " chip--on" : "") + "\" href=\"/admin/promo-banners?active=1\">Active now</a>" +
17070
+ "</div>";
17071
+
17072
+ var bodyRows = rows.map(function (p) {
17073
+ var isArchived = p.archived_at != null;
17074
+ var restoreOrArchive = isArchived
17075
+ ? "<form method=\"post\" action=\"/admin/promo-banners/" + _htmlEscape(encodeURIComponent(p.slug)) + "/unarchive\" class=\"form-inline\">" +
17076
+ "<button class=\"btn\" type=\"submit\">Restore</button></form>"
17077
+ : "<a class=\"btn btn--ghost\" href=\"/admin/promo-banners/" + _htmlEscape(encodeURIComponent(p.slug)) + "\">Edit</a> " +
17078
+ "<form method=\"post\" action=\"/admin/promo-banners/" + _htmlEscape(encodeURIComponent(p.slug)) + "/archive\" class=\"form-inline\">" +
17079
+ "<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form>";
17080
+ return "<tr>" +
17081
+ "<td><code class=\"order-id\">" + _htmlEscape(p.slug) + "</code></td>" +
17082
+ "<td>" + _htmlEscape(p.headline) + "</td>" +
17083
+ "<td>" + _htmlEscape(p.placement) + "</td>" +
17084
+ "<td><span class=\"status-pill\">" + _htmlEscape(p.theme) + "</span></td>" +
17085
+ "<td>" + _htmlEscape(p.audience) + "</td>" +
17086
+ "<td>" + _htmlEscape(String(p.priority)) + "</td>" +
17087
+ "<td><span class=\"status-pill " + (isArchived ? "cancelled" : "paid") + "\">" + (isArchived ? "archived" : "active") + "</span></td>" +
17088
+ "<td><div class=\"actions-row\">" + restoreOrArchive + "</div></td>" +
17089
+ "</tr>";
17090
+ }).join("");
17091
+
17092
+ var table = rows.length
17093
+ ? "<div class=\"panel\">" + _tableWrap("<table><thead><tr><th scope=\"col\">Slug</th><th scope=\"col\">Headline</th><th scope=\"col\">Placement</th><th scope=\"col\">Theme</th><th scope=\"col\">Audience</th><th scope=\"col\">Priority</th><th scope=\"col\">Status</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + bodyRows + "</tbody></table>") + "</div>"
17094
+ : "<p class=\"empty\">No promo banners" + (af === "1" ? " active right now" : " yet") + ".</p>";
17095
+
17096
+ var placementOpts = Object.keys(_PROMO_PLACEMENT_LABELS).map(function (pl) {
17097
+ return "<option value=\"" + pl + "\">" + _htmlEscape(_PROMO_PLACEMENT_LABELS[pl]) + "</option>";
17098
+ }).join("");
17099
+ var themeOpts = ["info", "promo", "urgency", "success"].map(function (t) {
17100
+ return "<option value=\"" + t + "\"" + (t === "info" ? " selected" : "") + ">" + t + "</option>";
17101
+ }).join("");
17102
+ var audienceOpts = ["all", "guest", "logged_in"].map(function (au) {
17103
+ return "<option value=\"" + au + "\">" + au + "</option>";
17104
+ }).join("");
17105
+
17106
+ var createForm =
17107
+ "<div class=\"panel mt mw-40\">" +
17108
+ "<h3 class=\"subhead\">Create a promo banner</h3>" +
17109
+ "<p class=\"meta\">The highest-priority active banner shows at each placement for a matching viewer. Top strip and footer appear on every page; the others appear on their named page.</p>" +
17110
+ "<form method=\"post\" action=\"/admin/promo-banners\">" +
17111
+ _setupField("Slug", "slug", "", "text", "Lowercase, hyphenated — a stable id.", " maxlength=\"80\" required") +
17112
+ "<label class=\"form-field\"><span>Placement</span><select name=\"placement\" required>" + placementOpts + "</select></label>" +
17113
+ _setupField("Headline", "headline", "", "text", "", " maxlength=\"200\" required") +
17114
+ "<label class=\"form-field\"><span>Body (optional)</span><textarea name=\"body\" maxlength=\"1000\"></textarea></label>" +
17115
+ _setupField("CTA label", "cta_label", "", "text", "", " maxlength=\"80\" required") +
17116
+ _setupField("CTA URL", "cta_url", "", "text", "https:// or a /-rooted path.", " maxlength=\"2048\" required") +
17117
+ _setupField("Image URL (optional)", "image_url", "", "text", "https:// or a /-rooted path.", " maxlength=\"2048\"") +
17118
+ "<label class=\"form-field\"><span>Theme</span><select name=\"theme\">" + themeOpts + "</select></label>" +
17119
+ "<label class=\"form-field\"><span>Audience</span><select name=\"audience\" required>" + audienceOpts + "</select></label>" +
17120
+ _setupField("Priority", "priority", "100", "number", "Higher wins when banners overlap a placement.", " min=\"0\" max=\"1000000\" required") +
17121
+ "<label class=\"form-field\"><span>Starts at</span><input type=\"datetime-local\" name=\"starts_at\" required></label>" +
17122
+ "<label class=\"form-field\"><span>Expires at</span><input type=\"datetime-local\" name=\"expires_at\" required></label>" +
17123
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Create banner</button></div>" +
17124
+ "</form>" +
17125
+ "</div>";
17126
+
17127
+ var bodyHtml = "<section><h2>Promo banners</h2>" + created + archived + restored + notice + chips + table + createForm + "</section>";
17128
+ return _renderAdminShell(opts.shop_name, "Promo banners", bodyHtml, "promo-banners", opts.nav_available);
17129
+ }
17130
+
16755
17131
  // Search-ranking console — the weight-set list (active flag + activate /
16756
17132
  // archive actions), the create form (slug + name + JSON weight map), the
16757
17133
  // per-query pin manager, and an optional per-set metrics rollup. Every
@@ -1,13 +1,13 @@
1
1
  {
2
- "version": "0.4.3",
2
+ "version": "0.4.5",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-6k53cvkRrxMgmeStLIoLjVXZQHqIJgTmv1Izd8TYhh1HOC4POgE6GCvx1bsalyEP",
6
6
  "fingerprinted": "css/admin.44eb97700c660798.css"
7
7
  },
8
8
  "css/main.css": {
9
- "integrity": "sha384-kjpDvd8TisWdb45G5S2FyHOo4plk5DiOMO0aTZWxKCJhAhBEo55yABnv5BbUpzS4",
10
- "fingerprinted": "css/main.337a7be6244cd7c0.css"
9
+ "integrity": "sha384-fxUZj7xaROivK6yjixSJavibw/WXXmUgjxS4mSG2PXavX/j2GZrsi9uquxvcJjTa",
10
+ "fingerprinted": "css/main.0117fe12d12ac55b.css"
11
11
  },
12
12
  "js/announcement.js": {
13
13
  "integrity": "sha384-z4zcEMn+tScoVnYRE4nEf8N/oyvpxdpaxTNrT4QO/jURChid4+qjAvWkzatCaAPq",
@@ -30,8 +30,8 @@
30
30
  "fingerprinted": "js/passkey-add.b535e6a3eef4514e.js"
31
31
  },
32
32
  "js/passkey-login.js": {
33
- "integrity": "sha384-AbMxT4s0paFnZsEfAHfcpbS2ZRSmZ9KPnttEBy9SvWErBX3U+LhoeYYN7Nm3Y8AX",
34
- "fingerprinted": "js/passkey-login.90c13d7c0d8667e2.js"
33
+ "integrity": "sha384-YcFe/H5GiEIXJ3bvx4RMUKtKwW249rKWyOYbd6xRcDrlAhDeDpt7HGZYPs+R6yWO",
34
+ "fingerprinted": "js/passkey-login.41da9ad7da816e97.js"
35
35
  },
36
36
  "js/passkey-register.js": {
37
37
  "integrity": "sha384-BjuUhbPZ18pHFMyOwT+309BEXu+VAc54RSj8lvN93jfFGDydEgmapiAOKTQM1yma",