@tokenoftrust/storefront-runner 4.3.4 → 4.5.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.
@@ -16,7 +16,8 @@ import PriceDisplay from "@/components/catalog/storefront/commerce/PriceDisplay.
16
16
  import RatingStars from "@/components/catalog/storefront/commerce/RatingStars.astro";
17
17
  import { withBase } from "@/lib/publication/shared/basePath";
18
18
  import { pageUrlForm } from "@/lib/content/seo/urlForm";
19
- import { buildCardCartForm } from "@/lib/checkout/commerce/cardCartForm";
19
+ import { buildCardCartForm, cardNeedsOptionChoice } from "@/lib/checkout/commerce/cardCartForm";
20
+ import { isLocalSampleCheckout } from "@/config/declaredTenantConfig";
20
21
 
21
22
  interface Props {
22
23
  product: CatalogProduct;
@@ -95,6 +96,10 @@ const cardCart = await buildCardCartForm({
95
96
  environment: browserEnvironment.environment,
96
97
  contentVersionId,
97
98
  });
99
+ // A product with options is chosen on its product page, never from the card.
100
+ const productHref = withBase(basePath, pageUrlForm(`/products/${product.handle}`, tenant.seo));
101
+ const selectOptions = available && cardNeedsOptionChoice(product) &&
102
+ (!!commerce || isLocalSampleCheckout(tenant.tenant_id));
98
103
 
99
104
  const quickview = {
100
105
  ...snapshot,
@@ -201,7 +206,7 @@ const quickview = {
201
206
  {/* `py-3` on an inline box raises the tap target to the 44px minimum
202
207
  without changing the line box, so the card layout is untouched. */}
203
208
  <a
204
- href={withBase(basePath, pageUrlForm(`/products/${product.handle}`, tenant.seo))}
209
+ href={productHref}
205
210
  data-astro-prefetch
206
211
  class="py-3 after:absolute after:inset-0 after:z-0 transition-colors motion-safe:group-hover:text-primary"
207
212
  >
@@ -234,6 +239,18 @@ const quickview = {
234
239
  </form>
235
240
  )
236
241
  }
242
+ {
243
+ selectOptions && (
244
+ <a
245
+ href={productHref}
246
+ data-select-options
247
+ data-product-handle={product.handle}
248
+ class="relative z-10 mt-3 block w-full rounded-full bg-[var(--color-primary)] px-4 py-3 text-center font-display text-sm font-medium text-[var(--color-primary-contrast)]"
249
+ >
250
+ Select options
251
+ </a>
252
+ )
253
+ }
237
254
 
238
255
  {rating != null && <RatingStars rating={rating} count={reviewCount} class="mt-1.5" />}
239
256
 
@@ -7,7 +7,7 @@
7
7
  * Encoding handled by src/lib/publication/shared/url.ts (opt.<Name>=<value>, tag=, price, avail).
8
8
  */
9
9
  import type { Facet, ListProductsQuery } from "@tot/public-runtime";
10
- import { toggleFacet, buildQueryString, isPriceBucketActive, togglePriceBucket } from "@/lib/publication/shared/url";
10
+ import { toggleFacet, splitFacetValues, buildQueryString, isPriceBucketActive, togglePriceBucket } from "@/lib/publication/shared/url";
11
11
  import { PRICE_BUCKETS, bucketByKey } from "@/lib/catalog/shared/priceBuckets";
12
12
 
13
13
  interface Props {
@@ -52,17 +52,29 @@ const clearHref =
52
52
 
53
53
  <div class="space-y-7">
54
54
  {
55
- ordered.map((facet) => (
56
- <fieldset>
57
- <legend class="mb-2.5 font-display text-[0.95rem] font-medium">
58
- {facet.name === "tags"
59
- ? "Tags"
60
- : facet.name === "availability"
61
- ? "Availability"
62
- : facet.name}
63
- </legend>
55
+ ordered.map((facet) => {
56
+ const hasSelected = facet.values.some((fv) => selected(facet.name, fv.value));
57
+ const label =
58
+ facet.name === "tags" ? "Tags" : facet.name === "availability" ? "Availability" : facet.name;
59
+ // The first values (plus any selected one) are listed; the rest sit in a nested
60
+ // "Show more" <details>. A selected value is never in the remainder.
61
+ const { shown, rest } = splitFacetValues(facet.values, (v) => selected(facet.name, v));
62
+ const segments = [
63
+ { more: false, values: shown },
64
+ ...(rest.length > 0 ? [{ more: true, values: rest }] : []),
65
+ ];
66
+ return (
67
+ <details open={hasSelected} data-facet-group={facet.name}>
68
+ <summary class="mb-2.5 cursor-pointer font-display text-[0.95rem] font-medium">
69
+ {label} ({facet.values.length})
70
+ </summary>
71
+ {segments.map((seg) => {
72
+ const Wrap = seg.more ? "details" : Fragment;
73
+ return (
74
+ <Wrap>
75
+ {seg.more && <summary class="cursor-pointer py-1 text-xs text-[var(--color-muted)]">Show more</summary>}
64
76
  <ul class="space-y-1.5">
65
- {facet.values.map((fv) => {
77
+ {seg.values.map((fv) => {
66
78
  const isSel = selected(facet.name, fv.value);
67
79
  const href =
68
80
  basePath + toggleFacet(query, facet.name, fv.value);
@@ -101,8 +113,12 @@ const clearHref =
101
113
  );
102
114
  })}
103
115
  </ul>
104
- </fieldset>
105
- ))
116
+ </Wrap>
117
+ );
118
+ })}
119
+ </details>
120
+ );
121
+ })
106
122
  }
107
123
 
108
124
  {/* Price range — bucketed quick-select (live counts) + a free-text form
@@ -64,6 +64,12 @@ interface Props {
64
64
  */
65
65
  layout?: HeaderVariant;
66
66
  sticky?: boolean;
67
+ /**
68
+ * Render the brand link inside `<h1 class="brand-h1">` (margin:0; font:inherit).
69
+ * The caller sets it on the home route only, and only when the page content
70
+ * supplies no h1 of its own — exactly one h1 per page.
71
+ */
72
+ brandAsH1?: boolean;
67
73
  class?: string;
68
74
  }
69
75
 
@@ -85,6 +91,7 @@ const {
85
91
  variant = "light",
86
92
  layout = "primary",
87
93
  sticky = true,
94
+ brandAsH1 = false,
88
95
  class: className,
89
96
  } = Astro.props;
90
97
 
@@ -96,7 +103,16 @@ const dark = variant === "dark";
96
103
  const minimal = layout === "minimal";
97
104
  const compact = layout === "compact-centered";
98
105
 
99
- const ctaClass = (v?: string) =>
106
+ // Home brand heading: the brand link sits inside <h1 class="brand-h1"> (look unchanged);
107
+ // the compact layout's positioning classes move to the h1, which is then the grid child.
108
+ const brandCompactClass =
109
+ "absolute left-1/2 -translate-x-1/2 md:static md:translate-x-0 md:justify-self-center";
110
+ const BrandWrap = brandAsH1 ? "h1" : Fragment;
111
+ const brandWrapProps = brandAsH1
112
+ ? { class: ["brand-h1", compact && brandCompactClass].filter(Boolean).join(" "), style: "margin:0;font:inherit" }
113
+ : {};
114
+
115
+ const ctaClass =(v?: string) =>
100
116
  v === "secondary" || v === "ghost"
101
117
  ? "inline-flex items-center rounded-full border border-current px-4 py-2 font-display text-sm font-medium transition-opacity hover:opacity-80"
102
118
  : "inline-flex items-center rounded-full bg-accent px-4 py-2 font-display text-sm font-medium text-accent-contrast transition-transform hover:-translate-y-0.5";
@@ -151,11 +167,12 @@ const currentPath = Astro.url.pathname;
151
167
  )
152
168
  }
153
169
  <!-- Logo / wordmark -->
170
+ <BrandWrap {...brandWrapProps}>
154
171
  <a
155
172
  href={homeHref}
156
173
  class:list={[
157
174
  "flex shrink-0 items-center transition-opacity hover:opacity-80",
158
- compact && "absolute left-1/2 -translate-x-1/2 md:static md:translate-x-0 md:justify-self-center",
175
+ !brandAsH1 && compact && brandCompactClass,
159
176
  ]}
160
177
  aria-label={logo?.alt ?? wordmark ?? "Home"}
161
178
  >
@@ -203,6 +220,7 @@ const currentPath = Astro.url.pathname;
203
220
  )
204
221
  }
205
222
  </a>
223
+ </BrandWrap>
206
224
 
207
225
  <!-- Search (live typeahead island) -->
208
226
  {
@@ -29,7 +29,14 @@ import { organizationLd, websiteLd } from "@/lib/content/shared/jsonld";
29
29
  import { composeStructuredData, structuredDataProfile, type StructuredDataPage } from "@/lib/content/seo/structuredData";
30
30
  import { canonicalUrl } from "@/lib/content/seo/urlForm";
31
31
  import { documentTitle } from "@/lib/content/seo/documentTitle";
32
- import { nonceInlineTags, prefixSharedChrome, CHROME_BODY_MARKER } from "@/lib/content/shared/rawMarketingHtml";
32
+ import {
33
+ nonceInlineTags,
34
+ prefixSharedChrome,
35
+ CHROME_BODY_MARKER,
36
+ demoteBrandH1,
37
+ hasContentH1,
38
+ isHomePath,
39
+ } from "@/lib/content/shared/rawMarketingHtml";
33
40
  import { withBase } from "@/lib/publication/shared/basePath";
34
41
  import { CHROME_BEHAVIOR_JS, CHROME_HEAD_JS, sanitizeSharedChromeFragment } from "@/lib/content/shared/rawChrome";
35
42
  import { ASYNC_CSS_SWAP_JS } from "@/lib/content/shared/asyncCss";
@@ -218,6 +225,16 @@ if (chromeHtml && chromeHtml.includes(CHROME_BODY_MARKER)) {
218
225
  };
219
226
  }
220
227
 
228
+ // One-h1 contract: on the home route the header brand is the page's <h1> unless the
229
+ // page content supplies its own (then the brand stays a plain link). Only home
230
+ // renders the slot to a string to inspect it.
231
+ const isHome = isHomePath(path);
232
+ const homeSlotHtml = isHome ? await Astro.slots.render("default") : null;
233
+ const brandIsH1 = isHome && !hasContentH1(homeSlotHtml ?? "");
234
+ if (sharedChrome && !brandIsH1) {
235
+ sharedChrome.header = demoteBrandH1(sharedChrome.header);
236
+ }
237
+
221
238
  const compliance = tenant.compliance;
222
239
  const sections = complianceSections({
223
240
  compliance,
@@ -395,6 +412,7 @@ const footerWidgets = await renderWidgetSlot(
395
412
  nav={chrome.header.nav}
396
413
  variant={headerDark ? "dark" : "light"}
397
414
  layout={resolvedHeaderVariant}
415
+ brandAsH1={brandIsH1}
398
416
  announcement={chrome.header.announcement?.messages[0]}
399
417
  searchAction={
400
418
  features.productSearch
@@ -415,7 +433,7 @@ const footerWidgets = await renderWidgetSlot(
415
433
  )}
416
434
 
417
435
  <main id="main">
418
- <slot />
436
+ {homeSlotHtml !== null ? <Fragment set:html={homeSlotHtml} /> : <slot />}
419
437
  </main>
420
438
 
421
439
  {sections.includes("pact-act") && (
@@ -1,7 +1,9 @@
1
1
  /**
2
- * Add-to-cart form for a catalog card. One default variant — the first
3
- * available one — signed the same way the product page signs its handoff.
4
- * No form when the product cannot be bought or the cart cannot be signed.
2
+ * Add-to-cart form for a catalog card. Only a product with a single variant
3
+ * buys from its card, signed the same way the product page signs its handoff;
4
+ * a product with options links to its product page ("Select options") so the
5
+ * card never adds a variant the shopper did not choose. No form when the
6
+ * product cannot be bought or the cart cannot be signed.
5
7
  */
6
8
  import type { CatalogProduct, CatalogVariant, ResolvedManagedCheckout, TenantConfig } from "@tot/public-runtime";
7
9
  import {
@@ -31,6 +33,11 @@ export function defaultCardVariant(product: CatalogProduct): CatalogVariant | nu
31
33
  return product.variants.find((variant) => variant.inventory.available) ?? null;
32
34
  }
33
35
 
36
+ /** A product with more than one variant needs the shopper's choice on its product page. */
37
+ export function cardNeedsOptionChoice(product: CatalogProduct): boolean {
38
+ return product.variants.length > 1;
39
+ }
40
+
34
41
  export async function buildCardCartForm(input: {
35
42
  product: CatalogProduct;
36
43
  tenant: TenantConfig;
@@ -40,6 +47,7 @@ export async function buildCardCartForm(input: {
40
47
  /** Candidate content pin this card rendered. Ignored on a live page. */
41
48
  contentVersionId?: string;
42
49
  }): Promise<CardCartForm | null> {
50
+ if (cardNeedsOptionChoice(input.product)) return null;
43
51
  const variant = defaultCardVariant(input.product);
44
52
  if (!variant) return null;
45
53
  const sampleHandoff = isLocalSampleCheckout(input.tenant.tenant_id);
@@ -36,6 +36,16 @@ export function cartSessionCookiePath(basePath: string): string {
36
36
  return `${withLead.replace(/\/+$/, "")}/`;
37
37
  }
38
38
 
39
+ /**
40
+ * True when a browser at `pathname` sends the cookie stored at `cookiePath`. A
41
+ * candidate preview page (`/preview/render/<tenant>/<change>/…`) is outside the
42
+ * tenant prefix, so the browser holds this store's session but does not send it there.
43
+ */
44
+ export function cartSessionCookieVisible(pathname: string, cookiePath: string): boolean {
45
+ if (cookiePath === "/") return true;
46
+ return pathname.startsWith(cookiePath) || `${pathname}/` === cookiePath;
47
+ }
48
+
39
49
  /** Cart URL that selects one Foxy session. Other params are kept. */
40
50
  export function withCartSession(cartUrl: string, id: string): string {
41
51
  const url = new URL(cartUrl);
@@ -47,12 +57,21 @@ function saneSessionId(value: unknown): value is string {
47
57
  return typeof value === "string" && CART_SESSION_ID.test(value);
48
58
  }
49
59
 
60
+ /**
61
+ * An option name as one key. FC.json spells names for display (measured 2026-10-23:
62
+ * `return_url` reads "return url"), and a signed name carries `||<hash>`, so names
63
+ * compare trimmed, lowercased, and with spaces and underscores as one `_`.
64
+ */
65
+ function optionKey(raw: string): string {
66
+ return (raw.split("||")[0] ?? "").trim().toLowerCase().replace(/[\s_]+/g, "_");
67
+ }
68
+
50
69
  function optionBaseName(option: unknown): string | null {
51
70
  if (!option || typeof option !== "object") return null;
52
71
  const raw = (option as { name?: unknown; class?: unknown }).name
53
72
  ?? (option as { class?: unknown }).class;
54
73
  if (typeof raw !== "string" || raw.length === 0) return null;
55
- return raw.split("||")[0] ?? "";
74
+ return optionKey(raw);
56
75
  }
57
76
 
58
77
  function optionValue(option: unknown): string | null {
@@ -72,7 +91,7 @@ function itemTenant(item: unknown): string | null {
72
91
  }
73
92
  if (options && typeof options === "object") {
74
93
  for (const [key, value] of Object.entries(options as Record<string, unknown>)) {
75
- if (key.split("||")[0] === "tenant" && typeof value === "string") return value;
94
+ if (optionKey(key) === "tenant" && typeof value === "string") return value;
76
95
  }
77
96
  }
78
97
  return null;
@@ -30,6 +30,15 @@ export interface SeamView {
30
30
  codes?: readonly string[];
31
31
  /** Flavor labels that triggered a `flavored_prohibited` block, for the refusal. */
32
32
  flavors?: readonly string[];
33
+ /** Every refused block with the compliance engine's own shopper-facing sentence. */
34
+ lines?: readonly RefusedLine[];
35
+ }
36
+
37
+ export interface RefusedLine {
38
+ code: string;
39
+ message: string;
40
+ sku?: string;
41
+ title?: string;
33
42
  }
34
43
 
35
44
  export interface FinalCartRequest {
@@ -17,10 +17,24 @@
17
17
  *
18
18
  * A cart-origin link click or form submit that beats the drawer is held, not
19
19
  * lost: it is cancelled, the loader is fetched, and the same control is activated
20
- * again once the drawer reports ready (or after a bounded wait), so it opens in
21
- * the drawer exactly as it would have. Holding runs in the window bubble phase,
22
- * after every page handler, so an action another handler already took over (the
23
- * compliance gate) is left alone.
20
+ * again once the drawer client reports `ready.done`, so it opens in the drawer
21
+ * exactly as it would have. Foxy binds its cart-link handler in `FC.client.init`
22
+ * and initialises the side cart on `ready.done`; before that a cart link is a
23
+ * plain navigation to the full cart page. So a held action waits for that signal,
24
+ * not a clock: it follows the link to the cart page only when the drawer cannot
25
+ * come up (the loader or bundle errors, no client appears, or nothing reports
26
+ * within LAZY_CHECKOUT_READY_TIMEOUT_MS). A held action never outlives the page
27
+ * that held it. Holding runs in the window bubble phase, after every page
28
+ * handler, so an action another handler already took over (the compliance gate,
29
+ * or the drawer itself) is left alone.
30
+ *
31
+ * Every cart action that reaches the window still unhandled once the drawer has
32
+ * settled is about to navigate to the cart page. That is recorded: a
33
+ * `[tot-cart]` console warning, a `tot:cart-fallthrough` window event, and
34
+ * `data-cart-fallthrough="<reason>[:<cause>]"` on the root element. The detail
35
+ * carries only the reason, the cause, elapsed ms, whether the action was held,
36
+ * whether `FC.client` existed, Foxy's own full-page and bypass decisions, the
37
+ * document ready state and viewport/screen/body widths — never a URL or session.
24
38
  *
25
39
  * Every cart open waits for this store's Foxy session. The id comes from the
26
40
  * same-origin session endpoint (an HttpOnly cookie the browser cannot read) and
@@ -43,8 +57,12 @@ import { CART_UNAVAILABLE_MESSAGE } from "@/lib/checkout/commerce/cartSession";
43
57
 
44
58
  export const LAZY_CHECKOUT_LOADER_IDLE_MS = 8000;
45
59
 
46
- /** Upper bound on waiting for the drawer to report ready before replaying a held action. */
47
- export const LAZY_CHECKOUT_READY_TIMEOUT_MS = 4000;
60
+ /**
61
+ * Upper bound on waiting for the drawer client to report ready before a held action
62
+ * follows its link to the full cart page. Long enough for a cold drawer bundle on a
63
+ * slow phone; reached only when the drawer is not coming up at all.
64
+ */
65
+ export const LAZY_CHECKOUT_READY_TIMEOUT_MS = 12000;
48
66
 
49
67
  export const LAZY_CHECKOUT_LOADER_EVENTS = ["pointerdown", "keydown", "touchstart"] as const;
50
68
 
@@ -81,7 +99,11 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
81
99
  var referrerPolicy = ${literal(CHECKOUT_NAVIGATION_REFERRER_POLICY)};
82
100
  var storeNotPublic = ${options.storeNotPublic ? "true" : "false"};
83
101
  var events = ${literal(LAZY_CHECKOUT_LOADER_EVENTS)};
102
+ // idle → loading → ready (the drawer client reported ready.done) | failed (it cannot come up).
84
103
  var state = 'idle';
104
+ var failure = null;
105
+ var armedAt = 0;
106
+ var replaying = null;
85
107
  var waiters = [];
86
108
  var sessionId = null;
87
109
  var sessionPromise = null;
@@ -143,7 +165,10 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
143
165
  document.addEventListener("visibilitychange", function () {
144
166
  if (document.visibilityState === "hidden") endCartNavigation();
145
167
  });
146
- window.addEventListener("pagehide", endCartNavigation);
168
+ window.addEventListener("pagehide", function () {
169
+ endCartNavigation();
170
+ dropHeld();
171
+ });
147
172
  window.addEventListener("pageshow", function (event) {
148
173
  if (event.persisted) endCartNavigation();
149
174
  });
@@ -201,20 +226,75 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
201
226
  });
202
227
  return sessionPromise;
203
228
  }
204
- function markSessionUnavailable() {
229
+ function release(held) {
230
+ if (held.el && held.el.removeAttribute) held.el.removeAttribute('aria-busy');
231
+ }
232
+ function dropHeld() {
233
+ var dropped = waiters;
205
234
  waiters = [];
235
+ for (var i = 0; i < dropped.length; i++) release(dropped[i]);
236
+ }
237
+ function markSessionUnavailable() {
238
+ dropHeld();
206
239
  state = 'idle';
207
240
  document.documentElement.setAttribute('data-cart-session', 'unavailable');
208
241
  for (var i = 0; i < events.length; i++) {
209
242
  document.addEventListener(events[i], onInput, { capture: true, passive: true });
210
243
  }
211
244
  }
245
+ function settled() {
246
+ return state === 'ready' || state === 'failed';
247
+ }
248
+ function runHeld() {
249
+ var run = waiters;
250
+ waiters = [];
251
+ for (var i = 0; i < run.length; i++) {
252
+ release(run[i]);
253
+ run[i].fn();
254
+ }
255
+ }
212
256
  function ready() {
213
257
  if (state === 'ready') return;
214
258
  state = 'ready';
215
- var run = waiters;
216
- waiters = [];
217
- for (var i = 0; i < run.length; i++) run[i]();
259
+ failure = null;
260
+ runHeld();
261
+ }
262
+ function fail(cause) {
263
+ if (state !== 'loading') return;
264
+ state = 'failed';
265
+ failure = cause;
266
+ runHeld();
267
+ }
268
+ // A cart action reaching the window unhandled after the drawer settled navigates
269
+ // to the full cart page. Record why, with no URL or session in the record.
270
+ function fallthrough(kind, el) {
271
+ var fc = window.FC || {};
272
+ var fullpage = null;
273
+ try {
274
+ if (fc.sidecart && typeof fc.sidecart.shouldUseFullpageCart === 'function') fullpage = !!fc.sidecart.shouldUseFullpageCart();
275
+ } catch (e) {}
276
+ var body = document.body;
277
+ function bypassed(node) {
278
+ return !!(node && node.classList && (node.classList.contains('fc-sidecart--bypass') || node.hasAttribute('data-fc-sidecart-bypass')));
279
+ }
280
+ var reason = state === 'ready' ? 'drawer-declined' : 'drawer-unavailable';
281
+ var detail = {
282
+ reason: reason,
283
+ cause: failure,
284
+ kind: kind,
285
+ held: replaying === el,
286
+ elapsedMs: armedAt ? Date.now() - armedAt : null,
287
+ fcClient: !!fc.client,
288
+ fullpage: fullpage,
289
+ bypass: bypassed(body) || bypassed(el),
290
+ docState: document.readyState,
291
+ viewport: window.innerWidth,
292
+ screenWidth: window.screen ? window.screen.availWidth : null,
293
+ bodyWidth: body ? body.clientWidth : null
294
+ };
295
+ document.documentElement.setAttribute('data-cart-fallthrough', reason + (failure ? ':' + failure : ''));
296
+ try { console.warn('[tot-cart] cart fell through to the cart page', detail); } catch (e) {}
297
+ try { window.dispatchEvent(new window.CustomEvent('tot:cart-fallthrough', { detail: detail })); } catch (e) {}
218
298
  }
219
299
  function armDrawer(id) {
220
300
  document.documentElement.removeAttribute('data-cart-session');
@@ -225,23 +305,25 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
225
305
  window.FC.settings.cookiepath = cookiePath;
226
306
  window.FC.settings.sitedomain = location.hostname;
227
307
  };
228
- setTimeout(ready, ${LAZY_CHECKOUT_READY_TIMEOUT_MS});
308
+ armedAt = Date.now();
309
+ setTimeout(function () { fail('ready-timeout'); }, ${LAZY_CHECKOUT_READY_TIMEOUT_MS});
229
310
  var s = document.createElement('script');
230
311
  s.src = src;
231
312
  s.async = true;
232
- // The loader appends the drawer bundle and exposes it as fc_script; the drawer is
233
- // ready once that bundle has run and its client reports ready.done.
313
+ // The loader appends the drawer bundle and exposes it as fc_script. The bundle's
314
+ // FC.client.init binds the cart-link handler and ready.done initialises the side
315
+ // cart, so ready.done is the only signal that a cart link will open the drawer.
234
316
  s.addEventListener('load', function () {
235
317
  var bundle = window.fc_script;
236
- if (!bundle) return ready();
318
+ if (!bundle) return fail('no-bundle');
237
319
  bundle.addEventListener('load', function () {
238
320
  var client = window.FC && window.FC.client;
239
- if (!client || typeof client.on !== 'function') return ready();
321
+ if (!client || typeof client.on !== 'function') return fail('no-client');
240
322
  client.on('ready.done', ready);
241
323
  });
242
- bundle.addEventListener('error', ready);
324
+ bundle.addEventListener('error', function () { fail('bundle-error'); });
243
325
  });
244
- s.addEventListener('error', ready);
326
+ s.addEventListener('error', function () { fail('loader-error'); });
245
327
  document.body.appendChild(s);
246
328
  }
247
329
  function start() {
@@ -253,11 +335,16 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
253
335
  armDrawer(id);
254
336
  });
255
337
  }
256
- function whenReady(fn) {
257
- if (state === 'ready') return fn();
258
- waiters.push(fn);
338
+ function whenReady(el, fn) {
339
+ if (settled()) return fn();
340
+ el.setAttribute('aria-busy', 'true');
341
+ waiters.push({ el: el, fn: fn });
259
342
  start();
260
343
  }
344
+ function replay(el, fn) {
345
+ replaying = el;
346
+ try { fn(); } finally { replaying = null; }
347
+ }
261
348
  function onInput() {
262
349
  if (commerceReachable()) start();
263
350
  }
@@ -292,16 +379,19 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
292
379
  var anchor = event.target && event.target.closest ? event.target.closest('a[href]') : null;
293
380
  if (!anchor || anchor.target === '_blank' || !targetsCart(anchor.href)) return;
294
381
  anchor.referrerPolicy = referrerPolicy;
295
- if (state === 'ready') {
382
+ if (settled()) {
296
383
  stampAnchor(anchor);
297
- if (!event.defaultPrevented) revealForNavigation();
384
+ if (!event.defaultPrevented) {
385
+ fallthrough('link', anchor);
386
+ revealForNavigation();
387
+ }
298
388
  return;
299
389
  }
300
390
  if (event.defaultPrevented) return;
301
391
  event.preventDefault();
302
- whenReady(function () {
392
+ whenReady(anchor, function () {
303
393
  stampAnchor(anchor);
304
- anchor.click();
394
+ replay(anchor, function () { anchor.click(); });
305
395
  });
306
396
  });
307
397
 
@@ -352,10 +442,11 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
352
442
  var cartForm = targetsCart(form.getAttribute('action'));
353
443
  if (!managed && !cartForm) return;
354
444
  if (form.dataset.cartSession === '1') {
355
- if (!cartForm || state === 'ready') {
356
- if (!event.defaultPrevented && state === 'ready') {
445
+ if (!cartForm || settled()) {
446
+ if (!event.defaultPrevented && settled()) {
357
447
  form.referrerPolicy = referrerPolicy;
358
448
  if (cartForm) {
449
+ fallthrough('form', form);
359
450
  form.setAttribute("referrerpolicy", referrerPolicy);
360
451
  loosenReferrer();
361
452
  revealForNavigation();
@@ -364,7 +455,7 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
364
455
  return;
365
456
  }
366
457
  event.preventDefault();
367
- whenReady(function () { stampForm(form); resubmit(form); });
458
+ whenReady(form, function () { stampForm(form); replay(form, function () { resubmit(form); }); });
368
459
  return;
369
460
  }
370
461
  event.preventDefault();
@@ -372,7 +463,7 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
372
463
  openSessionThen(form, function () {
373
464
  if (cartForm) {
374
465
  stampForm(form);
375
- whenReady(function () { resubmit(form); });
466
+ whenReady(form, function () { replay(form, function () { resubmit(form); }); });
376
467
  return;
377
468
  }
378
469
  resubmit(form);
@@ -24,7 +24,12 @@
24
24
  * stray quote/angle-bracket can't break out of an attribute or element; this is
25
25
  * defense-in-depth, not a sanitizer for untrusted input.
26
26
  */
27
- import { CHROME_BODY_MARKER, CHROME_HEAD_MARKER } from "./rawMarketingHtml.js";
27
+ import {
28
+ BRAND_H1_CLASS,
29
+ CHROME_BODY_MARKER,
30
+ CHROME_HEAD_MARKER,
31
+ isHomePath,
32
+ } from "./rawMarketingHtml.js";
28
33
  import { isCurrentNavHref } from "../chrome/navCurrent.js";
29
34
  import { ctaIdentityAttr } from "../chrome/ctaIdentity.js";
30
35
  import { CHROME_PLACEMENT } from "../chrome/trackingPlacements.js";
@@ -325,6 +330,14 @@ function renderActionLinks(links: ChromeHeaderActionLink[]): string {
325
330
  }
326
331
 
327
332
  function renderHeader(header: ChromeHeader, variant: HeaderVariant, currentPath?: string): string {
333
+ // One-h1 contract: on the home route (header only, never the footer) the brand
334
+ // renders inside <h1 class="brand-h1">. When the spliced page body supplies its
335
+ // own <h1>, wrapFragmentInChrome / Layout unwrap this one (demoteBrandH1), so
336
+ // every page carries exactly one h1.
337
+ const brandLink = renderBrand(header.brand);
338
+ const brandBlock = isHomePath(currentPath)
339
+ ? `<h1 class="${BRAND_H1_CLASS}" style="margin:0;font:inherit">${brandLink}</h1>`
340
+ : brandLink;
328
341
  const search = header.search ? renderSearchForm(header.search) : "";
329
342
  const memberCue = header.memberCue
330
343
  ? `<a class="member-cue" href="${esc(header.memberCue.href)}" aria-label="${esc(header.memberCue.label)}">${esc(header.memberCue.label)}</a>`
@@ -424,7 +437,7 @@ function renderHeader(header: ChromeHeader, variant: HeaderVariant, currentPath?
424
437
  ` <div class="container header-row">\n` +
425
438
  (menuLeft && hamburger ? ` ${hamburger}\n` : "") +
426
439
  (compact ? ` ${primaryNav}\n` : "") +
427
- ` ${renderBrand(header.brand)}\n` +
440
+ ` ${brandBlock}\n` +
428
441
  (!minimal && !compact && search ? ` ${search}\n` : "") +
429
442
  ` <div class="header-actions">${memberCue}${ctas}${compactSearch}${actionLinks}${account}${cart}${menuLeft ? "" : hamburger}</div>\n` +
430
443
  ` </div>\n` +
@@ -56,13 +56,39 @@ export function isFullDocument(html: string): boolean {
56
56
  return /^\s*<!doctype/i.test(html) || /<html[\s>]/i.test(html);
57
57
  }
58
58
 
59
+ /** Class of the home-route header brand heading (`<h1 class="brand-h1">`). */
60
+ export const BRAND_H1_CLASS = "brand-h1";
61
+
62
+ /** True for the home route ("/" with optional query/hash); false when no path is known. */
63
+ export function isHomePath(path: string | undefined): boolean {
64
+ return path !== undefined && (path.split(/[?#]/)[0] ?? "") === "/";
65
+ }
66
+
67
+ /** True when page content already supplies an `<h1>`. */
68
+ export function hasContentH1(html: string): boolean {
69
+ return /<h1[\s>]/i.test(html);
70
+ }
71
+
72
+ /**
73
+ * Unwrap the home brand heading (keeping the brand link) so a page whose own
74
+ * content supplies an `<h1>` ends up with exactly one h1.
75
+ */
76
+ export function demoteBrandH1(html: string): string {
77
+ return html.replace(
78
+ new RegExp(`<h1 class="${BRAND_H1_CLASS}"[^>]*>([\\s\\S]*?)</h1>`),
79
+ "$1",
80
+ );
81
+ }
82
+
59
83
  /**
60
84
  * Splice a body-only fragment into a tenant chrome wrapper at CHROME_BODY_MARKER.
85
+ * When the fragment carries its own `<h1>`, the wrapper's brand h1 is unwrapped.
61
86
  * If the wrapper has no marker (misconfigured), the fragment is returned unwrapped
62
87
  * so a page never disappears.
63
88
  */
64
89
  export function wrapFragmentInChrome(fragment: string, chromeWrapper: string): string {
65
90
  if (!chromeWrapper.includes(CHROME_BODY_MARKER)) return fragment;
91
+ if (hasContentH1(fragment)) chromeWrapper = demoteBrandH1(chromeWrapper);
66
92
  // A fragment may lead with a <head>…</head> of page-specific SEO tags. Split it
67
93
  // out so the shared wrapper keeps per-page title/meta/canonical/JSON-LD.
68
94
  let head = "";
@@ -96,6 +96,25 @@ export function buildQueryString(
96
96
  return s ? `?${s}` : "";
97
97
  }
98
98
 
99
+ /** Facet values shown before the nested "Show more" disclosure. */
100
+ export const FACET_VISIBLE_VALUES = 8;
101
+
102
+ /**
103
+ * Split a facet group's values into the always-visible head and the collapsed
104
+ * remainder: the first FACET_VISIBLE_VALUES, plus every selected value wherever
105
+ * it sits, so a selected value is never hidden in the remainder.
106
+ */
107
+ export function splitFacetValues<T extends { value: string }>(
108
+ values: readonly T[],
109
+ isSelected: (value: string) => boolean,
110
+ limit: number = FACET_VISIBLE_VALUES,
111
+ ): { shown: T[]; rest: T[] } {
112
+ const shown: T[] = [];
113
+ const rest: T[] = [];
114
+ values.forEach((fv, i) => (i < limit || isSelected(fv.value) ? shown : rest).push(fv));
115
+ return { shown, rest };
116
+ }
117
+
99
118
  /** Toggle a single facet value on/off, returning the new querystring (page reset). */
100
119
  export function toggleFacet(
101
120
  current: ListProductsQuery,
@@ -948,6 +948,9 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
948
948
  const key = pageKey;
949
949
  await timed(timing, "page-cache", () => putCachedPage(cache, key, response));
950
950
  }
951
+ // A preview-token (`test`) render is unpublished content shown to one viewer: no
952
+ // browser, proxy, or CDN may keep it or hand it to the next visitor of this URL.
953
+ if (customization.env === "test") response.headers.set("Cache-Control", "private, no-store");
951
954
  // MATERIALIZE-ONLY capture (ADR 0013): expose the tenant's resolved route-document
952
955
  // Cache-Control so the publish/materialize plane can bake it into the bundle
953
956
  // manifest (the same capture pattern as CSP). Gated on the materialize probe
@@ -2,13 +2,13 @@
2
2
  import type { APIContext } from "astro";
3
3
  import { createLocalJWKSet, decodeProtectedHeader, jwtVerify, type JSONWebKeySet } from "jose";
4
4
  import {
5
- commerceTenantId, totalsMatch, shippingMinorFor, isContentVersionId, catalogPinDigestFinding,
6
- type CallerToken, type CatalogProduct, type CommerceEvidence, type CommerceEvidenceProvenance,
5
+ commerceTenantId, totalsMatch, shippingMinorFor, isContentVersionId, catalogPinDigestFinding, cartLineCode,
6
+ type CallerToken, type CatalogProduct, type CatalogVariant, type CommerceEvidence, type CommerceEvidenceProvenance,
7
7
  type CommerceProviderCartSnapshot, type FinalCartBinding, type TenantConfig,
8
8
  } from "@tot/public-runtime";
9
9
  import { fromD1 } from "@/lib/catalog/d1/catalog.js";
10
10
  import { readD1, readEnv, readKv } from "@/lib/publication/shared/env";
11
- import { evaluateFinalCart, type SeamView } from "@/lib/checkout/commerce/finalCartAuthorization.js";
11
+ import { evaluateFinalCart, type RefusedLine, type SeamView } from "@/lib/checkout/commerce/finalCartAuthorization.js";
12
12
  import { d1CommerceRunner, reserveAllocation } from "@/lib/inventory/allocations.js";
13
13
  import { readMirrorBalance, readMirrorStock } from "@/lib/inventory/mirrorStock.js";
14
14
  import { admissionMatches, readCommerceAdmission, admissionSnapshotStatement } from "@/lib/checkout/commerce/commerceAdmission.js";
@@ -30,6 +30,17 @@ function json(body: unknown, status = 200): Response {
30
30
  return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" } });
31
31
  }
32
32
  function deny(finding: string, status = 409, owner?: string): Response { return json({ ok: false, finding, ...(owner ? { owner } : {}) }, status); }
33
+ /**
34
+ * Which check refused, and on which cart line (its index in the presented snapshot). Logged on
35
+ * the decision line only: closed check names and an index, never a SKU, price or cart value.
36
+ */
37
+ interface RefusalDetail { check: string; line?: number }
38
+ const refusalDetails = new WeakMap<Response, RefusalDetail>();
39
+ function refuse(finding: string, detail: RefusalDetail, status = 409): Response {
40
+ const response = deny(finding, status);
41
+ refusalDetails.set(response, detail);
42
+ return response;
43
+ }
33
44
  function list(value: string | undefined): string[] | null {
34
45
  if (!value) return null;
35
46
  try {
@@ -85,7 +96,11 @@ async function seam(context: APIContext, snapshot: CommerceProviderCartSnapshot,
85
96
  const { decision } = await evaluatePreflight({ locals: { tenant: context.locals.tenant, tot: {
86
97
  getProductByHandle: async (handle: string) => selected.get(handle) ?? null,
87
98
  } } } as APIContext, {
88
- lines: snapshot.lines.map(line => ({ handle: products.find(p => p.variants.some(v => v.sku === line.sku))!.handle, sku: line.sku, quantity: line.quantity })),
99
+ lines: snapshot.lines.map(line => {
100
+ const product = products.find(p => p.variants.some(v => cartLineCode(v) === line.sku))!;
101
+ const variant = product.variants.find(v => cartLineCode(v) === line.sku)!;
102
+ return { handle: product.handle, variantId: variant.id, sku: line.sku, quantity: line.quantity };
103
+ }),
89
104
  destination: snapshot.destination,
90
105
  ...(policy.verification === "required" && snapshot.verificationRef ? { verificationRef: snapshot.verificationRef } : {}),
91
106
  }, { verification: policy.verification, rules: policy.rules });
@@ -93,8 +108,19 @@ async function seam(context: APIContext, snapshot: CommerceProviderCartSnapshot,
93
108
  const blocks = decision.blocks;
94
109
  if (blocks.length === 0) return { status: "clear", verified };
95
110
  const flavors = [...new Set(blocks.flatMap(block => block.code === "flavored_prohibited" ? block.flavors ?? [] : []))];
111
+ const titleOf = (sku: string) => products.find(p => p.variants.some(v => v.sku === sku))?.title;
112
+ const seen = new Set<string>();
113
+ const lines: RefusedLine[] = [];
114
+ for (const block of blocks) {
115
+ if (block.failedClosed === true || typeof block.message !== "string" || !block.message) continue;
116
+ const key = `${block.code}\n${block.sku ?? ""}\n${block.message}`;
117
+ if (seen.has(key)) continue;
118
+ seen.add(key);
119
+ const title = block.sku ? titleOf(block.sku) : undefined;
120
+ lines.push({ code: block.code, message: block.message, ...(block.sku ? { sku: block.sku } : {}), ...(title ? { title } : {}) });
121
+ }
96
122
  return { status: "blocked", verified, failedClosed: blocks.some(block => block.failedClosed === true), codes: blocks.map(block => block.code),
97
- ...(flavors.length ? { flavors } : {}) };
123
+ ...(flavors.length ? { flavors } : {}), ...(lines.length ? { lines } : {}) };
98
124
  } catch { return { status: "absent", verified: false }; }
99
125
  }
100
126
  function fullCartProduct(value: unknown, handle: string, scope: string): value is CatalogProduct {
@@ -132,8 +158,11 @@ export async function POST(context: APIContext): Promise<Response> {
132
158
  if (response.status !== 200 || threw) {
133
159
  try { finding = String((await response.clone().json() as { finding?: unknown }).finding ?? "unreadable").slice(0, 80); } catch { finding = "unreadable"; }
134
160
  }
161
+ const detail = refusalDetails.get(response);
135
162
  try {
136
- console.info("commerce-final-cart-decision", JSON.stringify({ correlationId, ok: response.status === 200, finding, status: response.status, threw, ms: Date.now() - started }));
163
+ console.info("commerce-final-cart-decision", JSON.stringify({ correlationId, ok: response.status === 200, finding,
164
+ ...(detail ? { check: detail.check, ...(detail.line === undefined ? {} : { line: detail.line }) } : {}),
165
+ status: response.status, threw, ms: Date.now() - started }));
137
166
  } catch { /* Diagnostic delivery cannot alter the decision. */ }
138
167
  return response;
139
168
  }
@@ -189,31 +218,39 @@ async function authorize(context: APIContext): Promise<Response> {
189
218
  try { listing = await tot.getAllActiveProducts(); } catch { return deny("catalog-unavailable", 503); }
190
219
  if (!Array.isArray(listing)) return deny("catalog-unavailable", 503);
191
220
  const selected = new Map<string, CatalogProduct>();
192
- if (snapshot.currency !== resolveStoreCurrency(tenant.currency)) return deny("catalog-cart-mismatch");
193
- for (const line of snapshot.lines) {
221
+ const mismatch = (check: string, line?: number) => refuse("catalog-cart-mismatch", line === undefined ? { check } : { check, line });
222
+ if (snapshot.currency !== resolveStoreCurrency(tenant.currency)) return mismatch("store-currency");
223
+ // A line's `sku` is the product code the page signed (`cartLineCode`): the SKU, or the
224
+ // variant id for a variant with none.
225
+ const coded = (line: { sku: string }) => (variant: CatalogVariant | null | undefined) => !!variant && cartLineCode(variant) === line.sku;
226
+ for (const [index, line] of snapshot.lines.entries()) {
194
227
  const matches = listing.flatMap(product => product && product.status === "active" && (!product.visibility || product.visibility === "public") && product.tenant_id === tenant.tot_data_scope && Array.isArray(product.variants)
195
- ? product.variants.filter(v => v && v.sku === line.sku).map(variant => ({ product, variant })) : []);
196
- if (matches.length !== 1) return deny("catalog-cart-mismatch");
228
+ ? product.variants.filter(coded(line)).map(variant => ({ product, variant })) : []);
229
+ if (matches.length !== 1) return mismatch(matches.length === 0 ? "sku-not-in-catalog" : "sku-ambiguous", index);
197
230
  const match = matches[0]!;
198
231
  const handle = match.product.handle;
199
- if (typeof handle !== "string" || !handle) return deny("catalog-cart-mismatch");
232
+ if (typeof handle !== "string" || !handle) return mismatch("product-handle", index);
200
233
  if (!selected.has(handle)) {
201
234
  let detail: CatalogProduct | null;
202
235
  try { detail = await tot.getProductByHandle(handle); } catch { return deny("catalog-unavailable", 503); }
203
- if (!fullCartProduct(detail, handle, tenant.tot_data_scope)) return deny("catalog-cart-mismatch");
236
+ if (!fullCartProduct(detail, handle, tenant.tot_data_scope)) return mismatch("product-detail", index);
204
237
  selected.set(handle, detail);
205
238
  }
206
239
  const detail = selected.get(handle)!;
207
- if (detail.id !== match.product.id) return deny("catalog-cart-mismatch");
208
- const variants = detail.variants.filter(variant => variant.sku === line.sku);
209
- if (variants.length !== 1 || variants[0]!.id !== match.variant.id) return deny("catalog-cart-mismatch");
240
+ if (detail.id !== match.product.id) return mismatch("product-id", index);
241
+ const variants = detail.variants.filter(coded(line));
242
+ if (variants.length !== 1 || variants[0]!.id !== match.variant.id) return mismatch("variant-detail", index);
210
243
  const variant = variants[0]!;
211
244
  const digits = new Intl.NumberFormat("en", { style: "currency", currency: snapshot.currency }).resolvedOptions().maximumFractionDigits ?? 2;
212
245
  const scaled = variant.price * 10 ** digits;
213
- if (!variant.inventory.available || variant.currency !== snapshot.currency || Math.abs(scaled - Math.round(scaled)) > 0.000001 || Math.round(scaled) !== line.unitMinor) return deny("catalog-cart-mismatch");
246
+ if (!variant.inventory.available) return mismatch("unavailable", index);
247
+ if (variant.currency !== snapshot.currency) return mismatch("variant-currency", index);
248
+ if (Math.abs(scaled - Math.round(scaled)) > 0.000001) return mismatch("price-precision", index);
249
+ if (Math.round(scaled) !== line.unitMinor) return mismatch("price", index);
214
250
  }
215
251
  const products = [...selected.values()];
216
- if (snapshot.lines.some(line => products.flatMap(product => product.variants.filter(variant => variant.sku === line.sku)).length !== 1)) return deny("catalog-cart-mismatch");
252
+ const ambiguous = snapshot.lines.findIndex(line => products.flatMap(product => product.variants.filter(coded(line))).length !== 1);
253
+ if (ambiguous !== -1) return mismatch("sku-ambiguous", ambiguous);
217
254
  // Shipping is re-derived from the store's own declaration, never from the cart: a cart that
218
255
  // dropped, replayed or altered its signed `shipping_policy` option was charged another amount.
219
256
  if (tenant.shipping) {
@@ -280,8 +317,10 @@ async function authorize(context: APIContext): Promise<Response> {
280
317
  seam: seamView, catalogAvailable: true, browserConfirmed: false, token,
281
318
  trustedIssuers: issuers, expectedAudience: audience, activeKids, stockTracked, authorities });
282
319
  if (!decision.ok) {
283
- if (decision.finding === "verification-refused:flavored_prohibited" && seamView.flavors) {
284
- return json({ ok: false, finding: decision.finding, flavors: seamView.flavors }, 409);
320
+ if (decision.finding.startsWith("verification-refused") && (seamView.flavors || seamView.lines)) {
321
+ return json({ ok: false, finding: decision.finding,
322
+ ...(decision.finding === "verification-refused:flavored_prohibited" && seamView.flavors ? { flavors: seamView.flavors } : {}),
323
+ ...(seamView.lines ? { lines: seamView.lines } : {}) }, 409);
285
324
  }
286
325
  return deny(decision.finding, 409, decision.owner);
287
326
  }
@@ -1,28 +1,50 @@
1
1
  import type { APIRoute } from "astro";
2
2
  import { commerceTenantId } from "@tot/public-runtime";
3
- import { bindCartSession, CART_UNAVAILABLE_MESSAGE, withCartSession } from "@/lib/checkout/commerce/cartSession.js";
3
+ import {
4
+ bindCartSession,
5
+ CART_UNAVAILABLE_MESSAGE,
6
+ cartSessionCookiePath,
7
+ cartSessionCookieVisible,
8
+ withCartSession,
9
+ } from "@/lib/checkout/commerce/cartSession.js";
4
10
  import { managedCartHref } from "@/lib/content/chrome/managedCart.js";
5
11
 
6
12
  export const prerender = false;
7
13
 
14
+ const NO_STORE = "private, no-store";
15
+
8
16
  /**
9
- * Backward-compatible entry for bookmarked or tenant-authored `/cart` links.
10
- * Chrome now links straight to managed checkout, but this route prevents stale
11
- * links from falling through the content catch-all to a misleading storefront
12
- * 404. Tenant resolution and preview authorization have already run in
13
- * middleware before this redirect is selected. The redirect carries this
14
- * tenant's Foxy session and never the shared cart.
17
+ * Entry for bookmarked or tenant-authored `/cart` links. Chrome links straight to
18
+ * managed checkout, but this route keeps those links from falling through the
19
+ * content catch-all to a misleading storefront 404. Tenant resolution and preview
20
+ * authorization have already run in middleware before this redirect is selected.
21
+ * The redirect carries this tenant's Foxy session and never the shared cart.
22
+ *
23
+ * The session cookie lives on the tenant prefix. A link on a pinned preview page
24
+ * arrives from outside it, without the cookie, so it is sent to the tenant prefix
25
+ * first: binding there reads the shopper's own session instead of minting an empty
26
+ * one over it.
15
27
  */
16
28
  export const GET: APIRoute = async (context) => {
17
29
  const location = managedCartHref(context.locals.commerce);
18
30
  if (!location || !context.locals.commerce) return new Response(null, { status: 404 });
31
+ // `basePath` is the prefix the browser is on: a pinned render overrides it to the
32
+ // pin's own prefix, while `apiBasePath` stays the tenant prefix the cookie lives on.
33
+ const cookiePath = cartSessionCookiePath(context.locals.apiBasePath ?? context.locals.basePath ?? "");
34
+ const pagePrefix = cartSessionCookiePath(context.locals.basePath ?? context.locals.apiBasePath ?? "");
35
+ if (!cartSessionCookieVisible(pagePrefix, cookiePath)) {
36
+ return new Response(null, {
37
+ status: 302,
38
+ headers: { location: `${cookiePath}cart`, "cache-control": NO_STORE },
39
+ });
40
+ }
19
41
  const tenantId = context.locals.tenant ? commerceTenantId(context.locals.tenant) : "";
20
42
  const sessionId = tenantId ? await bindCartSession(context, context.locals.commerce, tenantId) : null;
21
43
  if (!sessionId) {
22
44
  return new Response(CART_UNAVAILABLE_MESSAGE, {
23
45
  status: 503,
24
46
  headers: {
25
- "cache-control": "private, no-store",
47
+ "cache-control": NO_STORE,
26
48
  "content-type": "text/plain; charset=utf-8",
27
49
  },
28
50
  });
@@ -31,7 +53,7 @@ export const GET: APIRoute = async (context) => {
31
53
  status: 302,
32
54
  headers: {
33
55
  location: withCartSession(location, sessionId),
34
- "cache-control": "private, no-store",
56
+ "cache-control": NO_STORE,
35
57
  },
36
58
  });
37
59
  };
@@ -158,16 +158,26 @@ export async function GET(context: APIContext): Promise<Response> {
158
158
  const previewVersionId = pinnedVersionId ?? sharedPreview.versionId;
159
159
 
160
160
  const pathname = url.pathname;
161
+ const publicationChannel =
162
+ sharedPreview.preview || context.locals.customizationEnv === "test" ? "preview" : "live";
161
163
  const fromPublication = await tryServePublicationRootAsset({
162
164
  tenant: context.locals.tenant,
163
165
  bucket: bucket as unknown as PublicationArtifactReadBucketV1,
164
166
  kvGet: (key) => kv.get(key),
165
167
  tenantId: urlTenant,
166
168
  pathname,
167
- channel: sharedPreview.preview || context.locals.customizationEnv === "test" ? "preview" : "live",
169
+ channel: publicationChannel,
168
170
  ifNoneMatch: context.request.headers.get("if-none-match"),
169
171
  });
170
- if (fromPublication) return fromPublication;
172
+ if (fromPublication) {
173
+ // Preview bytes answer a tokenless URL (the credential rides in a cookie), so
174
+ // they must never be cached where the next visitor of that URL would get them.
175
+ if (publicationChannel === "preview") {
176
+ fromPublication.headers.set("cache-control", "no-store");
177
+ fromPublication.headers.set("x-robots-tag", "noindex, nofollow");
178
+ }
179
+ return fromPublication;
180
+ }
171
181
 
172
182
  if (previewVersionId) {
173
183
  const overlay = await tryServePreviewRewriteOverlay({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "4.3.4",
3
+ "version": "4.5.0",
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",
@@ -782,6 +782,16 @@ export const CONSPICUITY_FLOOR = Object.freeze({ contrast: 7, fontSizePx: 12, fo
782
782
  /** Bounds on the size a custom style may declare, so the bar cannot swallow the header. */
783
783
  const MAX_FONT_SIZE_PX = 24;
784
784
 
785
+ /** Bounds on the optional box and spacing keys; an unset key renders the preset's own frame. */
786
+ export const NICOTINE_WARNING_BOX_BOUNDS = Object.freeze({
787
+ borderWidthPx: Object.freeze({ min: 0, max: 8, integer: true }),
788
+ paddingYPx: Object.freeze({ min: 0, max: 24, integer: true }),
789
+ letterSpacingPx: Object.freeze({ min: 0, max: 4, integer: false }),
790
+ });
791
+
792
+ /** Weight 500 is allowed only on a boxed bar that is at least this large, contrasty and bordered. */
793
+ export const MEDIUM_WEIGHT_REQUIREMENTS = Object.freeze({ fontSizePx: 16, contrast: 7, borderWidthPx: 2 });
794
+
785
795
  export const DEFAULT_NICOTINE_WARNING_PRESET = "dark";
786
796
 
787
797
  /**
@@ -816,6 +826,9 @@ export const NICOTINE_WARNING_PRESETS = Object.freeze({
816
826
  * textTransform: "uppercase" | "none",
817
827
  * fontWeight: number,
818
828
  * fontSizePx: number,
829
+ * borderWidthPx?: number,
830
+ * paddingYPx?: number,
831
+ * letterSpacingPx?: number,
819
832
  * }} NicotineWarningPresentation
820
833
  */
821
834
 
@@ -824,9 +837,10 @@ export const NICOTINE_WARNING_KEYS = Object.freeze(["preset", "style", "checkWid
824
837
 
825
838
  export const NICOTINE_WARNING_STYLE_KEYS = Object.freeze([
826
839
  "background", "text", "border", "textTransform", "fontWeight", "fontSizePx",
840
+ "borderWidthPx", "paddingYPx", "letterSpacingPx",
827
841
  ]);
828
842
  const TEXT_TRANSFORMS = ["uppercase", "none"];
829
- const FONT_WEIGHTS = [600, 700, 800, 900];
843
+ const FONT_WEIGHTS = [500, 600, 700, 800, 900];
830
844
  const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
831
845
 
832
846
  /** `#abc` / `#AABBCC` → `#aabbcc`. */
@@ -865,7 +879,15 @@ export function conspicuityProblems(p) {
865
879
  out.push(`font size ${p.fontSizePx}px is below the ${CONSPICUITY_FLOOR.fontSizePx}px floor`);
866
880
  }
867
881
  if (p.fontWeight < CONSPICUITY_FLOOR.fontWeight) {
868
- out.push(`font weight ${p.fontWeight} is below the ${CONSPICUITY_FLOOR.fontWeight} floor`);
882
+ const need = MEDIUM_WEIGHT_REQUIREMENTS;
883
+ const medium = p.fontWeight === 500 && p.fontSizePx >= need.fontSizePx && ratio >= need.contrast
884
+ && (p.borderWidthPx ?? 0) >= need.borderWidthPx;
885
+ if (!medium) {
886
+ out.push(`font weight ${p.fontWeight} is below the ${CONSPICUITY_FLOOR.fontWeight} floor` +
887
+ (p.fontWeight === 500
888
+ ? ` (500 needs font size >= ${need.fontSizePx}px, contrast >= ${need.contrast}:1 and borderWidthPx >= ${need.borderWidthPx})`
889
+ : ""));
890
+ }
869
891
  }
870
892
  return out;
871
893
  }
@@ -885,6 +907,14 @@ function styleValueProblem(key, value) {
885
907
  return typeof value === "number" && Number.isFinite(value) && value >= CONSPICUITY_FLOOR.fontSizePx && value <= MAX_FONT_SIZE_PX
886
908
  ? null
887
909
  : `\`fontSizePx\` must be a number from ${CONSPICUITY_FLOOR.fontSizePx} to ${MAX_FONT_SIZE_PX}`;
910
+ case "borderWidthPx":
911
+ case "paddingYPx":
912
+ case "letterSpacingPx": {
913
+ const { min, max, integer } = NICOTINE_WARNING_BOX_BOUNDS[key];
914
+ return typeof value === "number" && Number.isFinite(value) && value >= min && value <= max && (!integer || Number.isInteger(value))
915
+ ? null
916
+ : `\`${key}\` must be ${integer ? "a whole number" : "a number"} from ${min} to ${max}`;
917
+ }
888
918
  default:
889
919
  return `\`${key}\` is not a nicotine-warning style key (allowed: ${NICOTINE_WARNING_STYLE_KEYS.join(", ")})`;
890
920
  }
@@ -993,7 +1023,10 @@ export function nicotineWarningCheckWidths(declaration) {
993
1023
  */
994
1024
  export function describeNicotineWarningPresentation(p) {
995
1025
  return `background ${p.background}, text ${p.text}, border ${p.border}, ` +
996
- `${p.textTransform === "uppercase" ? "uppercase" : "normal case"}, weight ${p.fontWeight}, ${p.fontSizePx}px`;
1026
+ `${p.textTransform === "uppercase" ? "uppercase" : "normal case"}, weight ${p.fontWeight}, ${p.fontSizePx}px` +
1027
+ (p.borderWidthPx === undefined ? "" : `, border width ${p.borderWidthPx}px`) +
1028
+ (p.paddingYPx === undefined ? "" : `, vertical padding ${p.paddingYPx}px`) +
1029
+ (p.letterSpacingPx === undefined ? "" : `, letter spacing ${p.letterSpacingPx}px`);
997
1030
  }
998
1031
 
999
1032
  /**
@@ -1017,11 +1050,12 @@ export function describeNicotineWarningPresentation(p) {
1017
1050
  */
1018
1051
  export function nicotineWarningHtml(declaration, variant) {
1019
1052
  const { presentation, preset } = resolveNicotineWarning(declaration);
1053
+ const barPadding = presentation.paddingYPx === undefined ? "0.5rem" : `${presentation.paddingYPx}px`;
1020
1054
  const attrs = `style="${nicotineWarningStyle(presentation, variant)}" role="note" aria-label="Health warning" ` +
1021
1055
  `data-compliance="nicotine-warning" data-nicotine-warning-preset="${preset ?? "custom"}"`;
1022
1056
  const text = NICOTINE_WARNING_TEXT_LOCK;
1023
1057
  return variant === "bar"
1024
- ? `<div ${attrs}><p class="container-prose" style="margin-block:0;padding-block:0.5rem;text-align:center;${text}">${NICOTINE_WARNING_TEXT}</p></div>`
1058
+ ? `<div ${attrs}><p class="container-prose" style="margin-block:0;padding-block:${barPadding};text-align:center;${text}">${NICOTINE_WARNING_TEXT}</p></div>`
1025
1059
  : `<div ${attrs}><p style="margin:0;${text}">${NICOTINE_WARNING_TEXT}</p></div>`;
1026
1060
  }
1027
1061
 
@@ -1061,15 +1095,17 @@ const NICOTINE_WARNING_TEXT_LOCK = [
1061
1095
  */
1062
1096
  export function nicotineWarningStyle(p, variant) {
1063
1097
  const size = variant === "block" ? Math.max(p.fontSizePx, 14) : p.fontSizePx;
1098
+ const boxed = `${p.borderWidthPx ?? 2}px`;
1064
1099
  const frame = variant === "bar"
1065
- ? [`border-bottom:2px solid ${p.border}`]
1066
- : [`border:2px solid ${p.border}`, "border-radius:var(--radius-md)", "padding:0.75rem 1rem"];
1100
+ ? [p.borderWidthPx === undefined ? `border-bottom:2px solid ${p.border}` : `border:${boxed} solid ${p.border}`]
1101
+ : [`border:${boxed} solid ${p.border}`, "border-radius:var(--radius-md)",
1102
+ `padding:${p.paddingYPx === undefined ? "0.75rem" : `${p.paddingYPx}px`} 1rem`];
1067
1103
  return [
1068
1104
  ...frame,
1069
1105
  `background-color:${p.background}`,
1070
1106
  `color:${p.text}`,
1071
1107
  `text-transform:${p.textTransform}`,
1072
- `letter-spacing:${p.textTransform === "uppercase" ? "0.06em" : "normal"}`,
1108
+ `letter-spacing:${p.letterSpacingPx !== undefined ? `${p.letterSpacingPx}px` : p.textTransform === "uppercase" ? "0.06em" : "normal"}`,
1073
1109
  `font-weight:${p.fontWeight}`,
1074
1110
  `font-size:${size}px`,
1075
1111
  "line-height:1.4",
@@ -10,6 +10,7 @@
10
10
  "exports": {
11
11
  ".": "./src/index.ts",
12
12
  "./blog-visibility": "./src/blog-visibility.mjs",
13
+ "./preview-url": "./src/preview-url.mjs",
13
14
  "./publication-redirects": "./src/publication-redirects.mjs",
14
15
  "./image-dependencies": "./src/image-dependencies.ts",
15
16
  "./image-finding-dependencies": "./src/image-finding-dependencies.ts",
@@ -500,6 +500,14 @@ export function resolveManagedCheckout(
500
500
  };
501
501
  }
502
502
 
503
+ /**
504
+ * The checkout product code for a variant: its SKU, or its id when it has none. Every field
505
+ * is hashed against it, and the final-cart authority finds a cart line's variant by it.
506
+ */
507
+ export function cartLineCode(variant: Pick<CatalogVariant, "id" | "sku">): string {
508
+ return variant.sku && variant.sku.length ? variant.sku : variant.id;
509
+ }
510
+
503
511
  /**
504
512
  * The raw (unsigned) cart fields for a variant: name, price, code, image, the
505
513
  * variant's options, and an editable quantity. `code` is the checkout product code
@@ -511,7 +519,7 @@ export function cartFieldsForVariant(
511
519
  purchaseOption?: ProductPurchaseOption,
512
520
  opts?: { signedPrice?: number; tenantId?: string; currency?: string; contentVersionId?: string },
513
521
  ): { code: string; fields: CartField[] } {
514
- const code = variant.sku && variant.sku.length ? variant.sku : variant.id;
522
+ const code = cartLineCode(variant);
515
523
  const image = product.images.find((i) => i.id === variant.image_id)?.url ??
516
524
  product.images[0]?.url;
517
525
  // The signed price is either the caller's member-adjusted EFFECTIVE price
@@ -0,0 +1,10 @@
1
+ export const PREVIEW_URL_SHAPE: string;
2
+ export function previewPath(store: string, subPath?: string): string;
3
+ export function previewUrl(origin: string, store: string): string;
4
+ export function previewPrUrl(origin: string, store: string, prNumber: number | string): string | null;
5
+ export function previewRevUrl(origin: string, store: string, versionId: string): string;
6
+ export function legacyPreviewLocation(
7
+ pathname: string,
8
+ search: string | undefined,
9
+ isStore: (segment: string) => boolean,
10
+ ): string | null;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The one builder of preview URLs. A store's preview lives under the store, never under a
3
+ * `/preview/<store>` prefix:
4
+ *
5
+ * <origin>/<store>/preview the store's current shared preview
6
+ * <origin>/<store>/preview/pr/<n> candidate PR n
7
+ * <origin>/<store>/preview/rev/<sha>/ an immutable revision
8
+ *
9
+ * Every producer (Worker, CLI) calls these; `apps/storefront/src/pages/preview/preview-url-routes.test.ts`
10
+ * resolves each shape against the page route table. `packages/cli/src/preview-url.mjs` is a
11
+ * byte-identical copy (a CLI test pins it).
12
+ */
13
+
14
+ /** The canonical shapes, for messages that name the right URL. */
15
+ export const PREVIEW_URL_SHAPE = "/<store>/preview[/pr/<n>]";
16
+
17
+ function trimOrigin(origin) {
18
+ return String(origin).trim().replace(/\/+$/, "");
19
+ }
20
+
21
+ /** `/<store>/preview` with an optional sub-path (`/pr/5`). */
22
+ export function previewPath(store, subPath = "") {
23
+ return `/${encodeURIComponent(store)}/preview${subPath}`;
24
+ }
25
+
26
+ export function previewUrl(origin, store) {
27
+ return `${trimOrigin(origin)}${previewPath(store)}`;
28
+ }
29
+
30
+ /** `null` when `prNumber` is not a positive integer. */
31
+ export function previewPrUrl(origin, store, prNumber) {
32
+ const n = Number(prNumber);
33
+ if (!Number.isInteger(n) || n < 1) return null;
34
+ return `${trimOrigin(origin)}${previewPath(store, `/pr/${n}`)}`;
35
+ }
36
+
37
+ export function previewRevUrl(origin, store, versionId) {
38
+ return `${trimOrigin(origin)}${previewPath(store, `/rev/${encodeURIComponent(versionId)}/`)}`;
39
+ }
40
+
41
+ /**
42
+ * The canonical location for a legacy `/preview/<store>[/pr/<n>[/...]]` request, or `null` when
43
+ * the path is not that shape. `isStore` decides whether the first segment names a store
44
+ * (the caller owns what a store id looks like), so `/preview/pr/5`, `/preview/rev/...` and the
45
+ * other first-segment routes are never read as a store.
46
+ */
47
+ export function legacyPreviewLocation(pathname, search, isStore) {
48
+ const m = /^\/preview\/([^/]+)(\/.*)?$/.exec(pathname);
49
+ if (!m) return null;
50
+ const store = m[1];
51
+ if (!/^[A-Za-z0-9.-]+$/.test(store) || !isStore(store)) return null;
52
+ const rest = (m[2] ?? "").replace(/\/+$/, "");
53
+ return `/${store}/preview${rest}${search ?? ""}`;
54
+ }
@@ -296,6 +296,9 @@ export interface NicotineWarningDeclaration {
296
296
  textTransform?: "uppercase" | "none";
297
297
  fontWeight?: number;
298
298
  fontSizePx?: number;
299
+ borderWidthPx?: number;
300
+ paddingYPx?: number;
301
+ letterSpacingPx?: number;
299
302
  };
300
303
  }
301
304