@tokenoftrust/storefront-runner 2.5.6 → 3.0.1

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 (79) hide show
  1. package/agency-kit/tools/lib/public-binaries.mjs +111 -0
  2. package/apps/storefront/astro.config.mjs +7 -0
  3. package/apps/storefront/env.d.ts +3 -0
  4. package/apps/storefront/src/components/catalog/storefront/ProductCard.astro +5 -4
  5. package/apps/storefront/src/components/catalog/storefront/commerce/PriceDisplay.astro +2 -1
  6. package/apps/storefront/src/components/catalog/storefront/islands/SearchTypeahead.tsx +17 -20
  7. package/apps/storefront/src/components/catalog/storefront/islands/VariantSelector.tsx +13 -21
  8. package/apps/storefront/src/components/catalog/storefront/plp/Pagination.astro +9 -5
  9. package/apps/storefront/src/components/checkout/storefront/commerce/CheckoutLoader.astro +18 -6
  10. package/apps/storefront/src/components/compliance/storefront/islands/CheckoutComplianceGate.tsx +19 -4
  11. package/apps/storefront/src/components/content/storefront/Badge.astro +1 -0
  12. package/apps/storefront/src/components/content/storefront/blog/BlogIndexView.astro +10 -1
  13. package/apps/storefront/src/components/publication/storefront/islands/CatalogQueryView.tsx +321 -0
  14. package/apps/storefront/src/config/declaredTenantConfig.ts +59 -27
  15. package/apps/storefront/src/config/devTenantSeed.ts +2 -1
  16. package/apps/storefront/src/layouts/Layout.astro +1 -1
  17. package/apps/storefront/src/lib/access/auth/gatePage.ts +29 -14
  18. package/apps/storefront/src/lib/access/shared/publicAccess.ts +95 -0
  19. package/apps/storefront/src/lib/access/shared/tenantRouting.ts +19 -0
  20. package/apps/storefront/src/lib/catalog/shared/catalogIndex.ts +109 -0
  21. package/apps/storefront/src/lib/catalog/shared/catalogQueryView.ts +60 -0
  22. package/apps/storefront/src/lib/catalog/shared/collectionPaging.ts +78 -0
  23. package/apps/storefront/src/lib/catalog/shared/stockStatus.ts +23 -0
  24. package/apps/storefront/src/lib/catalog/tot/query.ts +8 -0
  25. package/apps/storefront/src/lib/checkout/browser/checkoutRequirementsForm.ts +54 -0
  26. package/apps/storefront/src/lib/checkout/commerce/checkoutHandoffRoute.ts +103 -35
  27. package/apps/storefront/src/lib/checkout/commerce/checkoutRequirements.ts +88 -0
  28. package/apps/storefront/src/lib/checkout/commerce/finalCartAuthorization.ts +2 -0
  29. package/apps/storefront/src/lib/checkout/commerce/sharedStoreAdmission.ts +95 -0
  30. package/apps/storefront/src/lib/checkout/shared/lazyCheckoutLoader.ts +191 -0
  31. package/apps/storefront/src/lib/compliance/browser/checkoutVerificationFlow.ts +9 -0
  32. package/apps/storefront/src/lib/compliance/enforcement.ts +7 -5
  33. package/apps/storefront/src/lib/content/seo/structuredData.ts +12 -3
  34. package/apps/storefront/src/lib/content/storyblok/provider.ts +4 -2
  35. package/apps/storefront/src/lib/observability/shared/serverTiming.ts +110 -0
  36. package/apps/storefront/src/lib/publication/shared/edgeCache.ts +3 -4
  37. package/apps/storefront/src/lib/publication/shared/staticQuery.ts +64 -0
  38. package/apps/storefront/src/middleware/controlPlane.ts +14 -11
  39. package/apps/storefront/src/middleware/index.ts +33 -12
  40. package/apps/storefront/src/middleware/renderChain.ts +52 -26
  41. package/apps/storefront/src/pages/[...slug].astro +3 -0
  42. package/apps/storefront/src/pages/api/commerce/final-cart-authorization.ts +112 -42
  43. package/apps/storefront/src/pages/api/compliance/preflight.ts +61 -13
  44. package/apps/storefront/src/pages/blog/author/[author].astro +1 -1
  45. package/apps/storefront/src/pages/blog/category/[category].astro +1 -1
  46. package/apps/storefront/src/pages/blog/tag/[tag].astro +1 -1
  47. package/apps/storefront/src/pages/catalog-index.json.ts +20 -0
  48. package/apps/storefront/src/pages/collections/[handle]/page/[n].astro +28 -0
  49. package/apps/storefront/src/pages/collections/[handle].astro +13 -198
  50. package/apps/storefront/src/pages/collections/_CollectionView.astro +251 -0
  51. package/apps/storefront/src/pages/collections/index.astro +1 -1
  52. package/apps/storefront/src/pages/products/[handle].astro +10 -5
  53. package/apps/storefront/src/pages/search.astro +33 -33
  54. package/apps/storefront/src/pages/sitemap.xml.ts +7 -1
  55. package/apps/storefront/vitest.config.cts +3 -0
  56. package/package.json +1 -1
  57. package/packages/cli/src/declared-config.mjs +318 -32
  58. package/packages/cli/src/publication-spaces.mjs +1 -1
  59. package/packages/public-runtime/package.json +1 -0
  60. package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +1 -1
  61. package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +1 -1
  62. package/packages/public-runtime/src/activity/catalog.ts +8 -1
  63. package/packages/public-runtime/src/candidate-index.ts +5 -0
  64. package/packages/public-runtime/src/capabilities.ts +12 -9
  65. package/packages/public-runtime/src/capture-rewrites.ts +687 -0
  66. package/packages/public-runtime/src/checkout-orders.ts +6 -7
  67. package/packages/public-runtime/src/checkout.ts +190 -6
  68. package/packages/public-runtime/src/compliance/evaluate.ts +7 -3
  69. package/packages/public-runtime/src/compliance/profile.ts +90 -14
  70. package/packages/public-runtime/src/compliance-coherence.d.mts +10 -1
  71. package/packages/public-runtime/src/compliance-coherence.mjs +129 -29
  72. package/packages/public-runtime/src/customization-runtime.ts +64 -2
  73. package/packages/public-runtime/src/declared-tenant-config.d.mts +13 -2
  74. package/packages/public-runtime/src/declared-tenant-config.mjs +83 -1
  75. package/packages/public-runtime/src/image-preview-rewrite-overlay.ts +13 -37
  76. package/packages/public-runtime/src/membership.ts +3 -0
  77. package/packages/public-runtime/src/publication-space.ts +1 -0
  78. package/packages/public-runtime/src/tenant.ts +28 -4
  79. package/scripts/tenant/validate.mjs +17 -8
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Tenant binaries never live in the tenant repo. The platform's content-addressed `/_a/<sha256>`
3
+ * pool owns them and the store's repo holds references only. Platform capture of catalog images
4
+ * is pending: until it owns them, an image stays at its source URL, go-live requires 0 uncaptured
5
+ * catalog images, and the source store is never shut down before that check passes. This decides,
6
+ * from a listing of the store's `public/` tree, whether the repo is carrying binaries it must not.
7
+ *
8
+ * Findings, each naming its remedy:
9
+ *
10
+ * - one per entry of {@link FORBIDDEN_PUBLIC_DIRS}: any file under a directory a media download
11
+ * writes (`public/catalog-images/` is `catalog-images-in-repo`, `public/blog/wp-content/uploads/`
12
+ * is `blog-media-in-repo`). Decided from paths alone, so a caller without sizes still refuses
13
+ * it. This list is the one source the validators and the reconcile read; the Gitea
14
+ * pre-receive size guard names the same directories.
15
+ * - `public-binary-oversized` — one raster or media file over {@link PUBLIC_BINARY_LIMITS}
16
+ * `maxFileBytes`.
17
+ * - `public-tree-over-budget` — the whole `public/` tree over `maxTotalBytes`.
18
+ *
19
+ * The size findings need `bytes`; an entry without it is judged by path only.
20
+ *
21
+ * Budget basis (measured 2026-10-20 on three live store repos' `preview` branches): public/ held
22
+ * 17.2 MB (384 files, largest 0.9 MB), 16.5 MB (largest 0.4 MB) and 0.2 MB outside
23
+ * catalog-images/. No legitimate file exceeded 1 MB.
24
+ *
25
+ * Pure: no filesystem, no network, no imports, so it runs in Node and in the Worker.
26
+ */
27
+
28
+ /** @type {{ maxFileBytes: number, maxTotalBytes: number }} */
29
+ export const PUBLIC_BINARY_LIMITS = Object.freeze({
30
+ maxFileBytes: 1024 * 1024,
31
+ maxTotalBytes: 32 * 1024 * 1024,
32
+ });
33
+
34
+ /**
35
+ * The directories under `public/` a media download writes. No file under one may be committed.
36
+ *
37
+ * @type {ReadonlyArray<Readonly<{ dir: string, code: string, noun: string }>>}
38
+ */
39
+ export const FORBIDDEN_PUBLIC_DIRS = Object.freeze([
40
+ Object.freeze({ dir: "catalog-images/", code: "catalog-images-in-repo", noun: "catalog image" }),
41
+ Object.freeze({ dir: "blog/wp-content/uploads/", code: "blog-media-in-repo", noun: "blog media" }),
42
+ ]);
43
+
44
+ /** The finding codes decided from paths alone. */
45
+ export const PATH_DECIDED_FINDING_CODES = Object.freeze(new Set(FORBIDDEN_PUBLIC_DIRS.map((d) => d.code)));
46
+
47
+ /** True for a path (relative to `public/`, `/` separators) under a forbidden directory. */
48
+ export function isForbiddenPublicPath(rel) {
49
+ return FORBIDDEN_PUBLIC_DIRS.some((d) => rel.startsWith(d.dir));
50
+ }
51
+
52
+ /** The kit command that moves downloaded catalog images and blog media back to their source URLs. */
53
+ export const UNHOST_IMAGES_COMMAND = "node agency-kit/tools/import-catalog.mjs --store <checkout> --unhost-images --write";
54
+
55
+ const RASTER_OR_MEDIA = /\.(?:jpe?g|png|gif|webp|avif|bmp|tiff?|heic|heif|ico|jxl|mp4|m4v|mov|webm|mkv|avi|mpe?g|mp3|wav|ogg|oga|m4a|aac|flac|opus)$/i;
56
+
57
+ const mb = (bytes) => `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
58
+
59
+ /**
60
+ * @param {ReadonlyArray<{ rel: string, bytes?: number }>} entries files under `public/`, `rel`
61
+ * relative to it (`catalog-images/ab.jpg`)
62
+ * @param {{ maxFileBytes?: number, maxTotalBytes?: number }} [limits]
63
+ * @returns {Array<{ code: string, path: string, message: string, remedy: string }>}
64
+ */
65
+ export function publicBinaryFindings(entries, limits = {}) {
66
+ const maxFileBytes = limits.maxFileBytes ?? PUBLIC_BINARY_LIMITS.maxFileBytes;
67
+ const maxTotalBytes = limits.maxTotalBytes ?? PUBLIC_BINARY_LIMITS.maxTotalBytes;
68
+ const findings = [];
69
+ for (const { dir, code, noun } of FORBIDDEN_PUBLIC_DIRS) {
70
+ const files = entries.filter((e) => e.rel.startsWith(dir));
71
+ if (files.length === 0) continue;
72
+ const bytes = files.reduce((n, e) => n + (e.bytes ?? 0), 0);
73
+ findings.push({
74
+ code,
75
+ path: `public/${dir}`,
76
+ message: `${files.length} ${noun} file(s)${bytes ? ` (${mb(bytes)})` : ""} are committed to the store repo; ` +
77
+ "images belong in the platform pool, not the repo; until platform capture owns them, keep each image at its source URL",
78
+ remedy: UNHOST_IMAGES_COMMAND,
79
+ });
80
+ }
81
+ for (const e of entries) {
82
+ if (isForbiddenPublicPath(e.rel) || e.bytes === undefined) continue;
83
+ if (e.bytes > maxFileBytes && RASTER_OR_MEDIA.test(e.rel)) {
84
+ findings.push({
85
+ code: "public-binary-oversized",
86
+ path: `public/${e.rel}`,
87
+ message: `${mb(e.bytes)} raster/media file exceeds the ${mb(maxFileBytes)} per-file limit for the store repo; ` +
88
+ "images and media belong in the platform pool",
89
+ remedy: "reference the file by its source URL, or compress it under the limit",
90
+ });
91
+ }
92
+ }
93
+ if (entries.every((e) => e.bytes !== undefined)) {
94
+ const total = entries.reduce((n, e) => n + /** @type {number} */ (e.bytes), 0);
95
+ if (total > maxTotalBytes) {
96
+ findings.push({
97
+ code: "public-tree-over-budget",
98
+ path: "public/",
99
+ message: `public/ holds ${mb(total)} across ${entries.length} file(s), over the ${mb(maxTotalBytes)} store-repo budget; ` +
100
+ "images and media belong in the platform pool",
101
+ remedy: "reference large or bulk assets by their source URL instead of committing them",
102
+ });
103
+ }
104
+ }
105
+ return findings;
106
+ }
107
+
108
+ /** One line per finding, remedy included: `<path>: [<code>] <message> — fix: <remedy>`. */
109
+ export function describePublicBinaryFindings(findings) {
110
+ return findings.map((f) => `${f.path}: [${f.code}] ${f.message} — fix: ${f.remedy}`);
111
+ }
@@ -95,6 +95,13 @@ export default defineConfig({
95
95
  // Local image/asset placeholders are served from /public; real product
96
96
  // images get rehosted to R2 in Phase 1 ETL (see DECISIONS.md).
97
97
  imageService: "compile",
98
+ // `astro preview` of the built Worker for the fold-gate loopback
99
+ // (scripts/publish/fold-gate-loopback.mjs): workerd keeps its local state in the
100
+ // directory the loopback names and opens no inspector port, so a preview never
101
+ // reads another run's cached responses or races a dev server for port 9229.
102
+ ...(process.env.TOT_PREVIEW_STATE_DIR
103
+ ? { persistState: { path: process.env.TOT_PREVIEW_STATE_DIR }, inspectorPort: false }
104
+ : {}),
98
105
  }),
99
106
  integrations: [
100
107
  // A framework Worker build requires no real-tenant checkout: tenant content
@@ -17,6 +17,7 @@ import type {
17
17
  } from "@tot/public-runtime";
18
18
  import type { ToTClient } from "@/lib/catalog/tot/ToTClient";
19
19
  import type { ContentProvider } from "@/lib/content/storyblok/provider";
20
+ import type { ServerTiming } from "@/lib/observability/shared/serverTiming";
20
21
 
21
22
  /**
22
23
  * Minimal binding shapes. We intentionally do NOT depend on
@@ -160,6 +161,8 @@ declare global {
160
161
  * exist yet. Unset on every public request.
161
162
  */
162
163
  storeNotPublic?: boolean;
164
+ /** Per-request phase timer; the middleware stamps it as the `Server-Timing` header. */
165
+ serverTiming?: ServerTiming;
163
166
  /**
164
167
  * Which customization environment to serve. "live" on production
165
168
  * hosts; "test" only behind a valid, tenant-bound preview token on a
@@ -29,7 +29,8 @@ interface Props {
29
29
  }
30
30
 
31
31
  const { product, priority = false, rating, reviewCount } = Astro.props;
32
- const { tenant, basePath } = Astro.locals;
32
+ const { tenant, basePath, features } = Astro.locals;
33
+ const showBadges = features.productBadges;
33
34
 
34
35
  // A compliance-verified item (age/lab/regulatory) earns a "Compliant" badge —
35
36
  // tag-driven so it stays tenant-agnostic (no per-merchant branch).
@@ -148,9 +149,9 @@ const quickview = {
148
149
 
149
150
  {/* Status badges */}
150
151
  <div class="pointer-events-none absolute left-2 top-2 z-10 flex flex-col items-start gap-1">
151
- {onSale && <Badge variant="sale" label={salePercent ? `-${salePercent}%` : "Sale"} />}
152
- {isNewProduct && <Badge variant="new" label="New" />}
153
- {isCompliant && <Badge variant="compliant" label="Compliant" />}
152
+ {showBadges && onSale && <Badge variant="sale" label={salePercent ? `-${salePercent}%` : "Sale"} />}
153
+ {showBadges && isNewProduct && <Badge variant="new" label="New" />}
154
+ {showBadges && isCompliant && <Badge variant="compliant" label="Compliant" />}
154
155
  {!available && <Badge variant="soldout" label="Sold out" />}
155
156
  </div>
156
157
 
@@ -10,6 +10,7 @@
10
10
  * atom is usable outside a CatalogProduct context (cart line, PDP, fixtures).
11
11
  */
12
12
  import type { CatalogProduct } from "@tot/public-runtime";
13
+ import { resolveStoreCurrency } from "@tot/public-runtime/declared-tenant-config";
13
14
  import { formatPrice, discountPercent } from "@/lib/content/shared/format";
14
15
  import { fromPrice, isOnSale } from "@/lib/catalog/tot/query";
15
16
  import Badge from "@/components/content/storefront/Badge.astro";
@@ -47,7 +48,7 @@ const {
47
48
  // Derive from the product when one is supplied; explicit props win.
48
49
  const derivedAmount = product ? fromPrice(product) : 0;
49
50
  const amount = amountProp ?? derivedAmount;
50
- const currency = currencyProp ?? product?.price_range.currency ?? "USD";
51
+ const currency = resolveStoreCurrency(currencyProp ?? product?.price_range.currency);
51
52
 
52
53
  const productHasRange = !!product && product.price_range.min !== product.price_range.max;
53
54
  const from = fromProp ?? productHasRange;
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * <SearchTypeahead> — compact header search with debounced typeahead dropdown.
3
3
  *
4
- * Interactive island (Preact). Fetches `GET {action}?q=<q>&format=json` (the
5
- * /search page returns `{ products: TypeaheadDoc[] }` when `format=json`) and
4
+ * Interactive island (Preact). Searches the tenant's static catalog index
5
+ * (`/catalog-index.json`, fetched once) with the same engine as the /search
6
+ * page, so it works wherever the page is served — a static host included — and
6
7
  * renders up to 6 results as an ARIA combobox/listbox. Submitting the form (or
7
8
  * Enter with no active option) navigates to the full `{action}?q=<q>` page.
8
9
  *
@@ -11,10 +12,12 @@
11
12
  * open a product, Escape to dismiss. Fetch failures degrade to "no dropdown".
12
13
  */
13
14
  import { useCallback, useEffect, useRef, useState } from "preact/hooks";
14
- import type { TypeaheadDoc } from "@/lib/catalog/search/index";
15
+ import { toTypeaheadDoc, type TypeaheadDoc } from "@/lib/catalog/search/index";
16
+ import { loadCatalogIndex, searchFromIndex } from "@/lib/catalog/shared/catalogQueryView";
17
+ import { asCatalogProduct } from "@/lib/catalog/shared/catalogIndex";
15
18
  import { formatPrice } from "@/lib/content/shared/format";
16
19
  import { placeholderDataUri, isUsableImageUrl } from "@/lib/content/shared/placeholder";
17
- import { withClientBase } from "@/lib/publication/shared/basePath";
20
+ import { clientBasePath, withClientBase } from "@/lib/publication/shared/basePath";
18
21
 
19
22
  interface Props {
20
23
  action?: string;
@@ -48,7 +51,7 @@ export default function SearchTypeahead({
48
51
  const [active, setActive] = useState(-1); // index into results, -1 = none
49
52
  const rootRef = useRef<HTMLFormElement>(null);
50
53
  const inputRef = useRef<HTMLInputElement>(null);
51
- const abortRef = useRef<AbortController | null>(null);
54
+ const requestRef = useRef(0);
52
55
  const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
53
56
 
54
57
  const closeDropdown = useCallback(() => {
@@ -56,7 +59,7 @@ export default function SearchTypeahead({
56
59
  setActive(-1);
57
60
  }, []);
58
61
 
59
- // Debounced fetch.
62
+ // Debounced search.
60
63
  useEffect(() => {
61
64
  const q = query.trim();
62
65
  if (debounceRef.current) clearTimeout(debounceRef.current);
@@ -67,24 +70,18 @@ export default function SearchTypeahead({
67
70
  return;
68
71
  }
69
72
  debounceRef.current = setTimeout(() => {
70
- abortRef.current?.abort();
71
- const ctrl = new AbortController();
72
- abortRef.current = ctrl;
73
- // Prefix with the tenant base path so the typeahead queries THIS tenant's
74
- // /search (path-routed) rather than the root fallback tenant.
75
- const url = `${withClientBase(action)}?q=${encodeURIComponent(q)}&format=json`;
76
- fetch(url, { signal: ctrl.signal, headers: { Accept: "application/json" } })
77
- .then((r) => (r.ok ? r.json() : Promise.reject(new Error("bad status"))))
78
- .then((data: { products?: TypeaheadDoc[] }) => {
79
- const products = Array.isArray(data.products)
80
- ? data.products.slice(0, MAX_RESULTS)
81
- : [];
73
+ const request = ++requestRef.current;
74
+ loadCatalogIndex(clientBasePath())
75
+ .then((index) => {
76
+ if (request !== requestRef.current) return;
77
+ const products = searchFromIndex(index, q, MAX_RESULTS).map((p) => toTypeaheadDoc(asCatalogProduct(p)));
82
78
  setResults(products);
83
79
  setActive(-1);
84
80
  setOpen(products.length > 0);
85
81
  })
86
82
  .catch(() => {
87
- // Network/parse error or aborted: degrade silently (no dropdown).
83
+ // Network/parse error: degrade silently (no dropdown).
84
+ if (request !== requestRef.current) return;
88
85
  setResults([]);
89
86
  setOpen(false);
90
87
  setActive(-1);
@@ -94,7 +91,7 @@ export default function SearchTypeahead({
94
91
  return () => {
95
92
  if (debounceRef.current) clearTimeout(debounceRef.current);
96
93
  };
97
- }, [query, action]);
94
+ }, [query]);
98
95
 
99
96
  // Close when focus/click leaves the widget.
100
97
  useEffect(() => {
@@ -27,6 +27,7 @@ import type {
27
27
  } from "@tot/public-runtime";
28
28
  import { priceForPurchaseOption } from "@tot/public-runtime";
29
29
  import { formatPrice } from "@/lib/content/shared/format";
30
+ import { stockStatus } from "@/lib/catalog/shared/stockStatus";
30
31
  import { useCheckoutHandoffCorrelation } from "@/lib/checkout/browser/useCheckoutHandoffCorrelation";
31
32
 
32
33
  export type PublicSelectorVariant = Pick<CatalogVariant,
@@ -69,6 +70,7 @@ interface Props {
69
70
  */
70
71
  checkoutCart?: {
71
72
  action: string;
73
+ method: "get" | "post";
72
74
  purchaseOptions: ProductPurchaseOption[];
73
75
  byVariantId: Record<string, Record<string, VariantCartPayload>>;
74
76
  };
@@ -90,8 +92,6 @@ interface Props {
90
92
  handoffUrl?: string;
91
93
  }
92
94
 
93
- const LOW_STOCK_THRESHOLD = 5;
94
-
95
95
  type Selection = Record<string, string>;
96
96
 
97
97
  function variantMatches(v: PublicSelectorVariant, sel: Selection): boolean {
@@ -234,22 +234,9 @@ export default function VariantSelector({
234
234
  }, [complete, selected]);
235
235
 
236
236
  // ---- live-region status copy --------------------------------------------
237
- const qty = selected?.inventory.quantity;
238
237
  const available = selected?.inventory.available ?? false;
239
- let stockLabel = "";
240
- let stockClass = "text-muted";
241
- if (complete && selected) {
242
- if (!available) {
243
- stockLabel = "Sold out";
244
- stockClass = "text-sale";
245
- } else if (qty !== undefined && qty !== null && qty <= LOW_STOCK_THRESHOLD) {
246
- stockLabel = `Only ${qty} left`;
247
- stockClass = "text-accent-ink";
248
- } else {
249
- stockLabel = "In stock";
250
- stockClass = "text-primary";
251
- }
252
- }
238
+ const stock = complete && selected ? stockStatus(selected.inventory) : undefined;
239
+ const STOCK_CLASS = { "sold-out": "text-sale", "low-stock": "text-accent-ink", "in-stock": "text-primary" };
253
240
 
254
241
  // Compose member + subscription pricing. The pricing context carries the
255
242
  // resolved member tier only when an eligible member was resolved server-side;
@@ -323,9 +310,14 @@ export default function VariantSelector({
323
310
  </span>
324
311
  )}
325
312
  </div>
326
- <p class={`font-mono text-xs uppercase tracking-[0.08em] ${stockClass}`}>
327
- {stockLabel}
328
- </p>
313
+ {stock && (
314
+ <p
315
+ data-product-availability={stock.state}
316
+ class={`font-mono text-xs uppercase tracking-[0.08em] ${STOCK_CLASS[stock.state]}`}
317
+ >
318
+ {stock.label}
319
+ </p>
320
+ )}
329
321
  {selected.sku && (
330
322
  <p class="font-mono text-[0.7rem] text-muted">
331
323
  SKU: {selected.sku}
@@ -486,7 +478,7 @@ export default function VariantSelector({
486
478
  <div class="flex flex-col gap-1.5">
487
479
  <form
488
480
  action={checkoutCart.action}
489
- method="post"
481
+ method={checkoutCart.method}
490
482
  target="_top"
491
483
  data-managed-cart
492
484
  data-product-handle={productHandle}
@@ -1,9 +1,11 @@
1
1
  ---
2
2
  /**
3
3
  * Numbered pagination (SEO-friendly, brief §5 default) + a "Load more" link.
4
- * Both are plain anchors carrying the full query, so they're crawlable and
5
- * back-button safe. "Load more" simply links to the next page; with JS it could
6
- * be progressively enhanced to append, but the no-JS path stays correct.
4
+ * Both are plain anchors, so they're crawlable and back-button safe. The default
5
+ * view links each page's path variant (`pageHref`, `/collections/<h>/page/<n>/`),
6
+ * which every static host serves; any other view links the full query. "Load
7
+ * more" simply links to the next page; with JS it could be progressively
8
+ * enhanced to append, but the no-JS path stays correct.
7
9
  */
8
10
  import type { ListProductsQuery } from "@tot/public-runtime";
9
11
  import { buildQueryString } from "@/lib/publication/shared/url";
@@ -14,14 +16,16 @@ interface Props {
14
16
  page: number;
15
17
  pageSize: number;
16
18
  total: number;
19
+ /** Path-variant link for page `p`; absent when the view must keep its query. */
20
+ pageHref?: (p: number) => string;
17
21
  }
18
- const { query, basePath, page, pageSize, total } = Astro.props;
22
+ const { query, basePath, page, pageSize, total, pageHref } = Astro.props;
19
23
  const pageCount = Math.max(1, Math.ceil(total / pageSize));
20
24
  if (pageCount <= 1) {
21
25
  // nothing to render
22
26
  }
23
27
 
24
- const href = (p: number) => basePath + buildQueryString(query, { page: p });
28
+ const href = (p: number) => pageHref?.(p) ?? basePath + buildQueryString(query, { page: p });
25
29
 
26
30
  // Compact page-number window.
27
31
  const windowSize = 5;
@@ -1,10 +1,12 @@
1
1
  ---
2
2
  /**
3
- * <CheckoutLoader> — injects the managed checkout loader (Track A).
3
+ * <CheckoutLoader> — injects the managed checkout loader (Track A), deferred.
4
4
  *
5
- * The loader turns our add-to-cart forms/links into the cart drawer + hosted
6
- * checkout. It's loaded from the store's CDN path and must sit near the end of
7
- * <body>. Rendered only when the tenant has a store (locals.commerce);
5
+ * The loader turns cart links/forms into the cart drawer + hosted checkout. It is
6
+ * loaded from the store's CDN path by a nonced bootstrap on the first shopper input
7
+ * that can reach a commerce control, or a quiet timer after load (see
8
+ * lazyCheckoutLoader.ts), so its cart-drawer bundle never lands in the load-time
9
+ * critical path. Rendered only when the tenant has a store (locals.commerce);
8
10
  * marketing tenants ship nothing.
9
11
  *
10
12
  * Note: the loader is a mutable, versioned CDN artifact and does NOT support
@@ -12,10 +14,20 @@
12
14
  * allowlist set in middleware. It is loaded as a plain cross-origin script
13
15
  * (NO `crossorigin` attribute) because the CDN serves it without CORS headers.
14
16
  */
15
- const { commerce } = Astro.locals;
17
+ import { lazyCheckoutLoaderJs } from "@/lib/checkout/shared/lazyCheckoutLoader";
18
+
19
+ interface Props {
20
+ nonce?: string;
21
+ }
22
+ const { commerce, storeNotPublic } = Astro.locals;
23
+ const { nonce } = Astro.props;
16
24
  ---
17
25
  {
18
26
  commerce && (
19
- <script src={commerce.loaderSrc} async defer></script>
27
+ <script is:inline nonce={nonce} set:html={lazyCheckoutLoaderJs({
28
+ loaderSrc: commerce.loaderSrc,
29
+ cartOrigin: commerce.origin,
30
+ storeNotPublic: !!storeNotPublic,
31
+ })} />
20
32
  )
21
33
  }
@@ -32,6 +32,7 @@ import {
32
32
  readStoredRef,
33
33
  verificationPhase,
34
34
  verificationRefKey,
35
+ verifiedRefKey,
35
36
  writeStoredRef,
36
37
  type PreflightResponse,
37
38
  type VerificationPhase,
@@ -151,7 +152,9 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
151
152
  setPopupBlocked(false);
152
153
  // A reload mid-trip must not lose the correlation: replay the stored ref so
153
154
  // the server re-reads THIS attempt's verification instead of starting over.
154
- setVerificationRef(readStoredRef(verificationRefKey(preflightUrl, line)));
155
+ setVerificationRef(
156
+ readStoredRef(verificationRefKey(preflightUrl, line)) ?? readStoredRef(verifiedRefKey(preflightUrl)),
157
+ );
155
158
  let pre = false;
156
159
  try {
157
160
  pre = localStorage.getItem(AGE_KEY) === "yes";
@@ -175,9 +178,19 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
175
178
  document.documentElement.style.overflow = "";
176
179
  }, [stopPolling]);
177
180
 
178
- const proceed = useCallback((line: PendingLine) => {
181
+ const proceed = useCallback((line: PendingLine, ref: string | null) => {
179
182
  stopPolling();
180
183
  clearStoredRef(verificationRefKey(preflightUrl, line));
184
+ // The cleared ref rides to the cart (signed server-side) and is reused for later lines.
185
+ line.form.querySelectorAll('input[name="verification"]').forEach((input) => input.remove());
186
+ if (ref) {
187
+ const input = document.createElement("input");
188
+ input.type = "hidden";
189
+ input.name = "verification";
190
+ input.value = ref;
191
+ line.form.appendChild(input);
192
+ writeStoredRef(verifiedRefKey(preflightUrl), ref);
193
+ }
181
194
  // UX latch so this dialog does not intercept the resubmit. Not payment
182
195
  // authority — the form's server action checks the cart.
183
196
  line.form.dataset.complianceCleared = "1";
@@ -258,12 +271,13 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
258
271
  switch (outcome.kind) {
259
272
  case "cleared":
260
273
  setDecision(outcome.response.decision);
261
- proceed(line);
274
+ proceed(line, outcome.response.verification?.ref ?? ref);
262
275
  return;
263
276
  case "denied":
264
277
  setDecision(outcome.response.decision);
265
278
  setPhase("denied");
266
279
  clearStoredRef(verificationRefKey(preflightUrl, line));
280
+ clearStoredRef(verifiedRefKey(preflightUrl));
267
281
  setVerificationRef(null);
268
282
  return;
269
283
  case "halted":
@@ -304,7 +318,7 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
304
318
  setDecision(res.decision);
305
319
  // Only the server's own verdict releases the handoff.
306
320
  if (res.decision.status === "clear") {
307
- proceed(line);
321
+ proceed(line, ref);
308
322
  return;
309
323
  }
310
324
 
@@ -312,6 +326,7 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
312
326
  setPhase(next);
313
327
  if (next === "denied") {
314
328
  clearStoredRef(key);
329
+ clearStoredRef(verifiedRefKey(preflightUrl));
315
330
  setVerificationRef(null);
316
331
  return;
317
332
  }
@@ -24,6 +24,7 @@ const variants: Record<Props["variant"], string> = {
24
24
  ---
25
25
 
26
26
  <span
27
+ data-product-badge={variant}
27
28
  class:list={[
28
29
  "inline-flex items-center rounded-full px-2 py-0.5 font-mono text-[0.62rem] uppercase tracking-[0.12em] leading-none whitespace-nowrap",
29
30
  variants[variant],
@@ -10,6 +10,7 @@ import type { BlogIndexCopy } from "@/lib/content/blog/pagination.js";
10
10
  import type { BlogPresentation } from "@/lib/content/blog/presentation.js";
11
11
  import { categorySlug } from "@/lib/content/blog/collection.js";
12
12
  import { collectionPathsV1 } from "@/lib/content/blog/collectionPaths.js";
13
+ import { homeCrumbLabel, siteTrail } from "@/lib/content/shared/breadcrumbs.js";
13
14
 
14
15
  interface Props {
15
16
  basePath: string;
@@ -52,6 +53,14 @@ const itemList = {
52
53
  url: new URL(blogPaths.post(post.slug), canonicalBase + "/").toString(),
53
54
  })),
54
55
  };
56
+ const crumbs = siteTrail(
57
+ homeCrumbLabel(await Astro.locals.content.getChrome()),
58
+ { label: "Blog", href: blogPaths.index },
59
+ );
60
+ const items = pagePosts.map((post) => ({
61
+ title: post.title,
62
+ url: new URL(blogPaths.post(post.slug), canonicalBase + "/").toString(),
63
+ }));
55
64
  ---
56
65
 
57
66
  <Layout
@@ -60,7 +69,7 @@ const itemList = {
60
69
  path={path}
61
70
  fullBleed
62
71
  jsonLd={itemList}
63
- structuredData={{ pageType: "collection" }}
72
+ structuredData={{ pageType: "collection", crumbs, items }}
64
73
  pagination={pagination}
65
74
  >
66
75
  <header class:list={["blog-hero", { "blog-hero--plain": !presentation.hero }]}>