@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.
- package/agency-kit/tools/lib/public-binaries.mjs +95 -0
- package/apps/storefront/astro.config.mjs +7 -0
- package/apps/storefront/env.d.ts +3 -0
- package/apps/storefront/src/components/catalog/storefront/ProductCard.astro +5 -4
- package/apps/storefront/src/components/catalog/storefront/commerce/PriceDisplay.astro +2 -1
- package/apps/storefront/src/components/catalog/storefront/islands/ImageGallery.tsx +20 -3
- package/apps/storefront/src/components/catalog/storefront/islands/SearchTypeahead.tsx +17 -20
- package/apps/storefront/src/components/catalog/storefront/islands/VariantSelector.tsx +13 -21
- package/apps/storefront/src/components/catalog/storefront/plp/Pagination.astro +9 -5
- package/apps/storefront/src/components/checkout/storefront/commerce/CheckoutLoader.astro +18 -6
- package/apps/storefront/src/components/compliance/storefront/islands/CheckoutComplianceGate.tsx +19 -4
- package/apps/storefront/src/components/content/storefront/Badge.astro +1 -0
- package/apps/storefront/src/components/content/storefront/blog/BlogIndexView.astro +10 -1
- package/apps/storefront/src/components/publication/storefront/islands/CatalogQueryView.tsx +321 -0
- package/apps/storefront/src/config/declaredTenantConfig.ts +59 -27
- package/apps/storefront/src/config/devTenantSeed.ts +2 -1
- package/apps/storefront/src/layouts/Layout.astro +1 -1
- package/apps/storefront/src/lib/access/auth/gatePage.ts +29 -14
- package/apps/storefront/src/lib/access/shared/publicAccess.ts +95 -0
- package/apps/storefront/src/lib/access/shared/tenantRouting.ts +19 -0
- package/apps/storefront/src/lib/catalog/shared/catalogIndex.ts +109 -0
- package/apps/storefront/src/lib/catalog/shared/catalogQueryView.ts +60 -0
- package/apps/storefront/src/lib/catalog/shared/collectionPaging.ts +78 -0
- package/apps/storefront/src/lib/catalog/shared/stockStatus.ts +23 -0
- package/apps/storefront/src/lib/catalog/tot/query.ts +8 -0
- package/apps/storefront/src/lib/checkout/browser/checkoutRequirementsForm.ts +54 -0
- package/apps/storefront/src/lib/checkout/commerce/checkoutHandoffRoute.ts +103 -35
- package/apps/storefront/src/lib/checkout/commerce/checkoutRequirements.ts +88 -0
- package/apps/storefront/src/lib/checkout/commerce/finalCartAuthorization.ts +2 -0
- package/apps/storefront/src/lib/checkout/commerce/sharedStoreAdmission.ts +95 -0
- package/apps/storefront/src/lib/checkout/shared/lazyCheckoutLoader.ts +191 -0
- package/apps/storefront/src/lib/compliance/browser/checkoutVerificationFlow.ts +9 -0
- package/apps/storefront/src/lib/compliance/enforcement.ts +7 -5
- package/apps/storefront/src/lib/content/seo/structuredData.ts +12 -3
- package/apps/storefront/src/lib/content/storyblok/provider.ts +14 -7
- package/apps/storefront/src/lib/observability/shared/serverTiming.ts +110 -0
- package/apps/storefront/src/lib/publication/shared/edgeCache.ts +3 -4
- package/apps/storefront/src/lib/publication/shared/staticQuery.ts +64 -0
- package/apps/storefront/src/middleware/controlPlane.ts +14 -11
- package/apps/storefront/src/middleware/index.ts +33 -12
- package/apps/storefront/src/middleware/renderChain.ts +52 -26
- package/apps/storefront/src/pages/[...slug].astro +3 -0
- package/apps/storefront/src/pages/api/commerce/final-cart-authorization.ts +112 -42
- package/apps/storefront/src/pages/api/compliance/preflight.ts +61 -13
- package/apps/storefront/src/pages/blog/author/[author].astro +1 -1
- package/apps/storefront/src/pages/blog/category/[category].astro +1 -1
- package/apps/storefront/src/pages/blog/tag/[tag].astro +1 -1
- package/apps/storefront/src/pages/catalog-index.json.ts +20 -0
- package/apps/storefront/src/pages/collections/[handle]/page/[n].astro +28 -0
- package/apps/storefront/src/pages/collections/[handle].astro +13 -198
- package/apps/storefront/src/pages/collections/_CollectionView.astro +251 -0
- package/apps/storefront/src/pages/collections/index.astro +1 -1
- package/apps/storefront/src/pages/products/[handle].astro +18 -8
- package/apps/storefront/src/pages/search.astro +33 -33
- package/apps/storefront/src/pages/sitemap.xml.ts +7 -1
- package/apps/storefront/vitest.config.cts +3 -0
- package/package.json +1 -1
- package/packages/cli/src/declared-config.mjs +318 -32
- package/packages/cli/src/publication-spaces.mjs +1 -1
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +1 -1
- package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +1 -1
- package/packages/public-runtime/src/activity/catalog.ts +8 -1
- package/packages/public-runtime/src/candidate-index.ts +5 -0
- package/packages/public-runtime/src/capabilities.ts +12 -9
- package/packages/public-runtime/src/capture-rewrites.ts +687 -0
- package/packages/public-runtime/src/checkout-orders.ts +6 -7
- package/packages/public-runtime/src/checkout.ts +190 -6
- package/packages/public-runtime/src/compliance/evaluate.ts +7 -3
- package/packages/public-runtime/src/compliance/profile.ts +90 -14
- package/packages/public-runtime/src/compliance-coherence.d.mts +10 -1
- package/packages/public-runtime/src/compliance-coherence.mjs +129 -29
- package/packages/public-runtime/src/customization-runtime.ts +64 -2
- package/packages/public-runtime/src/declared-tenant-config.d.mts +13 -2
- package/packages/public-runtime/src/declared-tenant-config.mjs +83 -1
- package/packages/public-runtime/src/image-preview-rewrite-overlay.ts +13 -37
- package/packages/public-runtime/src/membership.ts +3 -0
- package/packages/public-runtime/src/publication-space.ts +1 -0
- package/packages/public-runtime/src/tenant.ts +28 -4
- package/scripts/tenant/validate.mjs +17 -8
|
@@ -1,209 +1,24 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
|
-
* PLP / collection page
|
|
4
|
-
*
|
|
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
|
|
9
|
-
import
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
33
|
-
|
|
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
|
-
<
|
|
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={
|
|
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
|
|
428
|
-
|
|
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")}
|