@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
@@ -34,6 +34,7 @@ import {
34
34
  type TotVerificationClient,
35
35
  type TotVerificationStatus,
36
36
  } from "@/lib/compliance/verification/totVerificationClient";
37
+ import { sharedStorePolicy } from "@/lib/checkout/commerce/sharedStoreAdmission";
37
38
 
38
39
  export const prerender = false;
39
40
 
@@ -268,8 +269,7 @@ function withProviderCleared(ruleset: ComplianceRuleset): ComplianceRuleset {
268
269
  }
269
270
 
270
271
  export async function POST(context: APIContext): Promise<Response> {
271
- const { tenant, tot } = context.locals;
272
- const regulated = isRegulatedTenant(tenant);
272
+ const regulated = isRegulatedTenant(context.locals.tenant);
273
273
 
274
274
  let body: PreflightBody;
275
275
  try {
@@ -281,7 +281,45 @@ export async function POST(context: APIContext): Promise<Response> {
281
281
  : json({ decision: evaluateEmpty() });
282
282
  }
283
283
 
284
+ // The shared test store applies the store's own saved requirements, computed here from the
285
+ // tenant's config — never from the request, so a shopper cannot waive one.
286
+ const policy = await sharedStorePolicy(context.locals.tenant, context.locals.commerce, context.locals.customizationEnv);
287
+ return json(await evaluatePreflight(context, body, policy ? { verification: policy.verification, rules: policy.rules } : {}));
288
+ }
289
+
290
+ export interface PreflightOptions {
291
+ /**
292
+ * Server-owned verification requirement, from the shared test store's policy for this
293
+ * tenant. "required" requires verification even where the ruleset would not; absent keeps
294
+ * the ruleset's own. Never read from the request body.
295
+ */
296
+ verification?: "required" | "not-required";
297
+ /**
298
+ * Server-owned rule enforcement, from the same policy. "off" keeps only the verification
299
+ * outcome: destination, flavor, purchase-limit and excise blocks do not apply.
300
+ */
301
+ rules?: "enforce" | "off";
302
+ }
303
+
304
+ export interface PreflightResult {
305
+ decision: CartComplianceDecision;
306
+ verification?: LiveVerificationMeta;
307
+ }
308
+
309
+ /** The preflight decision for `body`, evaluated server-side against the tenant's ruleset. */
310
+ export async function evaluatePreflight(
311
+ context: APIContext,
312
+ body: PreflightBody,
313
+ options: PreflightOptions = {},
314
+ ): Promise<PreflightResult> {
315
+ const { tenant, tot } = context.locals;
316
+ const regulated = isRegulatedTenant(tenant);
317
+
284
318
  let ruleset = await resolveTenantRuleset(tenant);
319
+ if (ruleset && options.verification) {
320
+ ruleset = { ...ruleset, verification: { ...ruleset.verification, required: options.verification === "required" } };
321
+ }
322
+ const rulesOff = options.rules === "off";
285
323
  let verification = normalizeVerification(body.verification);
286
324
  const destination = normalizeDestination(body.destination);
287
325
  let liveVerification: LiveVerificationMeta | undefined;
@@ -291,7 +329,7 @@ export async function POST(context: APIContext): Promise<Response> {
291
329
  // not evidence) and the affirmation shortcut below is unreachable — "tot" is
292
330
  // never in SANDBOX_VERIFICATION_PROVIDERS. Any adapter failure clears the
293
331
  // provider so the engine blocks fail-closed, exactly as when none is wired.
294
- if (ruleset && isLiveTotVerificationProvider(ruleset.verification.provider)) {
332
+ if (ruleset && ruleset.verification.required && isLiveTotVerificationProvider(ruleset.verification.provider)) {
295
333
  const resolved = await resolveLiveVerification(body);
296
334
  if (!resolved) {
297
335
  ruleset = withProviderCleared(ruleset);
@@ -330,15 +368,12 @@ export async function POST(context: APIContext): Promise<Response> {
330
368
  if (!product) {
331
369
  // A line we can't resolve on a regulated tenant is a fail-closed block:
332
370
  // we can't prove it's shippable, so we don't let it through.
333
- if (regulated) {
334
- return json(
335
- {
336
- decision: blockedDecision(
337
- `Product "${raw.handle}" could not be resolved for compliance evaluation.`,
338
- ),
339
- },
340
- 200,
341
- );
371
+ if (regulated && !rulesOff) {
372
+ return {
373
+ decision: blockedDecision(
374
+ `Product "${raw.handle}" could not be resolved for compliance evaluation.`,
375
+ ),
376
+ };
342
377
  }
343
378
  continue;
344
379
  }
@@ -364,7 +399,20 @@ export async function POST(context: APIContext): Promise<Response> {
364
399
  ageAffirmed: body.ageAffirmed === true,
365
400
  });
366
401
 
367
- return json(liveVerification ? { decision, verification: liveVerification } : { decision });
402
+ const applied = rulesOff ? verificationOnly(decision) : decision;
403
+ return liveVerification ? { decision: applied, verification: liveVerification } : { decision: applied };
404
+ }
405
+
406
+ /** The decision with only its verification outcome: every rule block removed, status recomputed. */
407
+ function verificationOnly(decision: CartComplianceDecision): CartComplianceDecision {
408
+ const blocks = decision.blocks.filter((block) => block.code.startsWith("verification_"));
409
+ return {
410
+ ...decision,
411
+ blocks,
412
+ purchaseLimit: null,
413
+ failedClosed: blocks.some((block) => block.failedClosed === true),
414
+ status: blocks.some((block) => block.code !== "verification_required") ? "blocked" : blocks.length ? "action_required" : "clear",
415
+ };
368
416
  }
369
417
 
370
418
  /** The trivial "nothing to enforce" decision for an unregulated empty request. */
@@ -70,7 +70,7 @@ const crumbs = breadcrumbLd(trail, canonicalBase);
70
70
  path={blogPaths.author(slug)}
71
71
  fullBleed
72
72
  jsonLd={[crumbs, profileLd, itemListLd]}
73
- structuredData={{ pageType: "collection" }}
73
+ structuredData={{ pageType: "collection", crumbs: trail, items: posts.map((post) => ({ title: post.title, url: new URL(blogPaths.post(post.slug), canonicalBase + "/").toString() })) }}
74
74
  >
75
75
  <header class:list={["author-hero", { "author-hero--plain": !presentation.hero }]}>
76
76
  <div class="blog-shell">
@@ -48,7 +48,7 @@ const crumbs = breadcrumbLd(trail, canonicalBase);
48
48
  path={path}
49
49
  fullBleed
50
50
  jsonLd={[crumbs, itemList]}
51
- structuredData={{ pageType: "collection" }}
51
+ structuredData={{ pageType: "collection", crumbs: trail, items: posts.map((post) => ({ title: post.title, url: new URL(blogPaths.post(post.slug), canonicalBase + "/").toString() })) }}
52
52
  >
53
53
  <header class:list={["blog-hero", { "blog-hero--plain": !presentation.hero }]}>
54
54
  <div class="blog-shell">
@@ -46,7 +46,7 @@ const crumbs = breadcrumbLd(trail, canonicalBase);
46
46
  path={path}
47
47
  fullBleed
48
48
  jsonLd={[crumbs, itemList]}
49
- structuredData={{ pageType: "collection" }}
49
+ structuredData={{ pageType: "collection", crumbs: trail, items: posts.map((post) => ({ title: post.title, url: new URL(blogPaths.post(post.slug), canonicalBase + "/").toString() })) }}
50
50
  >
51
51
  <header class:list={["blog-hero", { "blog-hero--plain": !presentation.hero }]}>
52
52
  <div class="blog-shell">
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Per-tenant static catalog index (lib/catalog/shared/catalogIndex.ts): what the
3
+ * browser filters, sorts and searches when a static host serves a listing or
4
+ * search page whose query no renderer saw. Published alongside the pages it
5
+ * serves; commerce-only.
6
+ */
7
+ import type { APIContext } from "astro";
8
+ import { buildCatalogIndex } from "@/lib/catalog/shared/catalogIndex.js";
9
+
10
+ export async function GET(context: APIContext): Promise<Response> {
11
+ const { tot, features } = context.locals;
12
+ if (!features.catalog && !features.productSearch) return new Response(null, { status: 404 });
13
+ const [products, collections] = await Promise.all([tot.getAllActiveProducts(), tot.listCollections()]);
14
+ return new Response(JSON.stringify(buildCatalogIndex(products, collections)), {
15
+ headers: {
16
+ "content-type": "application/json; charset=utf-8",
17
+ "cache-control": "public, max-age=300, s-maxage=3600",
18
+ },
19
+ });
20
+ }
@@ -0,0 +1,28 @@
1
+ ---
2
+ /**
3
+ * `/collections/<h>/page/<n>/` — page N≥2 of a collection, the indexable path
4
+ * variant every static host can serve (ADR 0013: paging is N-bounded). Page 1
5
+ * lives at the bare collection path; both render through _CollectionView.
6
+ */
7
+ import CollectionView from "../../_CollectionView.astro";
8
+ import { collectionPageCount, loadCollectionListing } from "@/lib/catalog/shared/collectionPaging";
9
+ import { effectiveCollectionQuery } from "@/lib/publication/shared/staticQuery";
10
+ import { parsePagedRouteParam } from "@/lib/content/blog/pagination";
11
+
12
+ // Same query contract as page 1: sort/facets/price/search are applied client-side
13
+ // on a static host, the page number comes from the path.
14
+ export const STATIC_QUERY = "client-applied";
15
+
16
+ const { handle } = Astro.params;
17
+ if (!Astro.locals.features.catalog) return new Response(null, { status: 404 });
18
+ const pathPage = parsePagedRouteParam(Astro.params.n);
19
+ if (!handle || pathPage === null) return new Response(null, { status: 404 });
20
+
21
+ const query = effectiveCollectionQuery(Astro.url, pathPage);
22
+ const listing = await loadCollectionListing(Astro.locals.tot, handle, query);
23
+ if (!listing) return new Response(null, { status: 404 });
24
+ // A page past the collection's end is a real 404, never a clamped copy of the last page.
25
+ if (pathPage > collectionPageCount(listing.memberTotal)) return new Response(null, { status: 404 });
26
+ ---
27
+
28
+ <CollectionView {...listing} query={query} pathPage={pathPage} />
@@ -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} />