@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.
- 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/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
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant binaries never live in the tenant repo. The platform's content-addressed `/_a/<sha256>`
|
|
3
|
+
* pool owns them and the store's repo holds references only. Platform capture of catalog images
|
|
4
|
+
* is pending: until it owns them, an image stays at its source URL, go-live requires 0 uncaptured
|
|
5
|
+
* catalog images, and the source store is never shut down before that check passes. This decides,
|
|
6
|
+
* from a listing of the store's `public/` tree, whether the repo is carrying binaries it must not.
|
|
7
|
+
*
|
|
8
|
+
* Three findings, each naming its remedy:
|
|
9
|
+
*
|
|
10
|
+
* - `catalog-images-in-repo` — any file under `public/catalog-images/`, the directory a catalog
|
|
11
|
+
* image download writes. Decided from paths alone, so a caller without sizes still refuses it.
|
|
12
|
+
* - `public-binary-oversized` — one raster or media file over {@link PUBLIC_BINARY_LIMITS}
|
|
13
|
+
* `maxFileBytes`.
|
|
14
|
+
* - `public-tree-over-budget` — the whole `public/` tree over `maxTotalBytes`.
|
|
15
|
+
*
|
|
16
|
+
* The size findings need `bytes`; an entry without it is judged by path only.
|
|
17
|
+
*
|
|
18
|
+
* Budget basis (measured 2026-10-20 on three live store repos' `preview` branches): public/ held
|
|
19
|
+
* 17.2 MB (384 files, largest 0.9 MB), 16.5 MB (largest 0.4 MB) and 0.2 MB outside
|
|
20
|
+
* catalog-images/. No legitimate file exceeded 1 MB.
|
|
21
|
+
*
|
|
22
|
+
* Pure: no filesystem, no network, no imports, so it runs in Node and in the Worker.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** @type {{ maxFileBytes: number, maxTotalBytes: number }} */
|
|
26
|
+
export const PUBLIC_BINARY_LIMITS = Object.freeze({
|
|
27
|
+
maxFileBytes: 1024 * 1024,
|
|
28
|
+
maxTotalBytes: 32 * 1024 * 1024,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/** The directory under `public/` a catalog image download writes. */
|
|
32
|
+
export const CATALOG_IMAGES_DIR = "catalog-images/";
|
|
33
|
+
|
|
34
|
+
/** The finding code for files under {@link CATALOG_IMAGES_DIR}. */
|
|
35
|
+
export const CATALOG_IMAGES_FINDING_CODE = "catalog-images-in-repo";
|
|
36
|
+
|
|
37
|
+
/** The kit command that moves rehosted catalog image references back to their source URLs. */
|
|
38
|
+
export const UNHOST_IMAGES_COMMAND = "node agency-kit/tools/import-catalog.mjs --store <checkout> --unhost-images --write";
|
|
39
|
+
|
|
40
|
+
const RASTER_OR_MEDIA = /\.(?:jpe?g|png|gif|webp|avif|bmp|tiff?|heic|heif|ico|jxl|mp4|m4v|mov|webm|mkv|avi|mpe?g|mp3|wav|ogg|oga|m4a|aac|flac|opus)$/i;
|
|
41
|
+
|
|
42
|
+
const mb = (bytes) => `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {ReadonlyArray<{ rel: string, bytes?: number }>} entries files under `public/`, `rel`
|
|
46
|
+
* relative to it (`catalog-images/ab.jpg`)
|
|
47
|
+
* @param {{ maxFileBytes?: number, maxTotalBytes?: number }} [limits]
|
|
48
|
+
* @returns {Array<{ code: string, path: string, message: string, remedy: string }>}
|
|
49
|
+
*/
|
|
50
|
+
export function publicBinaryFindings(entries, limits = {}) {
|
|
51
|
+
const maxFileBytes = limits.maxFileBytes ?? PUBLIC_BINARY_LIMITS.maxFileBytes;
|
|
52
|
+
const maxTotalBytes = limits.maxTotalBytes ?? PUBLIC_BINARY_LIMITS.maxTotalBytes;
|
|
53
|
+
const findings = [];
|
|
54
|
+
const catalogImages = entries.filter((e) => e.rel.startsWith(CATALOG_IMAGES_DIR));
|
|
55
|
+
if (catalogImages.length > 0) {
|
|
56
|
+
const bytes = catalogImages.reduce((n, e) => n + (e.bytes ?? 0), 0);
|
|
57
|
+
findings.push({
|
|
58
|
+
code: CATALOG_IMAGES_FINDING_CODE,
|
|
59
|
+
path: `public/${CATALOG_IMAGES_DIR}`,
|
|
60
|
+
message: `${catalogImages.length} catalog image file(s)${bytes ? ` (${mb(bytes)})` : ""} are committed to the store repo; ` +
|
|
61
|
+
"images belong in the platform pool, not the repo; until platform capture owns them, keep each image at its source URL",
|
|
62
|
+
remedy: UNHOST_IMAGES_COMMAND,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
for (const e of entries) {
|
|
66
|
+
if (e.rel.startsWith(CATALOG_IMAGES_DIR) || e.bytes === undefined) continue;
|
|
67
|
+
if (e.bytes > maxFileBytes && RASTER_OR_MEDIA.test(e.rel)) {
|
|
68
|
+
findings.push({
|
|
69
|
+
code: "public-binary-oversized",
|
|
70
|
+
path: `public/${e.rel}`,
|
|
71
|
+
message: `${mb(e.bytes)} raster/media file exceeds the ${mb(maxFileBytes)} per-file limit for the store repo; ` +
|
|
72
|
+
"images and media belong in the platform pool",
|
|
73
|
+
remedy: "reference the file by its source URL, or compress it under the limit",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (entries.every((e) => e.bytes !== undefined)) {
|
|
78
|
+
const total = entries.reduce((n, e) => n + /** @type {number} */ (e.bytes), 0);
|
|
79
|
+
if (total > maxTotalBytes) {
|
|
80
|
+
findings.push({
|
|
81
|
+
code: "public-tree-over-budget",
|
|
82
|
+
path: "public/",
|
|
83
|
+
message: `public/ holds ${mb(total)} across ${entries.length} file(s), over the ${mb(maxTotalBytes)} store-repo budget; ` +
|
|
84
|
+
"images and media belong in the platform pool",
|
|
85
|
+
remedy: "reference large or bulk assets by their source URL instead of committing them",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return findings;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** One line per finding, remedy included: `<path>: [<code>] <message> — fix: <remedy>`. */
|
|
93
|
+
export function describePublicBinaryFindings(findings) {
|
|
94
|
+
return findings.map((f) => `${f.path}: [${f.code}] ${f.message} — fix: ${f.remedy}`);
|
|
95
|
+
}
|
|
@@ -95,6 +95,13 @@ export default defineConfig({
|
|
|
95
95
|
// Local image/asset placeholders are served from /public; real product
|
|
96
96
|
// images get rehosted to R2 in Phase 1 ETL (see DECISIONS.md).
|
|
97
97
|
imageService: "compile",
|
|
98
|
+
// `astro preview` of the built Worker for the fold-gate loopback
|
|
99
|
+
// (scripts/publish/fold-gate-loopback.mjs): workerd keeps its local state in the
|
|
100
|
+
// directory the loopback names and opens no inspector port, so a preview never
|
|
101
|
+
// reads another run's cached responses or races a dev server for port 9229.
|
|
102
|
+
...(process.env.TOT_PREVIEW_STATE_DIR
|
|
103
|
+
? { persistState: { path: process.env.TOT_PREVIEW_STATE_DIR }, inspectorPort: false }
|
|
104
|
+
: {}),
|
|
98
105
|
}),
|
|
99
106
|
integrations: [
|
|
100
107
|
// A framework Worker build requires no real-tenant checkout: tenant content
|
package/apps/storefront/env.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
} from "@tot/public-runtime";
|
|
18
18
|
import type { ToTClient } from "@/lib/catalog/tot/ToTClient";
|
|
19
19
|
import type { ContentProvider } from "@/lib/content/storyblok/provider";
|
|
20
|
+
import type { ServerTiming } from "@/lib/observability/shared/serverTiming";
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Minimal binding shapes. We intentionally do NOT depend on
|
|
@@ -160,6 +161,8 @@ declare global {
|
|
|
160
161
|
* exist yet. Unset on every public request.
|
|
161
162
|
*/
|
|
162
163
|
storeNotPublic?: boolean;
|
|
164
|
+
/** Per-request phase timer; the middleware stamps it as the `Server-Timing` header. */
|
|
165
|
+
serverTiming?: ServerTiming;
|
|
163
166
|
/**
|
|
164
167
|
* Which customization environment to serve. "live" on production
|
|
165
168
|
* hosts; "test" only behind a valid, tenant-bound preview token on a
|
|
@@ -29,7 +29,8 @@ interface Props {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
const { product, priority = false, rating, reviewCount } = Astro.props;
|
|
32
|
-
const { tenant, basePath } = Astro.locals;
|
|
32
|
+
const { tenant, basePath, features } = Astro.locals;
|
|
33
|
+
const showBadges = features.productBadges;
|
|
33
34
|
|
|
34
35
|
// A compliance-verified item (age/lab/regulatory) earns a "Compliant" badge —
|
|
35
36
|
// tag-driven so it stays tenant-agnostic (no per-merchant branch).
|
|
@@ -148,9 +149,9 @@ const quickview = {
|
|
|
148
149
|
|
|
149
150
|
{/* Status badges */}
|
|
150
151
|
<div class="pointer-events-none absolute left-2 top-2 z-10 flex flex-col items-start gap-1">
|
|
151
|
-
{onSale && <Badge variant="sale" label={salePercent ? `-${salePercent}%` : "Sale"} />}
|
|
152
|
-
{isNewProduct && <Badge variant="new" label="New" />}
|
|
153
|
-
{isCompliant && <Badge variant="compliant" label="Compliant" />}
|
|
152
|
+
{showBadges && onSale && <Badge variant="sale" label={salePercent ? `-${salePercent}%` : "Sale"} />}
|
|
153
|
+
{showBadges && isNewProduct && <Badge variant="new" label="New" />}
|
|
154
|
+
{showBadges && isCompliant && <Badge variant="compliant" label="Compliant" />}
|
|
154
155
|
{!available && <Badge variant="soldout" label="Sold out" />}
|
|
155
156
|
</div>
|
|
156
157
|
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* atom is usable outside a CatalogProduct context (cart line, PDP, fixtures).
|
|
11
11
|
*/
|
|
12
12
|
import type { CatalogProduct } from "@tot/public-runtime";
|
|
13
|
+
import { resolveStoreCurrency } from "@tot/public-runtime/declared-tenant-config";
|
|
13
14
|
import { formatPrice, discountPercent } from "@/lib/content/shared/format";
|
|
14
15
|
import { fromPrice, isOnSale } from "@/lib/catalog/tot/query";
|
|
15
16
|
import Badge from "@/components/content/storefront/Badge.astro";
|
|
@@ -47,7 +48,7 @@ const {
|
|
|
47
48
|
// Derive from the product when one is supplied; explicit props win.
|
|
48
49
|
const derivedAmount = product ? fromPrice(product) : 0;
|
|
49
50
|
const amount = amountProp ?? derivedAmount;
|
|
50
|
-
const currency = currencyProp ?? product?.price_range.currency
|
|
51
|
+
const currency = resolveStoreCurrency(currencyProp ?? product?.price_range.currency);
|
|
51
52
|
|
|
52
53
|
const productHasRange = !!product && product.price_range.min !== product.price_range.max;
|
|
53
54
|
const from = fromProp ?? productHasRange;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* <SearchTypeahead> — compact header search with debounced typeahead dropdown.
|
|
3
3
|
*
|
|
4
|
-
* Interactive island (Preact).
|
|
5
|
-
*
|
|
4
|
+
* Interactive island (Preact). Searches the tenant's static catalog index
|
|
5
|
+
* (`/catalog-index.json`, fetched once) with the same engine as the /search
|
|
6
|
+
* page, so it works wherever the page is served — a static host included — and
|
|
6
7
|
* renders up to 6 results as an ARIA combobox/listbox. Submitting the form (or
|
|
7
8
|
* Enter with no active option) navigates to the full `{action}?q=<q>` page.
|
|
8
9
|
*
|
|
@@ -11,10 +12,12 @@
|
|
|
11
12
|
* open a product, Escape to dismiss. Fetch failures degrade to "no dropdown".
|
|
12
13
|
*/
|
|
13
14
|
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
|
14
|
-
import type
|
|
15
|
+
import { toTypeaheadDoc, type TypeaheadDoc } from "@/lib/catalog/search/index";
|
|
16
|
+
import { loadCatalogIndex, searchFromIndex } from "@/lib/catalog/shared/catalogQueryView";
|
|
17
|
+
import { asCatalogProduct } from "@/lib/catalog/shared/catalogIndex";
|
|
15
18
|
import { formatPrice } from "@/lib/content/shared/format";
|
|
16
19
|
import { placeholderDataUri, isUsableImageUrl } from "@/lib/content/shared/placeholder";
|
|
17
|
-
import { withClientBase } from "@/lib/publication/shared/basePath";
|
|
20
|
+
import { clientBasePath, withClientBase } from "@/lib/publication/shared/basePath";
|
|
18
21
|
|
|
19
22
|
interface Props {
|
|
20
23
|
action?: string;
|
|
@@ -48,7 +51,7 @@ export default function SearchTypeahead({
|
|
|
48
51
|
const [active, setActive] = useState(-1); // index into results, -1 = none
|
|
49
52
|
const rootRef = useRef<HTMLFormElement>(null);
|
|
50
53
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
51
|
-
const
|
|
54
|
+
const requestRef = useRef(0);
|
|
52
55
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
53
56
|
|
|
54
57
|
const closeDropdown = useCallback(() => {
|
|
@@ -56,7 +59,7 @@ export default function SearchTypeahead({
|
|
|
56
59
|
setActive(-1);
|
|
57
60
|
}, []);
|
|
58
61
|
|
|
59
|
-
// Debounced
|
|
62
|
+
// Debounced search.
|
|
60
63
|
useEffect(() => {
|
|
61
64
|
const q = query.trim();
|
|
62
65
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
@@ -67,24 +70,18 @@ export default function SearchTypeahead({
|
|
|
67
70
|
return;
|
|
68
71
|
}
|
|
69
72
|
debounceRef.current = setTimeout(() => {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const url = `${withClientBase(action)}?q=${encodeURIComponent(q)}&format=json`;
|
|
76
|
-
fetch(url, { signal: ctrl.signal, headers: { Accept: "application/json" } })
|
|
77
|
-
.then((r) => (r.ok ? r.json() : Promise.reject(new Error("bad status"))))
|
|
78
|
-
.then((data: { products?: TypeaheadDoc[] }) => {
|
|
79
|
-
const products = Array.isArray(data.products)
|
|
80
|
-
? data.products.slice(0, MAX_RESULTS)
|
|
81
|
-
: [];
|
|
73
|
+
const request = ++requestRef.current;
|
|
74
|
+
loadCatalogIndex(clientBasePath())
|
|
75
|
+
.then((index) => {
|
|
76
|
+
if (request !== requestRef.current) return;
|
|
77
|
+
const products = searchFromIndex(index, q, MAX_RESULTS).map((p) => toTypeaheadDoc(asCatalogProduct(p)));
|
|
82
78
|
setResults(products);
|
|
83
79
|
setActive(-1);
|
|
84
80
|
setOpen(products.length > 0);
|
|
85
81
|
})
|
|
86
82
|
.catch(() => {
|
|
87
|
-
// Network/parse error
|
|
83
|
+
// Network/parse error: degrade silently (no dropdown).
|
|
84
|
+
if (request !== requestRef.current) return;
|
|
88
85
|
setResults([]);
|
|
89
86
|
setOpen(false);
|
|
90
87
|
setActive(-1);
|
|
@@ -94,7 +91,7 @@ export default function SearchTypeahead({
|
|
|
94
91
|
return () => {
|
|
95
92
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
96
93
|
};
|
|
97
|
-
}, [query
|
|
94
|
+
}, [query]);
|
|
98
95
|
|
|
99
96
|
// Close when focus/click leaves the widget.
|
|
100
97
|
useEffect(() => {
|
|
@@ -27,6 +27,7 @@ import type {
|
|
|
27
27
|
} from "@tot/public-runtime";
|
|
28
28
|
import { priceForPurchaseOption } from "@tot/public-runtime";
|
|
29
29
|
import { formatPrice } from "@/lib/content/shared/format";
|
|
30
|
+
import { stockStatus } from "@/lib/catalog/shared/stockStatus";
|
|
30
31
|
import { useCheckoutHandoffCorrelation } from "@/lib/checkout/browser/useCheckoutHandoffCorrelation";
|
|
31
32
|
|
|
32
33
|
export type PublicSelectorVariant = Pick<CatalogVariant,
|
|
@@ -69,6 +70,7 @@ interface Props {
|
|
|
69
70
|
*/
|
|
70
71
|
checkoutCart?: {
|
|
71
72
|
action: string;
|
|
73
|
+
method: "get" | "post";
|
|
72
74
|
purchaseOptions: ProductPurchaseOption[];
|
|
73
75
|
byVariantId: Record<string, Record<string, VariantCartPayload>>;
|
|
74
76
|
};
|
|
@@ -90,8 +92,6 @@ interface Props {
|
|
|
90
92
|
handoffUrl?: string;
|
|
91
93
|
}
|
|
92
94
|
|
|
93
|
-
const LOW_STOCK_THRESHOLD = 5;
|
|
94
|
-
|
|
95
95
|
type Selection = Record<string, string>;
|
|
96
96
|
|
|
97
97
|
function variantMatches(v: PublicSelectorVariant, sel: Selection): boolean {
|
|
@@ -234,22 +234,9 @@ export default function VariantSelector({
|
|
|
234
234
|
}, [complete, selected]);
|
|
235
235
|
|
|
236
236
|
// ---- live-region status copy --------------------------------------------
|
|
237
|
-
const qty = selected?.inventory.quantity;
|
|
238
237
|
const available = selected?.inventory.available ?? false;
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if (complete && selected) {
|
|
242
|
-
if (!available) {
|
|
243
|
-
stockLabel = "Sold out";
|
|
244
|
-
stockClass = "text-sale";
|
|
245
|
-
} else if (qty !== undefined && qty !== null && qty <= LOW_STOCK_THRESHOLD) {
|
|
246
|
-
stockLabel = `Only ${qty} left`;
|
|
247
|
-
stockClass = "text-accent-ink";
|
|
248
|
-
} else {
|
|
249
|
-
stockLabel = "In stock";
|
|
250
|
-
stockClass = "text-primary";
|
|
251
|
-
}
|
|
252
|
-
}
|
|
238
|
+
const stock = complete && selected ? stockStatus(selected.inventory) : undefined;
|
|
239
|
+
const STOCK_CLASS = { "sold-out": "text-sale", "low-stock": "text-accent-ink", "in-stock": "text-primary" };
|
|
253
240
|
|
|
254
241
|
// Compose member + subscription pricing. The pricing context carries the
|
|
255
242
|
// resolved member tier only when an eligible member was resolved server-side;
|
|
@@ -323,9 +310,14 @@ export default function VariantSelector({
|
|
|
323
310
|
</span>
|
|
324
311
|
)}
|
|
325
312
|
</div>
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
313
|
+
{stock && (
|
|
314
|
+
<p
|
|
315
|
+
data-product-availability={stock.state}
|
|
316
|
+
class={`font-mono text-xs uppercase tracking-[0.08em] ${STOCK_CLASS[stock.state]}`}
|
|
317
|
+
>
|
|
318
|
+
{stock.label}
|
|
319
|
+
</p>
|
|
320
|
+
)}
|
|
329
321
|
{selected.sku && (
|
|
330
322
|
<p class="font-mono text-[0.7rem] text-muted">
|
|
331
323
|
SKU: {selected.sku}
|
|
@@ -486,7 +478,7 @@ export default function VariantSelector({
|
|
|
486
478
|
<div class="flex flex-col gap-1.5">
|
|
487
479
|
<form
|
|
488
480
|
action={checkoutCart.action}
|
|
489
|
-
method=
|
|
481
|
+
method={checkoutCart.method}
|
|
490
482
|
target="_top"
|
|
491
483
|
data-managed-cart
|
|
492
484
|
data-product-handle={productHandle}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
3
|
* Numbered pagination (SEO-friendly, brief §5 default) + a "Load more" link.
|
|
4
|
-
* Both are plain anchors
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Both are plain anchors, so they're crawlable and back-button safe. The default
|
|
5
|
+
* view links each page's path variant (`pageHref`, `/collections/<h>/page/<n>/`),
|
|
6
|
+
* which every static host serves; any other view links the full query. "Load
|
|
7
|
+
* more" simply links to the next page; with JS it could be progressively
|
|
8
|
+
* enhanced to append, but the no-JS path stays correct.
|
|
7
9
|
*/
|
|
8
10
|
import type { ListProductsQuery } from "@tot/public-runtime";
|
|
9
11
|
import { buildQueryString } from "@/lib/publication/shared/url";
|
|
@@ -14,14 +16,16 @@ interface Props {
|
|
|
14
16
|
page: number;
|
|
15
17
|
pageSize: number;
|
|
16
18
|
total: number;
|
|
19
|
+
/** Path-variant link for page `p`; absent when the view must keep its query. */
|
|
20
|
+
pageHref?: (p: number) => string;
|
|
17
21
|
}
|
|
18
|
-
const { query, basePath, page, pageSize, total } = Astro.props;
|
|
22
|
+
const { query, basePath, page, pageSize, total, pageHref } = Astro.props;
|
|
19
23
|
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
|
20
24
|
if (pageCount <= 1) {
|
|
21
25
|
// nothing to render
|
|
22
26
|
}
|
|
23
27
|
|
|
24
|
-
const href = (p: number) => basePath + buildQueryString(query, { page: p });
|
|
28
|
+
const href = (p: number) => pageHref?.(p) ?? basePath + buildQueryString(query, { page: p });
|
|
25
29
|
|
|
26
30
|
// Compact page-number window.
|
|
27
31
|
const windowSize = 5;
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
|
-
* <CheckoutLoader> — injects the managed checkout loader (Track A).
|
|
3
|
+
* <CheckoutLoader> — injects the managed checkout loader (Track A), deferred.
|
|
4
4
|
*
|
|
5
|
-
* The loader turns
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* The loader turns cart links/forms into the cart drawer + hosted checkout. It is
|
|
6
|
+
* loaded from the store's CDN path by a nonced bootstrap on the first shopper input
|
|
7
|
+
* that can reach a commerce control, or a quiet timer after load (see
|
|
8
|
+
* lazyCheckoutLoader.ts), so its cart-drawer bundle never lands in the load-time
|
|
9
|
+
* critical path. Rendered only when the tenant has a store (locals.commerce);
|
|
8
10
|
* marketing tenants ship nothing.
|
|
9
11
|
*
|
|
10
12
|
* Note: the loader is a mutable, versioned CDN artifact and does NOT support
|
|
@@ -12,10 +14,20 @@
|
|
|
12
14
|
* allowlist set in middleware. It is loaded as a plain cross-origin script
|
|
13
15
|
* (NO `crossorigin` attribute) because the CDN serves it without CORS headers.
|
|
14
16
|
*/
|
|
15
|
-
|
|
17
|
+
import { lazyCheckoutLoaderJs } from "@/lib/checkout/shared/lazyCheckoutLoader";
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
nonce?: string;
|
|
21
|
+
}
|
|
22
|
+
const { commerce, storeNotPublic } = Astro.locals;
|
|
23
|
+
const { nonce } = Astro.props;
|
|
16
24
|
---
|
|
17
25
|
{
|
|
18
26
|
commerce && (
|
|
19
|
-
<script
|
|
27
|
+
<script is:inline nonce={nonce} set:html={lazyCheckoutLoaderJs({
|
|
28
|
+
loaderSrc: commerce.loaderSrc,
|
|
29
|
+
cartOrigin: commerce.origin,
|
|
30
|
+
storeNotPublic: !!storeNotPublic,
|
|
31
|
+
})} />
|
|
20
32
|
)
|
|
21
33
|
}
|
package/apps/storefront/src/components/compliance/storefront/islands/CheckoutComplianceGate.tsx
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
readStoredRef,
|
|
33
33
|
verificationPhase,
|
|
34
34
|
verificationRefKey,
|
|
35
|
+
verifiedRefKey,
|
|
35
36
|
writeStoredRef,
|
|
36
37
|
type PreflightResponse,
|
|
37
38
|
type VerificationPhase,
|
|
@@ -151,7 +152,9 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
|
|
|
151
152
|
setPopupBlocked(false);
|
|
152
153
|
// A reload mid-trip must not lose the correlation: replay the stored ref so
|
|
153
154
|
// the server re-reads THIS attempt's verification instead of starting over.
|
|
154
|
-
setVerificationRef(
|
|
155
|
+
setVerificationRef(
|
|
156
|
+
readStoredRef(verificationRefKey(preflightUrl, line)) ?? readStoredRef(verifiedRefKey(preflightUrl)),
|
|
157
|
+
);
|
|
155
158
|
let pre = false;
|
|
156
159
|
try {
|
|
157
160
|
pre = localStorage.getItem(AGE_KEY) === "yes";
|
|
@@ -175,9 +178,19 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
|
|
|
175
178
|
document.documentElement.style.overflow = "";
|
|
176
179
|
}, [stopPolling]);
|
|
177
180
|
|
|
178
|
-
const proceed = useCallback((line: PendingLine) => {
|
|
181
|
+
const proceed = useCallback((line: PendingLine, ref: string | null) => {
|
|
179
182
|
stopPolling();
|
|
180
183
|
clearStoredRef(verificationRefKey(preflightUrl, line));
|
|
184
|
+
// The cleared ref rides to the cart (signed server-side) and is reused for later lines.
|
|
185
|
+
line.form.querySelectorAll('input[name="verification"]').forEach((input) => input.remove());
|
|
186
|
+
if (ref) {
|
|
187
|
+
const input = document.createElement("input");
|
|
188
|
+
input.type = "hidden";
|
|
189
|
+
input.name = "verification";
|
|
190
|
+
input.value = ref;
|
|
191
|
+
line.form.appendChild(input);
|
|
192
|
+
writeStoredRef(verifiedRefKey(preflightUrl), ref);
|
|
193
|
+
}
|
|
181
194
|
// UX latch so this dialog does not intercept the resubmit. Not payment
|
|
182
195
|
// authority — the form's server action checks the cart.
|
|
183
196
|
line.form.dataset.complianceCleared = "1";
|
|
@@ -258,12 +271,13 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
|
|
|
258
271
|
switch (outcome.kind) {
|
|
259
272
|
case "cleared":
|
|
260
273
|
setDecision(outcome.response.decision);
|
|
261
|
-
proceed(line);
|
|
274
|
+
proceed(line, outcome.response.verification?.ref ?? ref);
|
|
262
275
|
return;
|
|
263
276
|
case "denied":
|
|
264
277
|
setDecision(outcome.response.decision);
|
|
265
278
|
setPhase("denied");
|
|
266
279
|
clearStoredRef(verificationRefKey(preflightUrl, line));
|
|
280
|
+
clearStoredRef(verifiedRefKey(preflightUrl));
|
|
267
281
|
setVerificationRef(null);
|
|
268
282
|
return;
|
|
269
283
|
case "halted":
|
|
@@ -304,7 +318,7 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
|
|
|
304
318
|
setDecision(res.decision);
|
|
305
319
|
// Only the server's own verdict releases the handoff.
|
|
306
320
|
if (res.decision.status === "clear") {
|
|
307
|
-
proceed(line);
|
|
321
|
+
proceed(line, ref);
|
|
308
322
|
return;
|
|
309
323
|
}
|
|
310
324
|
|
|
@@ -312,6 +326,7 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
|
|
|
312
326
|
setPhase(next);
|
|
313
327
|
if (next === "denied") {
|
|
314
328
|
clearStoredRef(key);
|
|
329
|
+
clearStoredRef(verifiedRefKey(preflightUrl));
|
|
315
330
|
setVerificationRef(null);
|
|
316
331
|
return;
|
|
317
332
|
}
|
|
@@ -24,6 +24,7 @@ const variants: Record<Props["variant"], string> = {
|
|
|
24
24
|
---
|
|
25
25
|
|
|
26
26
|
<span
|
|
27
|
+
data-product-badge={variant}
|
|
27
28
|
class:list={[
|
|
28
29
|
"inline-flex items-center rounded-full px-2 py-0.5 font-mono text-[0.62rem] uppercase tracking-[0.12em] leading-none whitespace-nowrap",
|
|
29
30
|
variants[variant],
|
|
@@ -10,6 +10,7 @@ import type { BlogIndexCopy } from "@/lib/content/blog/pagination.js";
|
|
|
10
10
|
import type { BlogPresentation } from "@/lib/content/blog/presentation.js";
|
|
11
11
|
import { categorySlug } from "@/lib/content/blog/collection.js";
|
|
12
12
|
import { collectionPathsV1 } from "@/lib/content/blog/collectionPaths.js";
|
|
13
|
+
import { homeCrumbLabel, siteTrail } from "@/lib/content/shared/breadcrumbs.js";
|
|
13
14
|
|
|
14
15
|
interface Props {
|
|
15
16
|
basePath: string;
|
|
@@ -52,6 +53,14 @@ const itemList = {
|
|
|
52
53
|
url: new URL(blogPaths.post(post.slug), canonicalBase + "/").toString(),
|
|
53
54
|
})),
|
|
54
55
|
};
|
|
56
|
+
const crumbs = siteTrail(
|
|
57
|
+
homeCrumbLabel(await Astro.locals.content.getChrome()),
|
|
58
|
+
{ label: "Blog", href: blogPaths.index },
|
|
59
|
+
);
|
|
60
|
+
const items = pagePosts.map((post) => ({
|
|
61
|
+
title: post.title,
|
|
62
|
+
url: new URL(blogPaths.post(post.slug), canonicalBase + "/").toString(),
|
|
63
|
+
}));
|
|
55
64
|
---
|
|
56
65
|
|
|
57
66
|
<Layout
|
|
@@ -60,7 +69,7 @@ const itemList = {
|
|
|
60
69
|
path={path}
|
|
61
70
|
fullBleed
|
|
62
71
|
jsonLd={itemList}
|
|
63
|
-
structuredData={{ pageType: "collection" }}
|
|
72
|
+
structuredData={{ pageType: "collection", crumbs, items }}
|
|
64
73
|
pagination={pagination}
|
|
65
74
|
>
|
|
66
75
|
<header class:list={["blog-hero", { "blog-hero--plain": !presentation.hero }]}>
|