@tokenoftrust/storefront-runner 2.4.38 → 2.4.40

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.
@@ -141,6 +141,17 @@ declare global {
141
141
  * inside this prefix on the client.
142
142
  */
143
143
  basePath: string;
144
+ /**
145
+ * The tenant's REAL routed prefix, always — never overridden to a candidate/
146
+ * revision-pinned render's own prefix the way {@link basePath} is. A pinned
147
+ * preview (`/preview/render/<tenant>/<changeId>`, `/preview/rev/<sha>`,
148
+ * `/preview/staged/<sha>`) has no API mirror under its prefix (the catch-all
149
+ * preview route only renders pages), so any same-origin fetch to a real
150
+ * backend endpoint (`/api/...`) must be built from THIS, not `basePath`, or
151
+ * it 404s under preview. Nav hrefs still want `basePath` so they stay inside
152
+ * the pinned render.
153
+ */
154
+ apiBasePath: string;
144
155
  /**
145
156
  * Which customization environment to serve. "live" on production
146
157
  * hosts; "test" only behind a valid, tenant-bound preview token on a
@@ -330,10 +330,13 @@ a.article-card__tag:hover { text-decoration: underline; }
330
330
  .age-gate__panel { width: min(30rem, 100%); background: var(--color-surface); border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); padding: clamp(24px, 4vw, 40px); text-align: center; }
331
331
  .age-gate__mark { font-family: var(--font-display); font-weight: 800; letter-spacing: .04em; font-size: 1.05rem; color: var(--color-text); }
332
332
  .age-gate__mark .is-red { color: var(--color-primary); }
333
+ .age-gate__eyebrow { font-family: var(--font-display); font-weight: 700; font-size: .75rem; letter-spacing: .12em; text-transform: uppercase; color: var(--color-muted); }
333
334
  .age-gate h2 { margin-top: 14px; font-size: clamp(1.4rem, 3vw, 1.85rem); }
334
335
  .age-gate p { margin-top: 10px; color: var(--color-text); }
335
336
  .age-gate__actions { display: flex; flex-wrap: wrap; gap: 12px; justify-content: center; margin-top: 24px; }
336
337
  .age-gate__actions .btn { min-width: 9rem; }
338
+ .age-gate__help { font-size: .875rem; }
339
+ .age-gate__help a { color: var(--color-accent-ink, var(--color-primary)); text-decoration: underline; }
337
340
  .age-gate__deny { display: none; }
338
341
  .age-gate.is-denied .age-gate__enter, .age-gate.is-denied .age-gate__lede { display: none; }
339
342
  .age-gate.is-denied .age-gate__deny { display: block; margin-top: 18px; color: var(--color-error); font-weight: 600; }
@@ -50,7 +50,7 @@ const {
50
50
  class: className,
51
51
  } = Astro.props;
52
52
 
53
- const { basePath } = Astro.locals;
53
+ const { basePath, apiBasePath } = Astro.locals;
54
54
  const hrefOf = (l: ChromeLink) => (l.external ? l.href : withBase(basePath, l.href));
55
55
  const showLogo = !!brand?.logo && /^(https?:|\/)/.test(brand.logo.src);
56
56
  const columnLinkStamp = (l: ChromeLink, colIndex: number, index: number) =>
@@ -184,7 +184,7 @@ const collapsible = variant === "light-collapsible";
184
184
  {newsletter.title && <h3 class="font-display text-base font-semibold">{newsletter.title}</h3>}
185
185
  {newsletter.body && <p class="mt-1 text-sm text-muted">{newsletter.body}</p>}
186
186
  <form
187
- action={withBase(basePath, newsletter.action ?? "/api/newsletter")}
187
+ action={withBase(apiBasePath, newsletter.action ?? "/api/newsletter")}
188
188
  method="post"
189
189
  class="mt-4 flex max-w-md flex-col gap-3 sm:flex-row sm:flex-wrap"
190
190
  >
@@ -47,7 +47,7 @@ import type {
47
47
  HeaderVariant,
48
48
  SocialLink,
49
49
  } from "./chrome/model.js";
50
- import { resolveAgeGateCopy, resolveChromeAssignment } from "./chrome/model.js";
50
+ import { isSafeAgeGateHref, resolveAgeGateCopy, resolveChromeAssignment } from "./chrome/model.js";
51
51
  import { resolveHeaderVariant } from "./chrome/variant.js";
52
52
 
53
53
  /** HTML-escape text/attribute content. Covers the five significant characters. */
@@ -436,6 +436,9 @@ function renderHeader(header: ChromeHeader, variant: HeaderVariant, currentPath?
436
436
  * can't close the tag early.
437
437
  */
438
438
  function renderAgeGate(g: ChromeAgeGate): string {
439
+ // showNo/eyebrow/helpLink defaults mirror resolveAgeGateRuntime (AgeGate.tsx)
440
+ // exactly, so the raw-chrome gate and the component gate present identically
441
+ // for the same chrome.json.
439
442
  const resolved = {
440
443
  ...g,
441
444
  title: resolveAgeGateCopy(g.title, g.minAge),
@@ -444,6 +447,13 @@ function renderAgeGate(g: ChromeAgeGate): string {
444
447
  noLabel: resolveAgeGateCopy(g.noLabel, g.minAge),
445
448
  denyText: resolveAgeGateCopy(g.denyText, g.minAge),
446
449
  fineText: resolveAgeGateCopy(g.fineText, g.minAge),
450
+ showNo: g.showNo !== false,
451
+ eyebrow:
452
+ g.eyebrow === false ? null : (resolveAgeGateCopy(g.eyebrow, g.minAge) ?? "Age verification"),
453
+ helpLink:
454
+ g.helpLink && isSafeAgeGateHref(g.helpLink.href)
455
+ ? { label: resolveAgeGateCopy(g.helpLink.label, g.minAge) ?? g.helpLink.label, href: g.helpLink.href }
456
+ : null,
447
457
  };
448
458
  const json = JSON.stringify(resolved).replace(/</g, "\\u003c");
449
459
  return `<script type="application/json" id="tot-age-gate-config">${json}</script>`;
@@ -726,20 +736,28 @@ export const CHROME_BEHAVIOR_JS = `(function () {
726
736
  return '<span' + (p.className ? ' class="' + gescape(p.className) + '"' : '') + '>' + gescape(p.text) + '</span>';
727
737
  }).join('') + '</p>'
728
738
  : '';
739
+ var geyebrow = !gmark && gcfg.eyebrow ? '<p class="age-gate__eyebrow">' + gescape(gcfg.eyebrow) + '</p>' : '';
729
740
  var gtitle = gescape(gcfg.title || ('Are you ' + gcfg.minAge + ' or older?'));
730
741
  var glede = gcfg.lede ? '<p class="age-gate__lede" id="ageGateBody">' + gescape(gcfg.lede) + '</p>' : '';
731
742
  var gfine = gcfg.fineText ? '<p class="age-gate__fine">' + gescape(gcfg.fineText) + '</p>' : '';
732
743
  var gdeny = gescape(gcfg.denyText || ('You must be ' + gcfg.minAge + ' or older to enter this site.'));
733
744
  var gdescribedby = gcfg.lede ? ' aria-describedby="ageGateBody"' : '';
745
+ var gnoButton = gcfg.showNo
746
+ ? '<button type="button" class="btn btn-outline age-gate__enter" id="ageNo">' + gescape(gcfg.noLabel || ('No, I am under ' + gcfg.minAge)) + '</button>'
747
+ : '';
748
+ var ghelp = gcfg.helpLink
749
+ ? '<p class="age-gate__help"><a href="' + gescape(gcfg.helpLink.href) + '">' + gescape(gcfg.helpLink.label) + '</a></p>'
750
+ : '';
734
751
 
735
752
  mount.innerHTML =
736
753
  '<div class="age-gate" id="ageGate" role="dialog" aria-modal="true" aria-labelledby="ageGateTitle"' + gdescribedby + '>' +
737
- '<div class="age-gate__panel">' + gmark +
754
+ '<div class="age-gate__panel">' + gmark + geyebrow +
738
755
  '<h2 id="ageGateTitle">' + gtitle + '</h2>' + glede +
739
756
  '<div class="age-gate__actions">' +
740
757
  '<button type="button" class="btn btn-primary age-gate__enter" id="ageYes">' + gescape(gcfg.yesLabel || ('Yes, I am ' + gcfg.minAge + '+')) + '</button>' +
741
- '<button type="button" class="btn btn-outline age-gate__enter" id="ageNo">' + gescape(gcfg.noLabel || ('No, I am under ' + gcfg.minAge)) + '</button>' +
758
+ gnoButton +
742
759
  '</div>' +
760
+ ghelp +
743
761
  '<p class="age-gate__deny" id="ageDeny">' + gdeny + '</p>' + gfine +
744
762
  '</div></div>';
745
763
 
@@ -668,6 +668,12 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
668
668
  // `/preview/render/<tenant>/<changeId>`) so a pinned page's OWN generated
669
669
  // links stay inside that candidate instead of escaping to the live host.
670
670
  locals.basePath = candidatePin ? candidatePin.basePath : basePath;
671
+ // ALWAYS the real tenant-routed prefix, even under a candidate/revision pin —
672
+ // fb-1791905696516-ubw8a3: a pinned preview's catch-all route only knows how to
673
+ // RENDER a page, so same-origin fetches to a real `/api/...` backend endpoint
674
+ // (checkout handoff, compliance preflight, newsletter) must stay off `basePath`
675
+ // once it's pin-overridden, or they 404 under preview.
676
+ locals.apiBasePath = basePath;
671
677
  // Renderer-owned four-stage browser environment (ADR 0014 E1 / 0017), computed
672
678
  // ONCE here from authoritative request state and serialized before app code by
673
679
  // both document mounts (Layout.astro + rawHtmlResponse). The candidate render
@@ -43,7 +43,7 @@ import {
43
43
  type CommerceStaticResolved,
44
44
  } from "@/lib/homeVisualParity";
45
45
 
46
- const { tenant, tot, content, basePath, canonicalBase, features, capabilities } = Astro.locals;
46
+ const { tenant, tot, content, basePath, apiBasePath, canonicalBase, features, capabilities } = Astro.locals;
47
47
  const marketing = !features.catalog;
48
48
 
49
49
  // Raw-HTML home (vendor-injected): serve the tenant's own self-contained
@@ -296,7 +296,7 @@ const homeSectionWidgets = marketing
296
296
  </h2>
297
297
  {b.body && <p class="mt-3 text-[var(--color-muted)]">{b.body}</p>}
298
298
  <form
299
- action={withBase(basePath, "/api/newsletter")}
299
+ action={withBase(apiBasePath, "/api/newsletter")}
300
300
  method="post"
301
301
  class="mx-auto mt-7 flex max-w-md flex-col gap-3 sm:flex-row"
302
302
  >
@@ -49,7 +49,7 @@ import { getRegistryService } from "@/lib/apps/registryAccess.js";
49
49
  import { renderWidgetSlot } from "@/lib/apps/widgets/renderSlot.js";
50
50
 
51
51
  const { handle } = Astro.params;
52
- const { tenant, tot, canonicalBase, basePath, commerce } = Astro.locals;
52
+ const { tenant, tot, canonicalBase, basePath, apiBasePath, commerce } = Astro.locals;
53
53
  // Commerce-only route: marketing tenants have no product detail pages.
54
54
  if (!Astro.locals.features.catalog) return new Response(null, { status: 404 });
55
55
  const compliance = tenant.compliance;
@@ -380,7 +380,7 @@ const jsonLd = [productJsonLd, breadcrumbLd(crumbs, canonicalBase)];
380
380
  productTitle={product.title}
381
381
  checkoutCart={checkoutCart}
382
382
  pricing={pricing}
383
- handoffUrl={withBase(basePath, "/api/commerce/checkout-handoff")}
383
+ handoffUrl={withBase(apiBasePath, "/api/commerce/checkout-handoff")}
384
384
  />
385
385
  {/* Regulated-tenant checkout ENFORCEMENT gate. Intercepts the
386
386
  managed-cart handoff to clear age + ship-to compliance server-side
@@ -389,7 +389,7 @@ const jsonLd = [productJsonLd, breadcrumbLd(crumbs, canonicalBase)];
389
389
  {checkoutCart && isRegulatedTenant(tenant) && compliance?.minAge && (
390
390
  <CheckoutComplianceGate
391
391
  client:load
392
- preflightUrl={withBase(basePath, "/api/compliance/preflight")}
392
+ preflightUrl={withBase(apiBasePath, "/api/compliance/preflight")}
393
393
  minAge={compliance.minAge}
394
394
  />
395
395
  )}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.4.38",
3
+ "version": "2.4.40",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",