@tokenoftrust/storefront-runner 2.5.6 → 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 (79) 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/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,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} />
@@ -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
  };
@@ -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
 
@@ -429,9 +434,9 @@ const structuredData = {
429
434
  />
430
435
  {/* Regulated-tenant checkout ENFORCEMENT gate. Intercepts the
431
436
  managed-cart handoff to clear age + ship-to compliance server-side
432
- before the external checkout. Only mounts when a store is wired AND
433
- the tenant is regulated. */}
434
- {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 && (
435
440
  <CheckoutComplianceGate
436
441
  client:load
437
442
  preflightUrl={withBase(apiBasePath, "/api/compliance/preflight")}
@@ -1,13 +1,15 @@
1
1
  ---
2
2
  /**
3
- * /search — dual-purpose route (brief §5 Search).
3
+ * /search — the full results page: a no-JS <form>, the echoed query, a result
4
+ * count, and a product grid. Search runs through the ToTClient (D1 FTS5 when
5
+ * bound, else a Fuse index over the snapshot) over ACTIVE products only (drafts
6
+ * never surface). Results pages are `noindex` (standard practice — thin,
7
+ * parameterized, duplicative).
4
8
  *
5
- * 1. `?format=json` — returns `{ products: TypeaheadDoc[] }` for the header
6
- * typeahead island. Search runs through the ToTClient (D1 FTS5 when bound, else
7
- * a Fuse index over the snapshot) over ACTIVE products only (drafts never surface).
8
- * 2. Otherwise — the full results page: a no-JS <form>, the echoed query, a
9
- * result count, and a product grid. Results pages are `noindex` (standard
10
- * practice — thin, parameterized, duplicative).
9
+ * A static host serves this page without its `?q=`, so the rendered results
10
+ * carry the query they applied (`data-applied-query`) and CatalogQueryView
11
+ * searches the static catalog index in the browser whenever the URL asks for a
12
+ * different one. The header typeahead searches that same index.
11
13
  */
12
14
  import Layout from "@/layouts/Layout.astro";
13
15
  import Breadcrumbs from "@/components/content/storefront/Breadcrumbs.astro";
@@ -15,32 +17,21 @@ import { homeCrumbLabel, siteTrail } from "@/lib/content/shared/breadcrumbs";
15
17
  import ProductCard from "@/components/catalog/storefront/ProductCard.astro";
16
18
  import EmptyState from "@/components/content/storefront/EmptyState.astro";
17
19
  import Section from "@/components/content/storefront/Section.astro";
18
- import { toTypeaheadDoc } from "@/lib/catalog/search/index";
20
+ import CatalogQueryView from "@/components/publication/storefront/islands/CatalogQueryView.tsx";
21
+ import { SEARCH_RESULT_LIMIT } from "@/lib/catalog/shared/catalogQueryView";
22
+ import { searchQueryFromUrl } from "@/lib/publication/shared/staticQuery";
19
23
  import { breadcrumbLd } from "@/lib/content/shared/jsonld";
20
24
  import { withBase } from "@/lib/publication/shared/basePath";
21
25
 
26
+ // `?q=` never reaches a renderer on a static host: CatalogQueryView applies it there.
27
+ export const STATIC_QUERY = "client-applied";
28
+
22
29
  const { tenant, tot, canonicalBase, basePath } = Astro.locals;
23
- // Commerce-only route (page + typeahead JSON): off for marketing tenants.
30
+ // Commerce-only route: off for marketing tenants.
24
31
  if (!Astro.locals.features.productSearch) return new Response(null, { status: 404 });
25
- const url = new URL(Astro.request.url);
26
- const q = (url.searchParams.get("q") ?? "").trim();
27
- const isJson = url.searchParams.get("format") === "json";
28
-
29
- // Search goes through the ToTClient: D1 FTS5 when a D1 binding is wired, else a
30
- // Fuse index over the bundled snapshot (FixtureToTClient). It returns full
31
- // products, so the HTML grid renders directly and the typeahead just projects to
32
- // the compact shape.
33
-
34
- // --- JSON path: powers the header typeahead island. Must return BEFORE render.
35
- if (isJson) {
36
- const docs = (await tot.search(q, 8)).map(toTypeaheadDoc);
37
- return new Response(JSON.stringify({ products: docs }), {
38
- headers: { "content-type": "application/json" },
39
- });
40
- }
32
+ const q = searchQueryFromUrl(Astro.url);
41
33
 
42
- // --- HTML path.
43
- const results = q ? await tot.search(q, 24) : [];
34
+ const results = q ? await tot.search(q, SEARCH_RESULT_LIMIT) : [];
44
35
  const featured = q ? [] : await tot.getFeatured(8);
45
36
 
46
37
  const crumbs = siteTrail(
@@ -63,7 +54,7 @@ const crumbs = siteTrail(
63
54
  {/* Search hero + no-JS form (works without the island). */}
64
55
  <header class="container-prose pb-2 pt-8">
65
56
  <p class="eyebrow eyebrow--tick mb-3">Search</p>
66
- <h1 class="display-xl">
57
+ <h1 class="display-xl" data-search-heading>
67
58
  {q ? <>Results for “{q}”</> : "What are you after?"}
68
59
  </h1>
69
60
  <form
@@ -103,13 +94,14 @@ const crumbs = siteTrail(
103
94
  Search
104
95
  </button>
105
96
  </form>
106
- {q && (
107
- <p class="mt-4 text-sm text-[var(--color-muted)]">
108
- {results.length} {results.length === 1 ? "result" : "results"}
109
- </p>
110
- )}
111
97
  </header>
112
98
 
99
+ <div data-catalog-query-rendered data-applied-query={q}>
100
+ {q && (
101
+ <p class="container-prose mt-4 text-sm text-[var(--color-muted)]">
102
+ {results.length} {results.length === 1 ? "result" : "results"}
103
+ </p>
104
+ )}
113
105
  {q ? (
114
106
  results.length === 0 ? (
115
107
  <EmptyState
@@ -148,4 +140,12 @@ const crumbs = siteTrail(
148
140
  )}
149
141
  </>
150
142
  )}
143
+ </div>
144
+
145
+ <CatalogQueryView
146
+ client:only="preact"
147
+ mode="search"
148
+ trailingSlash={!!tenant.seo?.trailingSlash}
149
+ locale={tenant.locale}
150
+ />
151
151
  </Layout>
@@ -9,7 +9,8 @@
9
9
  * loc is prefix-correct without special-casing here.
10
10
  */
11
11
  import type { APIContext } from "astro";
12
- import { nonEmptyCollections } from "@/lib/catalog/tot/query.js";
12
+ import { activeMemberCount, nonEmptyCollections } from "@/lib/catalog/tot/query.js";
13
+ import { collectionPageCount, collectionPagePath } from "@/lib/catalog/shared/collectionPaging.js";
13
14
  import { getBlogSettings, listBlogPosts } from "@/lib/content/blog/provider.js";
14
15
  import { authorSlug, categorySlug } from "@/lib/content/blog/collection.js";
15
16
  import { resolveBlogIndexCopy } from "@/lib/content/blog/pagination.js";
@@ -64,6 +65,11 @@ export async function GET(context: APIContext): Promise<Response> {
64
65
  urls.push({ loc: `${base}/collections` });
65
66
  for (const c of collections) {
66
67
  urls.push({ loc: `${base}/collections/${c.handle}` });
68
+ // Paginated pages (N≥2), same reasoning as /blog/page/N/ below: listing them
69
+ // is what makes every product of a large collection reachable on both
70
+ // serving planes, which publish one document per path.
71
+ const pages = collectionPageCount(activeMemberCount(c, products));
72
+ for (let n = 2; n <= pages; n++) urls.push({ loc: `${base}${collectionPagePath(c.handle, n)}` });
67
73
  }
68
74
  for (const p of products) {
69
75
  const entry: SitemapUrl = { loc: `${base}/products/${p.handle}` };
@@ -31,5 +31,8 @@ module.exports = defineConfig({
31
31
  include: ["src/**/*.test.ts", "e2e/a11y/**/*.test.ts"],
32
32
  environment: "node",
33
33
  cache: false,
34
+ // Folds and CI run many suites at once; a fixture-heavy test that takes 2s alone can pass 5s
35
+ // under that load. A genuinely hung test still fails, only later.
36
+ testTimeout: 30_000,
34
37
  },
35
38
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.5.6",
3
+ "version": "3.0.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",