@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.
- package/agency-kit/tools/lib/public-binaries.mjs +111 -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/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 +4 -2
- 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 +10 -5
- 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
|
@@ -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
|
|
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
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
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
|
|
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} />
|