@tokenoftrust/storefront-runner 2.5.5 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/agency-kit/tools/lib/public-binaries.mjs +95 -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/ImageGallery.tsx +20 -3
  7. package/apps/storefront/src/components/catalog/storefront/islands/SearchTypeahead.tsx +17 -20
  8. package/apps/storefront/src/components/catalog/storefront/islands/VariantSelector.tsx +13 -21
  9. package/apps/storefront/src/components/catalog/storefront/plp/Pagination.astro +9 -5
  10. package/apps/storefront/src/components/checkout/storefront/commerce/CheckoutLoader.astro +18 -6
  11. package/apps/storefront/src/components/compliance/storefront/islands/CheckoutComplianceGate.tsx +19 -4
  12. package/apps/storefront/src/components/content/storefront/Badge.astro +1 -0
  13. package/apps/storefront/src/components/content/storefront/blog/BlogIndexView.astro +10 -1
  14. package/apps/storefront/src/components/publication/storefront/islands/CatalogQueryView.tsx +321 -0
  15. package/apps/storefront/src/config/declaredTenantConfig.ts +59 -27
  16. package/apps/storefront/src/config/devTenantSeed.ts +2 -1
  17. package/apps/storefront/src/layouts/Layout.astro +1 -1
  18. package/apps/storefront/src/lib/access/auth/gatePage.ts +29 -14
  19. package/apps/storefront/src/lib/access/shared/publicAccess.ts +95 -0
  20. package/apps/storefront/src/lib/access/shared/tenantRouting.ts +19 -0
  21. package/apps/storefront/src/lib/catalog/shared/catalogIndex.ts +109 -0
  22. package/apps/storefront/src/lib/catalog/shared/catalogQueryView.ts +60 -0
  23. package/apps/storefront/src/lib/catalog/shared/collectionPaging.ts +78 -0
  24. package/apps/storefront/src/lib/catalog/shared/stockStatus.ts +23 -0
  25. package/apps/storefront/src/lib/catalog/tot/query.ts +8 -0
  26. package/apps/storefront/src/lib/checkout/browser/checkoutRequirementsForm.ts +54 -0
  27. package/apps/storefront/src/lib/checkout/commerce/checkoutHandoffRoute.ts +103 -35
  28. package/apps/storefront/src/lib/checkout/commerce/checkoutRequirements.ts +88 -0
  29. package/apps/storefront/src/lib/checkout/commerce/finalCartAuthorization.ts +2 -0
  30. package/apps/storefront/src/lib/checkout/commerce/sharedStoreAdmission.ts +95 -0
  31. package/apps/storefront/src/lib/checkout/shared/lazyCheckoutLoader.ts +191 -0
  32. package/apps/storefront/src/lib/compliance/browser/checkoutVerificationFlow.ts +9 -0
  33. package/apps/storefront/src/lib/compliance/enforcement.ts +7 -5
  34. package/apps/storefront/src/lib/content/seo/structuredData.ts +12 -3
  35. package/apps/storefront/src/lib/content/storyblok/provider.ts +14 -7
  36. package/apps/storefront/src/lib/observability/shared/serverTiming.ts +110 -0
  37. package/apps/storefront/src/lib/publication/shared/edgeCache.ts +3 -4
  38. package/apps/storefront/src/lib/publication/shared/staticQuery.ts +64 -0
  39. package/apps/storefront/src/middleware/controlPlane.ts +14 -11
  40. package/apps/storefront/src/middleware/index.ts +33 -12
  41. package/apps/storefront/src/middleware/renderChain.ts +52 -26
  42. package/apps/storefront/src/pages/[...slug].astro +3 -0
  43. package/apps/storefront/src/pages/api/commerce/final-cart-authorization.ts +112 -42
  44. package/apps/storefront/src/pages/api/compliance/preflight.ts +61 -13
  45. package/apps/storefront/src/pages/blog/author/[author].astro +1 -1
  46. package/apps/storefront/src/pages/blog/category/[category].astro +1 -1
  47. package/apps/storefront/src/pages/blog/tag/[tag].astro +1 -1
  48. package/apps/storefront/src/pages/catalog-index.json.ts +20 -0
  49. package/apps/storefront/src/pages/collections/[handle]/page/[n].astro +28 -0
  50. package/apps/storefront/src/pages/collections/[handle].astro +13 -198
  51. package/apps/storefront/src/pages/collections/_CollectionView.astro +251 -0
  52. package/apps/storefront/src/pages/collections/index.astro +1 -1
  53. package/apps/storefront/src/pages/products/[handle].astro +18 -8
  54. package/apps/storefront/src/pages/search.astro +33 -33
  55. package/apps/storefront/src/pages/sitemap.xml.ts +7 -1
  56. package/apps/storefront/vitest.config.cts +3 -0
  57. package/package.json +1 -1
  58. package/packages/cli/src/declared-config.mjs +318 -32
  59. package/packages/cli/src/publication-spaces.mjs +1 -1
  60. package/packages/public-runtime/package.json +1 -0
  61. package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +1 -1
  62. package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +1 -1
  63. package/packages/public-runtime/src/activity/catalog.ts +8 -1
  64. package/packages/public-runtime/src/candidate-index.ts +5 -0
  65. package/packages/public-runtime/src/capabilities.ts +12 -9
  66. package/packages/public-runtime/src/capture-rewrites.ts +687 -0
  67. package/packages/public-runtime/src/checkout-orders.ts +6 -7
  68. package/packages/public-runtime/src/checkout.ts +190 -6
  69. package/packages/public-runtime/src/compliance/evaluate.ts +7 -3
  70. package/packages/public-runtime/src/compliance/profile.ts +90 -14
  71. package/packages/public-runtime/src/compliance-coherence.d.mts +10 -1
  72. package/packages/public-runtime/src/compliance-coherence.mjs +129 -29
  73. package/packages/public-runtime/src/customization-runtime.ts +64 -2
  74. package/packages/public-runtime/src/declared-tenant-config.d.mts +13 -2
  75. package/packages/public-runtime/src/declared-tenant-config.mjs +83 -1
  76. package/packages/public-runtime/src/image-preview-rewrite-overlay.ts +13 -37
  77. package/packages/public-runtime/src/membership.ts +3 -0
  78. package/packages/public-runtime/src/publication-space.ts +1 -0
  79. package/packages/public-runtime/src/tenant.ts +28 -4
  80. package/scripts/tenant/validate.mjs +17 -8
@@ -1,209 +1,24 @@
1
1
  ---
2
2
  /**
3
- * PLP / collection page (brief §5). Responsive grid, collection hero, sort,
4
- * URL-addressable facets, numbered pagination + load-more. Product data from
5
- * ToT (active-only); hero copy from the collection record (Storyblok could
6
- * override later — documented seam).
3
+ * PLP / collection page 1. Page N≥2 lives at `/collections/<h>/page/<n>/`
4
+ * (collections/[handle]/page/[n].astro); both render through _CollectionView.
7
5
  */
8
- import Layout from "@/layouts/Layout.astro";
9
- import Breadcrumbs from "@/components/content/storefront/Breadcrumbs.astro";
10
- import { homeCrumbLabel, siteTrail } from "@/lib/content/shared/breadcrumbs";
11
- import ProductCard from "@/components/catalog/storefront/ProductCard.astro";
12
- import EmptyState from "@/components/content/storefront/EmptyState.astro";
13
- import FacetSidebar from "@/components/catalog/storefront/plp/FacetSidebar.astro";
14
- import ActiveFilters from "@/components/catalog/storefront/plp/ActiveFilters.astro";
15
- import SortSelect from "@/components/catalog/storefront/plp/SortSelect.astro";
16
- import Pagination from "@/components/catalog/storefront/plp/Pagination.astro";
17
- import Img from "@/components/assets/storefront/Img.astro";
18
- import { parseQuery, buildQueryString } from "@/lib/publication/shared/url";
19
- import { breadcrumbLd, itemListLd } from "@/lib/content/shared/jsonld";
20
- import { truncate } from "@/lib/content/shared/format";
21
- import { pageUrlForm } from "@/lib/content/seo/urlForm";
22
- import { sourceSeoTitle } from "@/lib/content/seo/documentTitle";
23
- import { withBase } from "@/lib/publication/shared/basePath";
24
- import { isBlankDescriptionHtml, sanitizeDescriptionHtml } from "@/lib/content/sanitizeDescriptionHtml";
6
+ import CollectionView from "./_CollectionView.astro";
7
+ import { loadCollectionListing } from "@/lib/catalog/shared/collectionPaging";
8
+ import { effectiveCollectionQuery } from "@/lib/publication/shared/staticQuery";
9
+
10
+ // Sort, facets, price and inline search ride the query string, which a static host
11
+ // never passes to a renderer: CatalogQueryView applies them client-side there.
12
+ export const STATIC_QUERY = "client-applied";
25
13
 
26
14
  const { handle } = Astro.params;
27
- const { tenant, tot, canonicalBase } = Astro.locals;
28
15
  // Commerce-only route: marketing tenants have no catalog surface.
29
16
  if (!Astro.locals.features.catalog) return new Response(null, { status: 404 });
30
17
  if (!handle) return new Response(null, { status: 404 });
31
18
 
32
- const collection = await tot.getCollectionByHandle(handle);
33
- if (!collection || collection.visibility === "hidden") return new Response(null, { status: 404 });
34
-
35
- const url = new URL(Astro.request.url);
36
- const query = parseQuery(url);
37
- query.collection = handle;
38
- if (!query.pageSize) query.pageSize = 9;
39
-
40
- const { products, page, pageSize, total, facets } = await tot.listProducts(query);
41
-
42
- // A synthesized-EMPTY collection (zero active members overall — not merely
43
- // zero under the current facet/price/q filters) still resolves 200 for a direct
44
- // link, but must not be indexed: it's a crawlable dead end, kept out of the
45
- // sitemap + directory too. Detect it via the UNFILTERED membership total.
46
- const { total: memberTotal } = await tot.listProducts({ collection: handle, pageSize: 1 });
47
- const isEmptyCollection = memberTotal === 0;
48
- const basePath = pageUrlForm(`/collections/${handle}`, tenant.seo);
49
- // Tenant-prefixed page path for facet/sort/pagination, which build links by
50
- // string-concatenating onto this base (so they stay inside a path-routed
51
- // tenant). `basePath` itself stays unprefixed for the SEO canonical `path`,
52
- // breadcrumbs, and EmptyState — those prefix on their own via withBase.
53
- const linkBase = withBase(Astro.locals.basePath, basePath);
54
-
55
- const crumbs = siteTrail(
56
- homeCrumbLabel(await Astro.locals.content.getChrome()),
57
- { label: collection.title, href: basePath },
58
- );
59
-
60
- const heroImg = collection.image;
61
- const desc = collection.description_text || collection.description_html?.replace(/<[^>]+>/g, "");
62
- // Full-fidelity body (brands/how-it-works/ingredients/FAQ sections on the merchant's
63
- // live collection pages), sanitized for render-time defense-in-depth on top of the
64
- // migration pipeline's own sanitize-html pass at ingestion (see
65
- // sanitizeDescriptionHtml.ts). Rendered as its own block BELOW the product grid,
66
- // matching the live site's layout — `desc` above stays the short hero excerpt.
67
- const contentBlockHtml = sanitizeDescriptionHtml(collection.description_html);
68
- const hasContentBlock = contentBlockHtml.length > 0 && !isBlankDescriptionHtml(contentBlockHtml);
69
-
70
- // Zero-results recovery: differentiate the cause (search term vs. facet/price
71
- // filters) and offer targeted "undo just this" links alongside the existing
72
- // "clear everything" CTA, instead of one generic message + one broad reset.
73
- const hasFacetOrPriceFilters =
74
- !!query.filters ||
75
- query.priceMin != null ||
76
- query.priceMax != null ||
77
- query.availability === "in-stock";
78
- const zeroResultsTitle = query.q ? `No matches for "${query.q}"` : "Nothing matches — yet";
79
- const zeroResultsBody = query.q
80
- ? "Try a broader search term, or clear your filters to browse the full collection."
81
- : "No pieces fit these filters. Loosen a filter or clear them to see the full collection.";
82
- const zeroResultsActions: { label: string; href: string }[] = [];
83
- if (query.q && hasFacetOrPriceFilters) {
84
- const { q: _q, ...rest } = query;
85
- zeroResultsActions.push({ label: "Clear search, keep filters", href: basePath + buildQueryString(rest) });
86
- }
87
- if (query.priceMin != null || query.priceMax != null) {
88
- const { priceMin: _priceMin, priceMax: _priceMax, ...rest } = query;
89
- zeroResultsActions.push({ label: "Remove price range", href: basePath + buildQueryString(rest) });
90
- }
19
+ const query = effectiveCollectionQuery(Astro.url);
20
+ const listing = await loadCollectionListing(Astro.locals.tot, handle, query);
21
+ if (!listing) return new Response(null, { status: 404 });
91
22
  ---
92
23
 
93
- <Layout
94
- title={collection.seo.title || collection.title}
95
- seoTitle={sourceSeoTitle(tenant.seo, collection.seo.title)}
96
- description={collection.seo.description || (desc ? truncate(desc, 160) : `Shop ${collection.title} at ${tenant.displayName}.`)}
97
- path={basePath}
98
- noindex={isEmptyCollection}
99
- jsonLd={[
100
- breadcrumbLd(crumbs, canonicalBase),
101
- itemListLd(products, canonicalBase, (page - 1) * pageSize),
102
- ]}
103
- structuredData={{
104
- pageType: "collection",
105
- description: collection.seo.description || undefined,
106
- crumbs,
107
- items: products.map((p) => ({ title: p.title, url: new URL(`/products/${p.handle}`, canonicalBase + "/").toString() })),
108
- }}
109
- >
110
- {/* Collection hero */}
111
- <header class="bg-[var(--color-primary)] text-[var(--color-primary-contrast)]">
112
- <div class="container-prose grid gap-6 py-[clamp(2.5rem,6vw,4.5rem)] md:grid-cols-[1fr_auto] md:items-end">
113
- <div data-reveal>
114
- <p class="eyebrow mb-3 text-[color-mix(in_srgb,var(--color-primary-contrast)_70%,transparent)]">
115
- Collection
116
- </p>
117
- <h1 class="display-xl">
118
- {collection.title}
119
- </h1>
120
- {desc && (
121
- <p class="mt-4 max-w-prose text-[1.05rem] text-[color-mix(in_srgb,var(--color-primary-contrast)_82%,transparent)]">
122
- {truncate(desc, 220)}
123
- </p>
124
- )}
125
- </div>
126
- {heroImg && (
127
- <div class="hidden w-48 overflow-hidden rounded-[var(--radius-md)] md:block" data-reveal>
128
- <Img
129
- src={heroImg.url}
130
- rehosted={heroImg.rehosted}
131
- seed={heroImg.id ?? collection.handle}
132
- label={collection.title}
133
- alt={heroImg.alt ?? collection.title}
134
- width={heroImg.width ?? 400}
135
- height={heroImg.height ?? 300}
136
- />
137
- </div>
138
- )}
139
- </div>
140
- </header>
141
-
142
- <div class="container-prose py-4">
143
- <Breadcrumbs crumbs={crumbs} />
144
- </div>
145
-
146
- <div class="container-prose grid gap-10 pb-16 lg:grid-cols-[230px_1fr]">
147
- {/* Facets — sticky on desktop; a <details> drawer on mobile. */}
148
- <div class="lg:sticky lg:top-24 lg:self-start">
149
- <details class="lg:hidden">
150
- <summary class="cursor-pointer rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-2.5 font-display text-sm font-medium">
151
- Filters & sort
152
- </summary>
153
- <div class="mt-4">
154
- <FacetSidebar facets={facets} query={query} basePath={linkBase} />
155
- </div>
156
- </details>
157
- <div class="hidden lg:block">
158
- <FacetSidebar facets={facets} query={query} basePath={linkBase} />
159
- </div>
160
- </div>
161
-
162
- <div>
163
- <div class="mb-5 flex flex-wrap items-center justify-between gap-3 border-b border-[var(--color-border)] pb-4">
164
- <p class="text-sm text-[var(--color-muted)]">
165
- {total} {total === 1 ? "item" : "items"}
166
- </p>
167
- <SortSelect query={query} basePath={linkBase} />
168
- </div>
169
-
170
- <ActiveFilters query={query} basePath={linkBase} />
171
-
172
- {products.length === 0 ? (
173
- <EmptyState
174
- title={zeroResultsTitle}
175
- body={zeroResultsBody}
176
- ctaLabel="Clear all filters"
177
- ctaHref={basePath}
178
- secondaryActions={zeroResultsActions}
179
- />
180
- ) : (
181
- <>
182
- <div class="grid grid-cols-2 gap-5 sm:grid-cols-2 lg:grid-cols-3">
183
- {products.map((p, i) => (
184
- <ProductCard
185
- product={p}
186
- position={(page - 1) * pageSize + i}
187
- list={collection.handle}
188
- priority={i < 3 && page === 1}
189
- />
190
- ))}
191
- </div>
192
- <Pagination
193
- query={query}
194
- basePath={linkBase}
195
- page={page}
196
- pageSize={pageSize}
197
- total={total}
198
- />
199
- </>
200
- )}
201
- </div>
202
- </div>
203
-
204
- {hasContentBlock && (
205
- <section class="container-prose pb-16">
206
- <div class="rte mx-auto max-w-[68ch] text-[var(--color-text)]" set:html={contentBlockHtml} />
207
- </section>
208
- )}
209
- </Layout>
24
+ <CollectionView {...listing} query={query} pathPage={1} />
@@ -0,0 +1,251 @@
1
+ ---
2
+ /**
3
+ * PLP / collection view (brief §5), shared by `/collections/<h>/` (page 1) and
4
+ * `/collections/<h>/page/<n>/` so the two can't drift. Responsive grid,
5
+ * collection hero, sort, URL-addressable facets, numbered pagination + load-more.
6
+ * Hero copy from the collection record (Storyblok could override later —
7
+ * documented seam).
8
+ *
9
+ * The rendered results carry the key of the query they applied
10
+ * (`data-applied-query`). A static host serves this page for any query, so
11
+ * `CatalogQueryView` re-applies the URL's own query client-side whenever it
12
+ * differs from that key (lib/publication/shared/staticQuery.ts).
13
+ */
14
+ import type { ListProductsQuery, ListProductsResponse, CatalogCollection } from "@tot/public-runtime";
15
+ import Layout from "@/layouts/Layout.astro";
16
+ import Breadcrumbs from "@/components/content/storefront/Breadcrumbs.astro";
17
+ import { homeCrumbLabel, siteTrail } from "@/lib/content/shared/breadcrumbs";
18
+ import ProductCard from "@/components/catalog/storefront/ProductCard.astro";
19
+ import EmptyState from "@/components/content/storefront/EmptyState.astro";
20
+ import FacetSidebar from "@/components/catalog/storefront/plp/FacetSidebar.astro";
21
+ import ActiveFilters from "@/components/catalog/storefront/plp/ActiveFilters.astro";
22
+ import SortSelect from "@/components/catalog/storefront/plp/SortSelect.astro";
23
+ import Pagination from "@/components/catalog/storefront/plp/Pagination.astro";
24
+ import CatalogQueryView from "@/components/publication/storefront/islands/CatalogQueryView.tsx";
25
+ import Img from "@/components/assets/storefront/Img.astro";
26
+ import { buildQueryString } from "@/lib/publication/shared/url";
27
+ import { collectionPageCount, collectionPagePath, isPathPageable } from "@/lib/catalog/shared/collectionPaging";
28
+ import { listQueryKey } from "@/lib/publication/shared/staticQuery";
29
+ import { breadcrumbLd, itemListLd } from "@/lib/content/shared/jsonld";
30
+ import { truncate } from "@/lib/content/shared/format";
31
+ import { pageUrlForm } from "@/lib/content/seo/urlForm";
32
+ import { sourceSeoTitle } from "@/lib/content/seo/documentTitle";
33
+ import { withBase } from "@/lib/publication/shared/basePath";
34
+ import { isBlankDescriptionHtml, sanitizeDescriptionHtml } from "@/lib/content/sanitizeDescriptionHtml";
35
+
36
+ interface Props {
37
+ collection: CatalogCollection;
38
+ query: ListProductsQuery;
39
+ result: ListProductsResponse;
40
+ memberTotal: number;
41
+ /** The page the route's PATH names (1 for the bare collection path). */
42
+ pathPage: number;
43
+ }
44
+
45
+ const { collection, query, result, memberTotal, pathPage } = Astro.props;
46
+ const { tenant, canonicalBase } = Astro.locals;
47
+ const { handle } = collection;
48
+ const { products, page, pageSize, total, facets } = result;
49
+
50
+ // A synthesized-EMPTY collection (zero active members overall — not merely
51
+ // zero under the current facet/price/q filters) still resolves 200 for a direct
52
+ // link, but must not be indexed: it's a crawlable dead end, kept out of the
53
+ // sitemap + directory too. Detect it via the UNFILTERED membership total.
54
+ const isEmptyCollection = memberTotal === 0;
55
+ const basePath = pageUrlForm(collectionPagePath(handle, 1), tenant.seo);
56
+ // Tenant-prefixed page path for facet/sort/pagination, which build links by
57
+ // string-concatenating onto this base (so they stay inside a path-routed
58
+ // tenant). `basePath` itself stays unprefixed for the SEO canonical `path`,
59
+ // breadcrumbs, and EmptyState — those prefix on their own via withBase.
60
+ const linkBase = withBase(Astro.locals.basePath, basePath);
61
+
62
+ // Page N of the default view is its own indexable path variant; every other view
63
+ // keeps its query (noindex territory) and canonicalizes to the collection.
64
+ const pathPaged = isPathPageable(query);
65
+ const pagePath = (n: number) => pageUrlForm(collectionPagePath(handle, n), tenant.seo);
66
+ const pageCount = collectionPageCount(total, pageSize);
67
+ const path = pathPaged ? pagePath(page) : basePath;
68
+ const pagination = pathPaged
69
+ ? {
70
+ ...(page > 1 ? { prev: pagePath(page - 1) } : {}),
71
+ ...(page < pageCount ? { next: pagePath(page + 1) } : {}),
72
+ }
73
+ : undefined;
74
+ const pageHref = pathPaged ? (n: number) => withBase(Astro.locals.basePath, pagePath(n)) : undefined;
75
+
76
+ const crumbs = siteTrail(
77
+ homeCrumbLabel(await Astro.locals.content.getChrome()),
78
+ { label: collection.title, href: basePath },
79
+ );
80
+
81
+ const heroImg = collection.image;
82
+ const desc = collection.description_text || collection.description_html?.replace(/<[^>]+>/g, "");
83
+ // Full-fidelity body (brands/how-it-works/ingredients/FAQ sections on the merchant's
84
+ // live collection pages), sanitized for render-time defense-in-depth on top of the
85
+ // migration pipeline's own sanitize-html pass at ingestion (see
86
+ // sanitizeDescriptionHtml.ts). Rendered as its own block BELOW the product grid,
87
+ // matching the live site's layout — `desc` above stays the short hero excerpt.
88
+ const contentBlockHtml = sanitizeDescriptionHtml(collection.description_html);
89
+ const hasContentBlock = contentBlockHtml.length > 0 && !isBlankDescriptionHtml(contentBlockHtml);
90
+
91
+ // Zero-results recovery: differentiate the cause (search term vs. facet/price
92
+ // filters) and offer targeted "undo just this" links alongside the existing
93
+ // "clear everything" CTA, instead of one generic message + one broad reset.
94
+ const hasFacetOrPriceFilters =
95
+ !!query.filters ||
96
+ query.priceMin != null ||
97
+ query.priceMax != null ||
98
+ query.availability === "in-stock";
99
+ const zeroResultsTitle = query.q ? `No matches for "${query.q}"` : "Nothing matches — yet";
100
+ const zeroResultsBody = query.q
101
+ ? "Try a broader search term, or clear your filters to browse the full collection."
102
+ : "No pieces fit these filters. Loosen a filter or clear them to see the full collection.";
103
+ const zeroResultsActions: { label: string; href: string }[] = [];
104
+ if (query.q && hasFacetOrPriceFilters) {
105
+ const { q: _q, ...rest } = query;
106
+ zeroResultsActions.push({ label: "Clear search, keep filters", href: basePath + buildQueryString(rest) });
107
+ }
108
+ if (query.priceMin != null || query.priceMax != null) {
109
+ const { priceMin: _priceMin, priceMax: _priceMax, ...rest } = query;
110
+ zeroResultsActions.push({ label: "Remove price range", href: basePath + buildQueryString(rest) });
111
+ }
112
+
113
+ const baseTitle = collection.seo.title || collection.title;
114
+ const documentTitle = pathPaged && page > 1 ? `${baseTitle} — Page ${page}` : baseTitle;
115
+ const seoTitle = sourceSeoTitle(tenant.seo, collection.seo.title);
116
+ ---
117
+
118
+ <Layout
119
+ title={documentTitle}
120
+ seoTitle={seoTitle && pathPaged && page > 1 ? `${seoTitle} — Page ${page}` : seoTitle}
121
+ description={collection.seo.description || (desc ? truncate(desc, 160) : `Shop ${collection.title} at ${tenant.displayName}.`)}
122
+ path={path}
123
+ noindex={isEmptyCollection}
124
+ pagination={pagination}
125
+ jsonLd={[
126
+ breadcrumbLd(crumbs, canonicalBase),
127
+ itemListLd(products, canonicalBase, (page - 1) * pageSize),
128
+ ]}
129
+ structuredData={{
130
+ pageType: "collection",
131
+ description: collection.seo.description || undefined,
132
+ crumbs,
133
+ items: products.map((p) => ({ title: p.title, url: new URL(`/products/${p.handle}`, canonicalBase + "/").toString() })),
134
+ }}
135
+ >
136
+ {/* Collection hero */}
137
+ <header class="bg-[var(--color-primary)] text-[var(--color-primary-contrast)]">
138
+ <div class="container-prose grid gap-6 py-[clamp(2.5rem,6vw,4.5rem)] md:grid-cols-[1fr_auto] md:items-end">
139
+ <div data-reveal>
140
+ <p class="eyebrow mb-3 text-[color-mix(in_srgb,var(--color-primary-contrast)_70%,transparent)]">
141
+ Collection
142
+ </p>
143
+ <h1 class="display-xl">
144
+ {collection.title}
145
+ </h1>
146
+ {desc && (
147
+ <p class="mt-4 max-w-prose text-[1.05rem] text-[color-mix(in_srgb,var(--color-primary-contrast)_82%,transparent)]">
148
+ {truncate(desc, 220)}
149
+ </p>
150
+ )}
151
+ </div>
152
+ {heroImg && (
153
+ <div class="hidden w-48 overflow-hidden rounded-[var(--radius-md)] md:block" data-reveal>
154
+ <Img
155
+ src={heroImg.url}
156
+ rehosted={heroImg.rehosted}
157
+ seed={heroImg.id ?? collection.handle}
158
+ label={collection.title}
159
+ alt={heroImg.alt ?? collection.title}
160
+ width={heroImg.width ?? 400}
161
+ height={heroImg.height ?? 300}
162
+ />
163
+ </div>
164
+ )}
165
+ </div>
166
+ </header>
167
+
168
+ <div class="container-prose py-4">
169
+ <Breadcrumbs crumbs={crumbs} />
170
+ </div>
171
+
172
+ <div
173
+ class="container-prose grid gap-10 pb-16 lg:grid-cols-[230px_1fr]"
174
+ data-catalog-query-rendered
175
+ data-applied-query={listQueryKey(query)}
176
+ >
177
+ {/* Facets — sticky on desktop; a <details> drawer on mobile. */}
178
+ <div class="lg:sticky lg:top-24 lg:self-start">
179
+ <details class="lg:hidden">
180
+ <summary class="cursor-pointer rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-2.5 font-display text-sm font-medium">
181
+ Filters & sort
182
+ </summary>
183
+ <div class="mt-4">
184
+ <FacetSidebar facets={facets} query={query} basePath={linkBase} />
185
+ </div>
186
+ </details>
187
+ <div class="hidden lg:block">
188
+ <FacetSidebar facets={facets} query={query} basePath={linkBase} />
189
+ </div>
190
+ </div>
191
+
192
+ <div>
193
+ <div class="mb-5 flex flex-wrap items-center justify-between gap-3 border-b border-[var(--color-border)] pb-4">
194
+ <p class="text-sm text-[var(--color-muted)]">
195
+ {total} {total === 1 ? "item" : "items"}
196
+ </p>
197
+ <SortSelect query={query} basePath={linkBase} />
198
+ </div>
199
+
200
+ <ActiveFilters query={query} basePath={linkBase} />
201
+
202
+ {products.length === 0 ? (
203
+ <EmptyState
204
+ title={zeroResultsTitle}
205
+ body={zeroResultsBody}
206
+ ctaLabel="Clear all filters"
207
+ ctaHref={basePath}
208
+ secondaryActions={zeroResultsActions}
209
+ />
210
+ ) : (
211
+ <>
212
+ <div class="grid grid-cols-2 gap-5 sm:grid-cols-2 lg:grid-cols-3">
213
+ {products.map((p, i) => (
214
+ <ProductCard
215
+ product={p}
216
+ position={(page - 1) * pageSize + i}
217
+ list={collection.handle}
218
+ priority={i < 3 && page === 1}
219
+ />
220
+ ))}
221
+ </div>
222
+ <Pagination
223
+ query={query}
224
+ basePath={linkBase}
225
+ page={page}
226
+ pageSize={pageSize}
227
+ total={total}
228
+ pageHref={pageHref}
229
+ />
230
+ </>
231
+ )}
232
+ </div>
233
+ </div>
234
+
235
+ <CatalogQueryView
236
+ client:only="preact"
237
+ mode="collection"
238
+ collection={handle}
239
+ pathPage={pathPage}
240
+ linkBase={linkBase}
241
+ pageBase={withBase(Astro.locals.basePath, collectionPagePath(handle, 1))}
242
+ trailingSlash={!!tenant.seo?.trailingSlash}
243
+ locale={tenant.locale}
244
+ />
245
+
246
+ {hasContentBlock && (
247
+ <section class="container-prose pb-16">
248
+ <div class="rte mx-auto max-w-[68ch] text-[var(--color-text)]" set:html={contentBlockHtml} />
249
+ </section>
250
+ )}
251
+ </Layout>
@@ -34,7 +34,7 @@ const crumbs = siteTrail(
34
34
  description={`Browse every collection at ${tenant.displayName}.`}
35
35
  path="/collections"
36
36
  jsonLd={breadcrumbLd(crumbs, canonicalBase)}
37
- structuredData={{ pageType: "collection" }}
37
+ structuredData={{ pageType: "collection", crumbs }}
38
38
  >
39
39
  <div class="container-prose pt-6">
40
40
  <Breadcrumbs crumbs={crumbs} />
@@ -11,7 +11,7 @@ import { homeCrumbLabel, siteTrail } from "@/lib/content/shared/breadcrumbs";
11
11
  import Badge from "@/components/content/storefront/Badge.astro";
12
12
  import ProductCard from "@/components/catalog/storefront/ProductCard.astro";
13
13
  import Section from "@/components/content/storefront/Section.astro";
14
- import ImageGallery from "@/components/catalog/storefront/islands/ImageGallery.tsx";
14
+ import ImageGallery, { publicGalleryImages } from "@/components/catalog/storefront/islands/ImageGallery.tsx";
15
15
  import VariantSelector, { publicSelectorVariants } from "@/components/catalog/storefront/islands/VariantSelector.tsx";
16
16
  import CheckoutComplianceGate from "@/components/compliance/storefront/islands/CheckoutComplianceGate.tsx";
17
17
  import RecentlyViewed from "@/components/catalog/storefront/islands/RecentlyViewed.tsx";
@@ -44,6 +44,7 @@ import type {
44
44
  import { resolveCartSigning, shopperCheckoutUnavailableHtml } from "@/lib/checkout/shared/checkoutCommerce";
45
45
  import { isLocalSampleCheckout } from "@/config/declaredTenantConfig";
46
46
  import { isRegulatedTenant } from "@/lib/compliance/enforcement";
47
+ import { checkoutComplianceGateRequired } from "@/lib/checkout/commerce/sharedStoreAdmission";
47
48
  import { resolveMemberContext } from "@/lib/membership/eligibility";
48
49
  import { truncate } from "@/lib/content/shared/format";
49
50
  import { productLd, breadcrumbLd, reviewLd } from "@/lib/content/shared/jsonld";
@@ -232,12 +233,14 @@ const cartApiPath = workerCartApiPath({
232
233
  let checkoutCart:
233
234
  | {
234
235
  action: string;
236
+ method: "get" | "post";
235
237
  purchaseOptions: ProductPurchaseOption[];
236
238
  byVariantId: Record<string, Record<string, VariantCartPayload>>;
237
239
  }
238
240
  | undefined;
239
241
  let checkoutUnavailable = false;
240
242
  const sampleHandoff = isLocalSampleCheckout(tenant.tenant_id);
243
+ const complianceGateRequired = await checkoutComplianceGateRequired(tenant, commerce, Astro.locals.customizationEnv);
241
244
  if (commerce || sampleHandoff) {
242
245
  // Fail closed for production signed checkout: a signed tenant with no
243
246
  // cart-validation secret builds NO cart (never a tamperable form). See
@@ -265,6 +268,7 @@ if (commerce || sampleHandoff) {
265
268
  pricing: pricingCtx,
266
269
  autopilotEnabled,
267
270
  tenantId: commerceTenantId(tenant),
271
+ currency: tenant.currency,
268
272
  }),
269
273
  ] as const;
270
274
  }),
@@ -275,6 +279,7 @@ if (commerce || sampleHandoff) {
275
279
  );
276
280
  checkoutCart = {
277
281
  action: sampleHandoff ? SAMPLE_CART_HANDOFF_URL : cartApiPath,
282
+ method: sampleHandoff ? "get" : "post",
278
283
  purchaseOptions,
279
284
  byVariantId: Object.fromEntries(entries),
280
285
  };
@@ -345,7 +350,7 @@ const structuredData = {
345
350
  <div>
346
351
  <ImageGallery
347
352
  client:idle
348
- images={product.images}
353
+ images={publicGalleryImages(product.images)}
349
354
  title={product.title}
350
355
  productHandle={product.handle}
351
356
  />
@@ -354,8 +359,8 @@ const structuredData = {
354
359
  {/* Buy column */}
355
360
  <div class="md:pt-2">
356
361
  <div class="flex flex-wrap items-center gap-2">
357
- {onSale && <Badge variant="sale" label="Sale" />}
358
- {!onSale && isNew(product) && <Badge variant="new" label="New" />}
362
+ {Astro.locals.features.productBadges && onSale && <Badge variant="sale" label="Sale" />}
363
+ {Astro.locals.features.productBadges && !onSale && isNew(product) && <Badge variant="new" label="New" />}
359
364
  {!available && <Badge variant="soldout" label="Sold out" />}
360
365
  </div>
361
366
 
@@ -420,13 +425,18 @@ const structuredData = {
420
425
  productTitle={product.title}
421
426
  checkoutCart={checkoutCart}
422
427
  pricing={pricing}
423
- handoffUrl={sampleHandoff ? undefined : cartApiPath}
428
+ handoffUrl={
429
+ !sampleHandoff &&
430
+ (Astro.locals.browserEnvironment.environment === "live" || Astro.locals.browserEnvironment.environment === "local")
431
+ ? cartApiPath
432
+ : undefined
433
+ }
424
434
  />
425
435
  {/* Regulated-tenant checkout ENFORCEMENT gate. Intercepts the
426
436
  managed-cart handoff to clear age + ship-to compliance server-side
427
- before the external checkout. Only mounts when a store is wired AND
428
- the tenant is regulated. */}
429
- {checkoutCart && !sampleHandoff && isRegulatedTenant(tenant) && compliance?.minAge && (
437
+ before the external checkout. Only mounts when a store is wired, the
438
+ tenant is regulated, and that store's policy requires a check. */}
439
+ {checkoutCart && !sampleHandoff && isRegulatedTenant(tenant) && compliance?.minAge && complianceGateRequired && (
430
440
  <CheckoutComplianceGate
431
441
  client:load
432
442
  preflightUrl={withBase(apiBasePath, "/api/compliance/preflight")}