@tokenoftrust/storefront-runner 2.5.3 → 2.5.4
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/apps/storefront/public/shared/commerce-chrome.css +21 -10
- package/apps/storefront/src/components/content/storefront/chrome/SiteHeader.astro +5 -5
- package/apps/storefront/src/components/content/storefront/islands/MobileNav.tsx +45 -5
- package/apps/storefront/src/lib/catalog/d1/catalog.ts +13 -4
- package/apps/storefront/src/lib/catalog/tot/ToTClient.ts +3 -4
- package/apps/storefront/src/lib/catalog/tot/query.ts +23 -0
- package/apps/storefront/src/lib/checkout/commerce/checkoutHandoffRoute.ts +197 -0
- package/apps/storefront/src/lib/content/shared/rawChrome.ts +82 -7
- package/apps/storefront/src/pages/api/commerce/checkout-handoff.ts +1 -190
- package/package.json +1 -1
|
@@ -141,11 +141,11 @@ body { overflow-x: clip; }
|
|
|
141
141
|
.header-search svg { width: 20px; height: 20px; stroke: var(--color-primary); fill: none; stroke-width: 2; }
|
|
142
142
|
|
|
143
143
|
.header-actions { display: flex; align-items: center; gap: 16px; }
|
|
144
|
-
.header-actions a { display: inline-flex; align-items: center; color: var(--color-text); }
|
|
145
|
-
.header-actions a:hover { color: var(--color-primary); }
|
|
146
|
-
.header-actions svg { width: 24px; height: 24px; stroke: currentColor; fill: none; stroke-width: 1.8; }
|
|
147
|
-
.header-actions .cart { position: relative; }
|
|
148
|
-
.header-actions .cart .count {
|
|
144
|
+
.header-actions a, .nav-mobile-actions a { display: inline-flex; align-items: center; color: var(--color-text); }
|
|
145
|
+
.header-actions a:hover, .nav-mobile-actions a:hover { color: var(--color-primary); }
|
|
146
|
+
.header-actions svg, .nav-mobile-actions svg { width: 24px; height: 24px; stroke: currentColor; fill: none; stroke-width: 1.8; }
|
|
147
|
+
.header-actions .cart, .nav-mobile-actions .cart { position: relative; }
|
|
148
|
+
.header-actions .cart .count, .nav-mobile-actions .cart .count {
|
|
149
149
|
position: absolute; top: -6px; right: -8px; background: var(--color-primary); color: var(--color-primary-contrast);
|
|
150
150
|
font-size: .62rem; font-weight: 700; min-width: 16px; height: 16px; border-radius: var(--radius-full);
|
|
151
151
|
display: grid; place-items: center; padding: 0 4px;
|
|
@@ -158,13 +158,13 @@ body { overflow-x: clip; }
|
|
|
158
158
|
.header-cta--primary {
|
|
159
159
|
background: var(--color-accent); color: var(--color-accent-contrast);
|
|
160
160
|
}
|
|
161
|
-
.header-actions .header-cta--primary:hover { color: var(--color-accent-contrast); transform: translateY(-1px); }
|
|
161
|
+
.header-actions .header-cta--primary:hover, .nav-mobile-actions .header-cta--primary:hover { color: var(--color-accent-contrast); transform: translateY(-1px); }
|
|
162
162
|
.header-cta--secondary, .header-cta--ghost { border: 1px solid currentColor; }
|
|
163
|
-
.header-actions .header-cta--secondary:hover, .header-actions .header-cta--ghost:hover { opacity: .8; }
|
|
163
|
+
.header-actions .header-cta--secondary:hover, .nav-mobile-actions .header-cta--secondary:hover, .header-actions .header-cta--ghost:hover, .nav-mobile-actions .header-cta--ghost:hover { opacity: .8; }
|
|
164
164
|
/* Labelled action links (chrome header.actionLinks): text with an optional leading icon. */
|
|
165
165
|
.header-action { gap: 6px; font-size: .875rem; font-weight: 600; white-space: nowrap; }
|
|
166
|
-
.header-actions .header-action svg { width: 20px; height: 20px; }
|
|
167
|
-
.header-actions .header-action.cart .count { position: static; margin-left: 2px; }
|
|
166
|
+
.header-actions .header-action svg, .nav-mobile-actions .header-action svg { width: 20px; height: 20px; }
|
|
167
|
+
.header-actions .header-action.cart .count, .nav-mobile-actions .header-action.cart .count { position: static; margin-left: 2px; }
|
|
168
168
|
/* A search form with a visible text submit (header.search.buttonLabel). */
|
|
169
169
|
.header-search--labelled { display: flex; }
|
|
170
170
|
.header-search--labelled input { padding-right: 18px; border-radius: var(--radius-full) 0 0 var(--radius-full); }
|
|
@@ -197,6 +197,7 @@ body { overflow-x: clip; }
|
|
|
197
197
|
|
|
198
198
|
/* --- Primary nav + mega-menu ---------------------------------------------- */
|
|
199
199
|
.primary-nav { flex: 1; }
|
|
200
|
+
.nav-submenu-toggle, .nav-close, .nav-mobile-actions { display: none; }
|
|
200
201
|
.primary-nav > ul { display: flex; flex-wrap: wrap; align-items: center; gap: clamp(10px, 1.6vw, 22px); }
|
|
201
202
|
/* Flat primary links */
|
|
202
203
|
.primary-nav a { font-weight: 600; font-size: .92rem; color: var(--color-text); padding: 6px 2px; border-bottom: 2px solid transparent; transition: color .15s, border-color .15s; }
|
|
@@ -478,8 +479,18 @@ a.article-card__tag:hover { text-decoration: underline; }
|
|
|
478
479
|
by the hamburger. Gating the fixed/off-screen positioning behind .js-nav
|
|
479
480
|
means a no-JS visitor always sees the nav in place — JS only changes its
|
|
480
481
|
PRESENTATION, never its reachability. */
|
|
481
|
-
.js-nav .primary-nav { position: fixed; inset: 0
|
|
482
|
+
.js-nav .primary-nav { position: fixed; inset: 0; width: 100vw; height: 100dvh; box-sizing: border-box; visibility: hidden; background: var(--color-surface); box-shadow: var(--shadow-lg); transform: translateX(100%); transition: transform .25s ease; padding: 78px 20px 24px; overflow-y: auto; z-index: 120; }
|
|
482
483
|
.js-nav .primary-nav.open { transform: none; }
|
|
484
|
+
.js-nav .primary-nav.open { visibility: visible; }
|
|
485
|
+
.js-nav .primary-nav .nav-close { display: block; position: absolute; top: 16px; right: 20px; width: 44px; height: 44px; border: 0; background: transparent; color: inherit; font-size: 2rem; }
|
|
486
|
+
.js-nav .primary-nav .nav-item { display: grid; grid-template-columns: minmax(0, 1fr) 44px; align-items: center; }
|
|
487
|
+
.js-nav .primary-nav .nav-item > a { grid-column: 1; }
|
|
488
|
+
.js-nav .primary-nav .nav-item > a .caret { display: none; }
|
|
489
|
+
.js-nav .primary-nav .nav-submenu-toggle { display: grid; place-items: center; min-height: 44px; border: 0; background: transparent; color: inherit; }
|
|
490
|
+
.js-nav .primary-nav .nav-submenu-toggle[aria-expanded="true"] span { transform: rotate(180deg); }
|
|
491
|
+
.js-nav .primary-nav .mega { grid-column: 1 / -1; }
|
|
492
|
+
.js-nav .primary-nav .mega[hidden] { display: none; }
|
|
493
|
+
.js-nav .primary-nav .nav-mobile-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; padding-top: 24px; }
|
|
483
494
|
/* header.mobile — menu button side, brand alignment, search placement. */
|
|
484
495
|
.js-nav .site-header[data-mobile-menu="left"] .primary-nav { inset: 0 auto 0 0; transform: translateX(-100%); }
|
|
485
496
|
.js-nav .site-header[data-mobile-menu="left"] .primary-nav.open { transform: none; }
|
|
@@ -235,7 +235,7 @@ const currentPath = Astro.url.pathname;
|
|
|
235
235
|
}
|
|
236
236
|
{
|
|
237
237
|
!minimal && savedItemsHref && (
|
|
238
|
-
<a
|
|
238
|
+
<a data-mobile-menu-action
|
|
239
239
|
href={withBase(basePath, savedItemsHref)}
|
|
240
240
|
aria-label="Saved items"
|
|
241
241
|
class="relative grid h-9 w-9 shrink-0 place-items-center rounded-full text-text transition-colors hover:text-accent-ink"
|
|
@@ -265,7 +265,7 @@ const currentPath = Astro.url.pathname;
|
|
|
265
265
|
index: i,
|
|
266
266
|
});
|
|
267
267
|
return (
|
|
268
|
-
<a
|
|
268
|
+
<a data-mobile-menu-action
|
|
269
269
|
href={withBase(basePath, cta.href)}
|
|
270
270
|
class={ctaClass(cta.variant)}
|
|
271
271
|
{...{ [idAttr.name]: idAttr.value }}
|
|
@@ -280,7 +280,7 @@ const currentPath = Astro.url.pathname;
|
|
|
280
280
|
}
|
|
281
281
|
{
|
|
282
282
|
authLink && (
|
|
283
|
-
<a
|
|
283
|
+
<a data-mobile-menu-action
|
|
284
284
|
href={authLink.href}
|
|
285
285
|
class="inline-flex shrink-0 items-center rounded-full border border-current px-4 py-2 font-display text-sm font-medium transition-opacity hover:opacity-80"
|
|
286
286
|
>
|
|
@@ -290,7 +290,7 @@ const currentPath = Astro.url.pathname;
|
|
|
290
290
|
}
|
|
291
291
|
{
|
|
292
292
|
accountHref && (
|
|
293
|
-
<a
|
|
293
|
+
<a data-mobile-menu-action
|
|
294
294
|
href={withBase(basePath, accountHref)}
|
|
295
295
|
aria-label="Account"
|
|
296
296
|
class="grid h-9 w-9 place-items-center rounded-full text-text transition-colors hover:text-accent-ink"
|
|
@@ -304,7 +304,7 @@ const currentPath = Astro.url.pathname;
|
|
|
304
304
|
}
|
|
305
305
|
{
|
|
306
306
|
cartHref && (
|
|
307
|
-
<a
|
|
307
|
+
<a data-mobile-menu-action
|
|
308
308
|
href={withBase(basePath, cartHref)}
|
|
309
309
|
aria-label={`Cart, ${cartCount} ${cartCount === 1 ? "item" : "items"}`}
|
|
310
310
|
class="relative grid h-9 w-9 place-items-center rounded-full text-text transition-colors hover:text-accent-ink"
|
|
@@ -60,11 +60,13 @@ function prefersReducedMotion(): boolean {
|
|
|
60
60
|
|
|
61
61
|
export default function MobileNav({ nav, announcement, currentPath }: Props) {
|
|
62
62
|
const [open, setOpen] = useState(false);
|
|
63
|
+
const [expanded, setExpanded] = useState<Set<number>>(new Set());
|
|
63
64
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
64
65
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
66
|
+
const actionsRef = useRef<HTMLDivElement>(null);
|
|
65
67
|
const reduced = prefersReducedMotion();
|
|
66
68
|
|
|
67
|
-
const close = useCallback(() => setOpen(false), []);
|
|
69
|
+
const close = useCallback(() => { setOpen(false); setExpanded(new Set()); }, []);
|
|
68
70
|
|
|
69
71
|
// The no-JS fallback nav is only needed when JS never runs. Since this
|
|
70
72
|
// component mounting IS JS running, hide it in favor of the drawer above —
|
|
@@ -77,6 +79,29 @@ export default function MobileNav({ nav, announcement, currentPath }: Props) {
|
|
|
77
79
|
});
|
|
78
80
|
}, []);
|
|
79
81
|
|
|
82
|
+
// Desktop navigation takes over at the same breakpoint as md:hidden.
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
const desktop = window.matchMedia("(min-width: 768px)");
|
|
85
|
+
const onViewportChange = () => { if (desktop.matches) close(); };
|
|
86
|
+
desktop.addEventListener("change", onViewportChange);
|
|
87
|
+
onViewportChange();
|
|
88
|
+
return () => desktop.removeEventListener("change", onViewportChange);
|
|
89
|
+
}, [close]);
|
|
90
|
+
|
|
91
|
+
// Keep each governed action's DOM identity unique across header and drawer.
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (!open || !actionsRef.current) return;
|
|
94
|
+
const mount = actionsRef.current;
|
|
95
|
+
const header = buttonRef.current?.closest("header");
|
|
96
|
+
const slots = Array.from(header?.querySelectorAll<HTMLElement>("[data-mobile-menu-action]") ?? []).map((node) => {
|
|
97
|
+
const marker = document.createComment("header-action");
|
|
98
|
+
node.parentNode!.insertBefore(marker, node);
|
|
99
|
+
mount.appendChild(node);
|
|
100
|
+
return { node, marker };
|
|
101
|
+
});
|
|
102
|
+
return () => slots.forEach(({ node, marker }) => marker.parentNode?.replaceChild(node, marker));
|
|
103
|
+
}, [open]);
|
|
104
|
+
|
|
80
105
|
// Body scroll lock while open.
|
|
81
106
|
useEffect(() => {
|
|
82
107
|
if (!open) return;
|
|
@@ -100,7 +125,7 @@ export default function MobileNav({ nav, announcement, currentPath }: Props) {
|
|
|
100
125
|
return () => window.cancelAnimationFrame(id);
|
|
101
126
|
}
|
|
102
127
|
// On close, return focus to the hamburger.
|
|
103
|
-
buttonRef.current?.focus();
|
|
128
|
+
if (!window.matchMedia("(min-width: 768px)").matches) buttonRef.current?.focus();
|
|
104
129
|
return undefined;
|
|
105
130
|
}, [open]);
|
|
106
131
|
|
|
@@ -188,7 +213,7 @@ export default function MobileNav({ nav, announcement, currentPath }: Props) {
|
|
|
188
213
|
role="dialog"
|
|
189
214
|
aria-modal="true"
|
|
190
215
|
aria-label="Site menu"
|
|
191
|
-
class="absolute inset-
|
|
216
|
+
class="absolute inset-0 flex w-full flex-col overflow-y-auto bg-surface text-text shadow-xl"
|
|
192
217
|
style={
|
|
193
218
|
reduced
|
|
194
219
|
? undefined
|
|
@@ -233,7 +258,7 @@ export default function MobileNav({ nav, announcement, currentPath }: Props) {
|
|
|
233
258
|
{nav.map((item, i) => (
|
|
234
259
|
<li key={item.href}>
|
|
235
260
|
{item.children && item.children.length > 0 ? (
|
|
236
|
-
<div class="px-3 py-2">
|
|
261
|
+
<div class="grid grid-cols-[minmax(0,1fr)_44px] items-center px-3 py-2">
|
|
237
262
|
<a
|
|
238
263
|
href={item.href}
|
|
239
264
|
aria-current={
|
|
@@ -251,7 +276,21 @@ export default function MobileNav({ nav, announcement, currentPath }: Props) {
|
|
|
251
276
|
>
|
|
252
277
|
{item.label}
|
|
253
278
|
</a>
|
|
254
|
-
<
|
|
279
|
+
<button
|
|
280
|
+
type="button"
|
|
281
|
+
aria-label={`Toggle ${item.label} submenu`}
|
|
282
|
+
aria-expanded={expanded.has(i)}
|
|
283
|
+
aria-controls={`${DRAWER_ID}-submenu-${i}`}
|
|
284
|
+
class="grid h-11 w-11 place-items-center text-text"
|
|
285
|
+
onClick={() => setExpanded((previous) => {
|
|
286
|
+
const next = new Set(previous);
|
|
287
|
+
if (next.has(i)) next.delete(i); else next.add(i);
|
|
288
|
+
return next;
|
|
289
|
+
})}
|
|
290
|
+
>
|
|
291
|
+
<span aria-hidden="true">{expanded.has(i) ? "▴" : "▾"}</span>
|
|
292
|
+
</button>
|
|
293
|
+
<ul id={`${DRAWER_ID}-submenu-${i}`} hidden={!expanded.has(i)} class="col-span-2 mt-1 space-y-0.5 border-l border-border pl-3">
|
|
255
294
|
{item.children.map((child, ci) => (
|
|
256
295
|
<li key={child.href}>
|
|
257
296
|
<a
|
|
@@ -297,6 +336,7 @@ export default function MobileNav({ nav, announcement, currentPath }: Props) {
|
|
|
297
336
|
</li>
|
|
298
337
|
))}
|
|
299
338
|
</ul>
|
|
339
|
+
<div ref={actionsRef} class="mt-6 flex flex-wrap items-center gap-4 border-t border-border px-3 pt-4" />
|
|
300
340
|
</nav>
|
|
301
341
|
</div>
|
|
302
342
|
</div>
|
|
@@ -195,7 +195,10 @@ export async function listAllActiveProducts(db: Queryable): Promise<CatalogProdu
|
|
|
195
195
|
return rows.map((r) => JSON.parse(r.data) as CatalogProduct);
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
-
function sortClause(sort?: ListProductsQuery["sort"]): string {
|
|
198
|
+
function sortClause(sort?: ListProductsQuery["sort"], collection = false): string {
|
|
199
|
+
if (collection && (!sort || sort === "featured")) {
|
|
200
|
+
return "ORDER BY COALESCE(cm.collection_position, 2147483647), p.position ASC, p.handle ASC";
|
|
201
|
+
}
|
|
199
202
|
switch (sort) {
|
|
200
203
|
case "price-asc":
|
|
201
204
|
return "ORDER BY p.price_min ASC, p.title COLLATE NOCASE ASC";
|
|
@@ -378,10 +381,16 @@ export async function listProducts(
|
|
|
378
381
|
|
|
379
382
|
// Pool: active products, optionally scoped to a collection.
|
|
380
383
|
const poolJoin = query.collection
|
|
381
|
-
?
|
|
384
|
+
? `LEFT JOIN (SELECT j.value AS handle, MIN(CAST(j.key AS INTEGER)) AS collection_position
|
|
385
|
+
FROM collections c, json_each(c.data, '$.product_handles') j
|
|
386
|
+
WHERE c.handle = ? GROUP BY j.value) cm ON cm.handle = p.handle`
|
|
382
387
|
: "";
|
|
383
388
|
const poolParams: unknown[] = query.collection ? [query.collection] : [];
|
|
384
|
-
const
|
|
389
|
+
const membership = query.collection
|
|
390
|
+
? " AND (cm.handle IS NOT NULL OR EXISTS (SELECT 1 FROM product_collections pc WHERE pc.handle = p.handle AND pc.collection = ?))"
|
|
391
|
+
: "";
|
|
392
|
+
if (query.collection) poolParams.push(query.collection);
|
|
393
|
+
const poolSql = `FROM products p ${poolJoin} WHERE p.status = 'active' AND COALESCE(json_extract(p.data, '$.visibility'), 'public') = 'public'${membership}`;
|
|
385
394
|
|
|
386
395
|
// Result filters layered on the pool.
|
|
387
396
|
const clauses = buildFilterClauses(query);
|
|
@@ -398,7 +407,7 @@ export async function listProducts(
|
|
|
398
407
|
const offset = (page - 1) * pageSize;
|
|
399
408
|
|
|
400
409
|
const rows = await db.all<{ data: string }>(
|
|
401
|
-
`SELECT p.data AS data ${poolSql}${filterClause} ${sortClause(query.sort)} LIMIT ? OFFSET ?`,
|
|
410
|
+
`SELECT p.data AS data ${poolSql}${filterClause} ${sortClause(query.sort, !!query.collection)} LIMIT ? OFFSET ?`,
|
|
402
411
|
...resultParams, pageSize, offset,
|
|
403
412
|
);
|
|
404
413
|
const products = rows.map((r) => JSON.parse(r.data) as CatalogProduct);
|
|
@@ -23,7 +23,7 @@ import type {
|
|
|
23
23
|
TenantConfig,
|
|
24
24
|
} from "@tot/public-runtime";
|
|
25
25
|
import { isPublicCatalogProduct } from "@tot/public-runtime";
|
|
26
|
-
import { buildFacets, applyQuery, paginate } from "./query.js";
|
|
26
|
+
import { buildFacets, applyQuery, paginate, collectionProducts } from "./query.js";
|
|
27
27
|
import { D1ToTClient } from "./d1Client.js";
|
|
28
28
|
import { fromD1, type D1Like } from "../d1/catalog.js";
|
|
29
29
|
import { buildSearchIndex, type SearchIndex } from "../search/index.js";
|
|
@@ -67,11 +67,10 @@ export class FixtureToTClient implements ToTClient {
|
|
|
67
67
|
let pool = active;
|
|
68
68
|
if (query.collection) {
|
|
69
69
|
const col = this.collections.find((c) => c.handle === query.collection);
|
|
70
|
-
|
|
71
|
-
pool = active.filter((p) => handles.has(p.handle));
|
|
70
|
+
pool = col ? collectionProducts(active, col) : [];
|
|
72
71
|
}
|
|
73
72
|
const facets: Facet[] = buildFacets(pool, query);
|
|
74
|
-
const filtered = applyQuery(pool, query);
|
|
73
|
+
const filtered = applyQuery(pool, query, !!query.collection);
|
|
75
74
|
const { items, page, pageSize, total } = paginate(filtered, query);
|
|
76
75
|
return { products: items, page, pageSize, total, facets };
|
|
77
76
|
}
|
|
@@ -197,12 +197,35 @@ function applyFilters(
|
|
|
197
197
|
return out;
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
+
/** Declared members first (first duplicate wins), then product-side members in catalog order. */
|
|
201
|
+
export function collectionProducts(pool: CatalogProduct[], collection: CatalogCollection): CatalogProduct[] {
|
|
202
|
+
const byHandle = new Map(pool.map((p) => [p.handle, p]));
|
|
203
|
+
const seen = new Set<string>();
|
|
204
|
+
const members: CatalogProduct[] = [];
|
|
205
|
+
for (const handle of collection.product_handles) {
|
|
206
|
+
const product = byHandle.get(handle);
|
|
207
|
+
if (product && !seen.has(handle)) {
|
|
208
|
+
members.push(product);
|
|
209
|
+
seen.add(handle);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
for (const product of pool) {
|
|
213
|
+
if (!seen.has(product.handle) && product.collections.includes(collection.handle)) {
|
|
214
|
+
members.push(product);
|
|
215
|
+
seen.add(product.handle);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return members;
|
|
219
|
+
}
|
|
220
|
+
|
|
200
221
|
/** Apply filters + sort (NOT pagination). */
|
|
201
222
|
export function applyQuery(
|
|
202
223
|
pool: CatalogProduct[],
|
|
203
224
|
query: ListProductsQuery,
|
|
225
|
+
manualOrder = false,
|
|
204
226
|
): CatalogProduct[] {
|
|
205
227
|
const out = applyFilters(pool, query);
|
|
228
|
+
if (manualOrder && (!query.sort || query.sort === "featured")) return out;
|
|
206
229
|
return sortProducts(out, query.sort, pool);
|
|
207
230
|
}
|
|
208
231
|
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/commerce/checkout-handoff — the checkout-correlation MINT seam
|
|
3
|
+
* (ext-commerce-10).
|
|
4
|
+
*
|
|
5
|
+
* The shopper's analytics consent is a CLIENT-side decision (CMP / native banner),
|
|
6
|
+
* but the correlation token is minted under the SERVER-only signing secret. So at the
|
|
7
|
+
* handoff the client posts its LIVE consent snapshot and the server mints the token,
|
|
8
|
+
* returning ONLY the opaque passthrough cart field to attach to the managed-cart form.
|
|
9
|
+
* The signing secret never leaves the server; the token carries no PII and no secret.
|
|
10
|
+
*
|
|
11
|
+
* Server-owned by construction:
|
|
12
|
+
* - The tenant and the {@link IntegrationEnvironment} stage come from `context.locals`
|
|
13
|
+
* (the renderer-owned `BrowserEnvironment`), NEVER from the request body — a client
|
|
14
|
+
* cannot mint a cross-tenant or cross-stage token.
|
|
15
|
+
* - The consent snapshot is parsed FAIL-CLOSED (`parseConsentSnapshot`): an absent,
|
|
16
|
+
* forged, or tampered snapshot collapses to default-deny → unattributed.
|
|
17
|
+
* - No store on this tenant, consent withheld, or no signing key → an explicit
|
|
18
|
+
* unattributed response; the handoff proceeds, checkout is never blocked.
|
|
19
|
+
*/
|
|
20
|
+
import type { APIContext } from "astro";
|
|
21
|
+
import {
|
|
22
|
+
enforceCartSubmission,
|
|
23
|
+
foxyCartRedirectUrl,
|
|
24
|
+
parseConsentSnapshot,
|
|
25
|
+
defaultConsentSnapshot,
|
|
26
|
+
buildVariantCartPayload,
|
|
27
|
+
commerceTenantId,
|
|
28
|
+
MAX_CART_QUANTITY,
|
|
29
|
+
type CartDenialCode,
|
|
30
|
+
type CatalogProduct,
|
|
31
|
+
type TenantConfig,
|
|
32
|
+
type ResolvedManagedCheckout,
|
|
33
|
+
} from "@tot/public-runtime";
|
|
34
|
+
import { resolveCheckoutHandoffCorrelation } from "@/lib/checkout/commerce/checkoutHandoffCorrelation";
|
|
35
|
+
import { resolveCartSigning, shopperCartDenialMessage } from "@/lib/checkout/shared/checkoutCommerce";
|
|
36
|
+
import { readEnv } from "@/lib/publication/shared/runtimeEnv";
|
|
37
|
+
|
|
38
|
+
export type SampleProductReader = (tenant: TenantConfig, handle: string, commerce: ResolvedManagedCheckout) => Promise<CatalogProduct | null>;
|
|
39
|
+
|
|
40
|
+
function json(body: unknown, status = 200): Response {
|
|
41
|
+
return new Response(JSON.stringify(body), {
|
|
42
|
+
status,
|
|
43
|
+
headers: {
|
|
44
|
+
"content-type": "application/json; charset=utf-8",
|
|
45
|
+
"cache-control": "no-store",
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface HandoffBody {
|
|
51
|
+
/** The shopper's serialized consent snapshot (the `__TOT_INTEGRATION_CONSENT__` global). */
|
|
52
|
+
consent?: unknown;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function escapeHtml(value: string): string {
|
|
56
|
+
return value.replace(/[&<>"']/g, (ch) => {
|
|
57
|
+
if (ch === "&") return "&";
|
|
58
|
+
if (ch === "<") return "<";
|
|
59
|
+
if (ch === ">") return ">";
|
|
60
|
+
if (ch === '"') return """;
|
|
61
|
+
return "'";
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function cartDenial(code: CartDenialCode, message: string, status: number): Response {
|
|
66
|
+
const body =
|
|
67
|
+
`<!doctype html><html><body><p data-cart-denial="${code}" data-payment-authorized="false">${escapeHtml(message)}</p></body></html>`;
|
|
68
|
+
return new Response(body, {
|
|
69
|
+
status,
|
|
70
|
+
headers: {
|
|
71
|
+
"content-type": "text/html; charset=utf-8",
|
|
72
|
+
"cache-control": "no-store",
|
|
73
|
+
"x-tot-payment-authorized": "false",
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isCartForm(request: Request): boolean {
|
|
79
|
+
const type = request.headers.get("content-type") ?? "";
|
|
80
|
+
return (
|
|
81
|
+
type.includes("application/x-www-form-urlencoded") ||
|
|
82
|
+
type.includes("multipart/form-data")
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Native cart POST. Browser preflight is not consulted. Payment is never authorized. */
|
|
87
|
+
async function cartFormPost(context: APIContext, readSampleProduct?: SampleProductReader): Promise<Response> {
|
|
88
|
+
const { tenant, commerce } = context.locals;
|
|
89
|
+
if (!commerce) {
|
|
90
|
+
return cartDenial(
|
|
91
|
+
"signing-unavailable",
|
|
92
|
+
"Checkout is unavailable because this store has no cart configured.",
|
|
93
|
+
409,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
const signing = await resolveCartSigning(commerce);
|
|
97
|
+
if (!signing.enabled) {
|
|
98
|
+
return cartDenial(
|
|
99
|
+
"signing-unavailable",
|
|
100
|
+
shopperCartDenialMessage("signing-unavailable", signing.operatorError ?? ""),
|
|
101
|
+
503,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
const form = await context.request.formData();
|
|
105
|
+
const entries: Array<[string, string]> = [];
|
|
106
|
+
for (const [name, value] of form.entries()) {
|
|
107
|
+
if (typeof value !== "string") return cartDenial("signature", "Invalid cart.", 422);
|
|
108
|
+
entries.push([name, value]);
|
|
109
|
+
}
|
|
110
|
+
if (form.has("sample_product") || form.has("sample_variant")) {
|
|
111
|
+
const sampleTenant = await readEnv("COMMERCE_SAMPLE_TENANT_ID");
|
|
112
|
+
if (
|
|
113
|
+
!sampleTenant || sampleTenant !== commerceTenantId(tenant) ||
|
|
114
|
+
await readEnv("COMMERCE_DEMO_CHECKOUT_TIER") !== "test" || commerce.mode !== "signed"
|
|
115
|
+
) return cartDenial("tenant", "Invalid cart.", 422);
|
|
116
|
+
const allowed = new Set(["sample_product", "sample_variant", "quantity"]);
|
|
117
|
+
if (entries.length !== 3 || entries.some(([name]) => !allowed.has(name)) ||
|
|
118
|
+
new Set(entries.map(([name]) => name)).size !== 3) {
|
|
119
|
+
return cartDenial("signature", "Invalid cart.", 422);
|
|
120
|
+
}
|
|
121
|
+
const quantity = form.get("quantity") as string;
|
|
122
|
+
if (!/^[1-9][0-9]*$/.test(quantity) || !Number.isSafeInteger(Number(quantity)) || Number(quantity) > MAX_CART_QUANTITY) {
|
|
123
|
+
return cartDenial("quantity", "Invalid quantity.", 422);
|
|
124
|
+
}
|
|
125
|
+
const product = await readSampleProduct?.(tenant, form.get("sample_product") as string, commerce) ?? null;
|
|
126
|
+
const variant = product?.variants.find((v) => v.id === form.get("sample_variant"));
|
|
127
|
+
if (!product || product.status !== "active" || product.visibility === "hidden" || product.visibility === "unlisted" ||
|
|
128
|
+
!variant || !variant.inventory.available || product.tenant_id !== tenant.tot_data_scope ||
|
|
129
|
+
variant.tenant_id !== tenant.tot_data_scope) {
|
|
130
|
+
return cartDenial("signature", "Product is unavailable.", 422);
|
|
131
|
+
}
|
|
132
|
+
const payload = await buildVariantCartPayload(product, variant, {
|
|
133
|
+
mode: "signed", secret: signing.secret, tenantId: commerceTenantId(tenant),
|
|
134
|
+
});
|
|
135
|
+
entries.splice(0, entries.length, ...payload.hidden.map((f): [string, string] => [f.name, f.value]), [payload.quantityName, quantity]);
|
|
136
|
+
}
|
|
137
|
+
const result = await enforceCartSubmission({
|
|
138
|
+
entries,
|
|
139
|
+
mode: commerce.mode,
|
|
140
|
+
secret: signing.secret,
|
|
141
|
+
trustedTenantId: commerceTenantId(tenant),
|
|
142
|
+
});
|
|
143
|
+
if (!result.ok) {
|
|
144
|
+
const status = result.code === "signing-unavailable" ? 503 : 422;
|
|
145
|
+
return cartDenial(result.code, shopperCartDenialMessage(result.code, result.message), status);
|
|
146
|
+
}
|
|
147
|
+
return new Response(null, {
|
|
148
|
+
status: 303,
|
|
149
|
+
headers: {
|
|
150
|
+
location: foxyCartRedirectUrl(commerce.cartUrl, result.fields),
|
|
151
|
+
"cache-control": "no-store",
|
|
152
|
+
"x-tot-payment-authorized": "false",
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function checkoutHandoffPost(context: APIContext, readSampleProduct?: SampleProductReader): Promise<Response> {
|
|
158
|
+
if (isCartForm(context.request)) return cartFormPost(context, readSampleProduct);
|
|
159
|
+
const { tenant, browserEnvironment, commerce } = context.locals;
|
|
160
|
+
|
|
161
|
+
// No managed checkout on this tenant → nothing to attribute.
|
|
162
|
+
if (!commerce) {
|
|
163
|
+
return json({ attributed: false, reason: "no-store" });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let body: HandoffBody;
|
|
167
|
+
try {
|
|
168
|
+
body = (await context.request.json()) as HandoffBody;
|
|
169
|
+
} catch {
|
|
170
|
+
body = {};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Fail-closed: an absent/forged/tampered snapshot is default-deny (essential only).
|
|
174
|
+
const consent = parseConsentSnapshot(body.consent) ?? defaultConsentSnapshot();
|
|
175
|
+
|
|
176
|
+
const result = await resolveCheckoutHandoffCorrelation({
|
|
177
|
+
tenant: tenant.tenant_id,
|
|
178
|
+
// Env-consistency: the request's own resolved stage, never a client value.
|
|
179
|
+
environment: browserEnvironment.environment,
|
|
180
|
+
consent,
|
|
181
|
+
// Opaque per-handoff logical id the order join threads back to.
|
|
182
|
+
eventId: crypto.randomUUID(),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
if (result.attributed) {
|
|
186
|
+
// Return ONLY the opaque passthrough field; the signing secret never leaves here.
|
|
187
|
+
return json({ attributed: true, cartField: result.cartField });
|
|
188
|
+
}
|
|
189
|
+
return json({ attributed: false, reason: result.reason });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function GET(): Promise<Response> {
|
|
193
|
+
return json({ error: "method_not_allowed" }, 405);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The renderer has no hosted preview catalog authority. */
|
|
197
|
+
export const POST = (context: APIContext): Promise<Response> => checkoutHandoffPost(context);
|
|
@@ -265,8 +265,10 @@ function renderNavItem(item: ChromeNavItem, index: number, currentPath?: string)
|
|
|
265
265
|
return `<div class="mega__col">${heading}${links}</div>`;
|
|
266
266
|
})
|
|
267
267
|
.join("");
|
|
268
|
-
const
|
|
269
|
-
|
|
268
|
+
const panelId = `nav-submenu-${index}`;
|
|
269
|
+
const toggle = `<button type="button" class="nav-submenu-toggle" aria-label="${esc(`Toggle ${item.label} submenu`)}" aria-expanded="false" aria-controls="${panelId}"><span aria-hidden="true">▾</span></button>`;
|
|
270
|
+
const mega = `<div class="mega${wide}" id="${panelId}"><div class="mega__cols">${cols}</div></div>`;
|
|
271
|
+
return `<li class="nav-item">${link}${toggle}${mega}</li>`;
|
|
270
272
|
}
|
|
271
273
|
|
|
272
274
|
/**
|
|
@@ -383,7 +385,9 @@ function renderHeader(header: ChromeHeader, variant: HeaderVariant, currentPath?
|
|
|
383
385
|
const primaryNav =
|
|
384
386
|
`<nav class="primary-nav" id="primaryNav" aria-label="Primary">\n` +
|
|
385
387
|
menuSearch +
|
|
388
|
+
`<button type="button" class="nav-close" aria-label="Close navigation menu">×</button>` +
|
|
386
389
|
` <ul>\n ${navItems}\n </ul>\n` +
|
|
390
|
+
`<div class="nav-mobile-actions"></div>` +
|
|
387
391
|
` </nav>`;
|
|
388
392
|
const navBar = minimal
|
|
389
393
|
? ""
|
|
@@ -396,7 +400,7 @@ function renderHeader(header: ChromeHeader, variant: HeaderVariant, currentPath?
|
|
|
396
400
|
` </nav>\n`;
|
|
397
401
|
const compactSearch =
|
|
398
402
|
compact && header.search
|
|
399
|
-
? `<a class="header-search-trigger" href="${esc(header.search.action ?? "/search")}" aria-label="${esc(header.search.label ?? "Search")}">${ICON_SEARCH}</a>`
|
|
403
|
+
? `<a class="header-search-trigger" data-header-fixed-action href="${esc(header.search.action ?? "/search")}" aria-label="${esc(header.search.label ?? "Search")}">${ICON_SEARCH}</a>`
|
|
400
404
|
: "";
|
|
401
405
|
const mobileBottom =
|
|
402
406
|
compact
|
|
@@ -656,10 +660,81 @@ export const CHROME_BEHAVIOR_JS = `(function () {
|
|
|
656
660
|
// Mobile nav drawer
|
|
657
661
|
var burger = document.getElementById('hamburger');
|
|
658
662
|
var nav = document.getElementById('primaryNav');
|
|
659
|
-
if (burger && nav)
|
|
660
|
-
var
|
|
661
|
-
|
|
662
|
-
|
|
663
|
+
if (burger && nav) {
|
|
664
|
+
var mobileNavQuery = window.matchMedia('(max-width: 860px)');
|
|
665
|
+
var navClose = nav.querySelector('.nav-close');
|
|
666
|
+
var previousOverflow = null;
|
|
667
|
+
var actionMount = nav.querySelector('.nav-mobile-actions');
|
|
668
|
+
var header = burger.closest('.site-header');
|
|
669
|
+
var actionNodes = header ? Array.from(header.querySelectorAll('.header-actions > a:not([data-header-fixed-action])')) : [];
|
|
670
|
+
var actionSlots = actionNodes.map(function (node) {
|
|
671
|
+
return { node: node, marker: document.createComment('header-action') };
|
|
672
|
+
});
|
|
673
|
+
var placeActions = function (inMenu) {
|
|
674
|
+
actionSlots.forEach(function (slot) {
|
|
675
|
+
if (inMenu && actionMount && !slot.marker.parentNode) {
|
|
676
|
+
slot.node.parentNode.insertBefore(slot.marker, slot.node);
|
|
677
|
+
actionMount.appendChild(slot.node);
|
|
678
|
+
} else if (!inMenu && slot.marker.parentNode) {
|
|
679
|
+
slot.marker.parentNode.replaceChild(slot.node, slot.marker);
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
};
|
|
683
|
+
var setNavOpen = function (open, returnFocus) {
|
|
684
|
+
open = open && mobileNavQuery.matches;
|
|
685
|
+
nav.classList.toggle('open', open);
|
|
686
|
+
burger.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
687
|
+
nav.inert = mobileNavQuery.matches && !open;
|
|
688
|
+
placeActions(open);
|
|
689
|
+
if (open) {
|
|
690
|
+
previousOverflow = document.body.style.overflow;
|
|
691
|
+
document.body.style.overflow = 'hidden';
|
|
692
|
+
nav.setAttribute('role', 'dialog');
|
|
693
|
+
nav.setAttribute('aria-modal', 'true');
|
|
694
|
+
if (navClose) navClose.focus();
|
|
695
|
+
} else {
|
|
696
|
+
nav.removeAttribute('role');
|
|
697
|
+
nav.removeAttribute('aria-modal');
|
|
698
|
+
if (previousOverflow !== null) document.body.style.overflow = previousOverflow;
|
|
699
|
+
previousOverflow = null;
|
|
700
|
+
if (returnFocus) burger.focus();
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
var resetMobileNav = function () {
|
|
704
|
+
setNavOpen(false, false);
|
|
705
|
+
nav.querySelectorAll('.nav-submenu-toggle').forEach(function (toggle) {
|
|
706
|
+
toggle.setAttribute('aria-expanded', 'false');
|
|
707
|
+
var panel = document.getElementById(toggle.getAttribute('aria-controls'));
|
|
708
|
+
if (panel) panel.hidden = mobileNavQuery.matches;
|
|
709
|
+
});
|
|
710
|
+
};
|
|
711
|
+
nav.querySelectorAll('.nav-submenu-toggle').forEach(function (toggle) {
|
|
712
|
+
toggle.addEventListener('click', function () {
|
|
713
|
+
var panel = document.getElementById(toggle.getAttribute('aria-controls'));
|
|
714
|
+
var expanded = toggle.getAttribute('aria-expanded') !== 'true';
|
|
715
|
+
toggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
|
716
|
+
if (panel) panel.hidden = !expanded;
|
|
717
|
+
});
|
|
718
|
+
});
|
|
719
|
+
burger.addEventListener('click', function () { setNavOpen(!nav.classList.contains('open'), true); });
|
|
720
|
+
if (navClose) navClose.addEventListener('click', function () { setNavOpen(false, true); });
|
|
721
|
+
nav.addEventListener('keydown', function (event) {
|
|
722
|
+
if (!nav.classList.contains('open')) return;
|
|
723
|
+
if (event.key === 'Escape') {
|
|
724
|
+
event.preventDefault();
|
|
725
|
+
setNavOpen(false, true);
|
|
726
|
+
}
|
|
727
|
+
if (event.key !== 'Tab') return;
|
|
728
|
+
var links = Array.from(nav.querySelectorAll('a[href], button, input, [tabindex="0"]')).filter(function (el) {
|
|
729
|
+
return !el.disabled && !el.closest('[hidden]') && el.getClientRects().length;
|
|
730
|
+
});
|
|
731
|
+
var first = links[0], last = links[links.length - 1];
|
|
732
|
+
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
|
|
733
|
+
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
|
|
734
|
+
});
|
|
735
|
+
if (mobileNavQuery.addEventListener) mobileNavQuery.addEventListener('change', resetMobileNav);
|
|
736
|
+
resetMobileNav();
|
|
737
|
+
}
|
|
663
738
|
|
|
664
739
|
// Announcement bar: dismiss (remembered) + rotate messages
|
|
665
740
|
var bar = document.getElementById('announceBar');
|
|
@@ -1,191 +1,2 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* POST /api/commerce/checkout-handoff — the checkout-correlation MINT seam
|
|
3
|
-
* (ext-commerce-10).
|
|
4
|
-
*
|
|
5
|
-
* The shopper's analytics consent is a CLIENT-side decision (CMP / native banner),
|
|
6
|
-
* but the correlation token is minted under the SERVER-only signing secret. So at the
|
|
7
|
-
* handoff the client posts its LIVE consent snapshot and the server mints the token,
|
|
8
|
-
* returning ONLY the opaque passthrough cart field to attach to the managed-cart form.
|
|
9
|
-
* The signing secret never leaves the server; the token carries no PII and no secret.
|
|
10
|
-
*
|
|
11
|
-
* Server-owned by construction:
|
|
12
|
-
* - The tenant and the {@link IntegrationEnvironment} stage come from `context.locals`
|
|
13
|
-
* (the renderer-owned `BrowserEnvironment`), NEVER from the request body — a client
|
|
14
|
-
* cannot mint a cross-tenant or cross-stage token.
|
|
15
|
-
* - The consent snapshot is parsed FAIL-CLOSED (`parseConsentSnapshot`): an absent,
|
|
16
|
-
* forged, or tampered snapshot collapses to default-deny → unattributed.
|
|
17
|
-
* - No store on this tenant, consent withheld, or no signing key → an explicit
|
|
18
|
-
* unattributed response; the handoff proceeds, checkout is never blocked.
|
|
19
|
-
*/
|
|
20
|
-
import type { APIContext } from "astro";
|
|
21
|
-
import {
|
|
22
|
-
enforceCartSubmission,
|
|
23
|
-
foxyCartRedirectUrl,
|
|
24
|
-
parseConsentSnapshot,
|
|
25
|
-
defaultConsentSnapshot,
|
|
26
|
-
buildVariantCartPayload,
|
|
27
|
-
commerceTenantId,
|
|
28
|
-
MAX_CART_QUANTITY,
|
|
29
|
-
type CartDenialCode,
|
|
30
|
-
} from "@tot/public-runtime";
|
|
31
|
-
import { resolveCheckoutHandoffCorrelation } from "@/lib/checkout/commerce/checkoutHandoffCorrelation";
|
|
32
|
-
import { resolveCartSigning, shopperCartDenialMessage } from "@/lib/checkout/shared/checkoutCommerce";
|
|
33
|
-
import { readEnv } from "@/lib/publication/shared/runtimeEnv";
|
|
34
|
-
|
|
35
1
|
export const prerender = false;
|
|
36
|
-
|
|
37
|
-
function json(body: unknown, status = 200): Response {
|
|
38
|
-
return new Response(JSON.stringify(body), {
|
|
39
|
-
status,
|
|
40
|
-
headers: {
|
|
41
|
-
"content-type": "application/json; charset=utf-8",
|
|
42
|
-
"cache-control": "no-store",
|
|
43
|
-
},
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
interface HandoffBody {
|
|
48
|
-
/** The shopper's serialized consent snapshot (the `__TOT_INTEGRATION_CONSENT__` global). */
|
|
49
|
-
consent?: unknown;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function escapeHtml(value: string): string {
|
|
53
|
-
return value.replace(/[&<>"']/g, (ch) => {
|
|
54
|
-
if (ch === "&") return "&";
|
|
55
|
-
if (ch === "<") return "<";
|
|
56
|
-
if (ch === ">") return ">";
|
|
57
|
-
if (ch === '"') return """;
|
|
58
|
-
return "'";
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function cartDenial(code: CartDenialCode, message: string, status: number): Response {
|
|
63
|
-
const body =
|
|
64
|
-
`<!doctype html><html><body><p data-cart-denial="${code}" data-payment-authorized="false">${escapeHtml(message)}</p></body></html>`;
|
|
65
|
-
return new Response(body, {
|
|
66
|
-
status,
|
|
67
|
-
headers: {
|
|
68
|
-
"content-type": "text/html; charset=utf-8",
|
|
69
|
-
"cache-control": "no-store",
|
|
70
|
-
"x-tot-payment-authorized": "false",
|
|
71
|
-
},
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function isCartForm(request: Request): boolean {
|
|
76
|
-
const type = request.headers.get("content-type") ?? "";
|
|
77
|
-
return (
|
|
78
|
-
type.includes("application/x-www-form-urlencoded") ||
|
|
79
|
-
type.includes("multipart/form-data")
|
|
80
|
-
);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Native cart POST. Browser preflight is not consulted. Payment is never authorized. */
|
|
84
|
-
async function cartFormPost(context: APIContext): Promise<Response> {
|
|
85
|
-
const { tenant, commerce } = context.locals;
|
|
86
|
-
if (!commerce) {
|
|
87
|
-
return cartDenial(
|
|
88
|
-
"signing-unavailable",
|
|
89
|
-
"Checkout is unavailable because this store has no cart configured.",
|
|
90
|
-
409,
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
const signing = await resolveCartSigning(commerce);
|
|
94
|
-
if (!signing.enabled) {
|
|
95
|
-
return cartDenial(
|
|
96
|
-
"signing-unavailable",
|
|
97
|
-
shopperCartDenialMessage("signing-unavailable", signing.operatorError ?? ""),
|
|
98
|
-
503,
|
|
99
|
-
);
|
|
100
|
-
}
|
|
101
|
-
const form = await context.request.formData();
|
|
102
|
-
const entries: Array<[string, string]> = [];
|
|
103
|
-
for (const [name, value] of form.entries()) {
|
|
104
|
-
if (typeof value !== "string") return cartDenial("signature", "Invalid cart.", 422);
|
|
105
|
-
entries.push([name, value]);
|
|
106
|
-
}
|
|
107
|
-
if (form.has("sample_product") || form.has("sample_variant")) {
|
|
108
|
-
const sampleTenant = await readEnv("COMMERCE_SAMPLE_TENANT_ID");
|
|
109
|
-
if (
|
|
110
|
-
!sampleTenant || sampleTenant !== commerceTenantId(tenant) ||
|
|
111
|
-
await readEnv("COMMERCE_DEMO_CHECKOUT_TIER") !== "test" || commerce.mode !== "signed"
|
|
112
|
-
) return cartDenial("tenant", "Invalid cart.", 422);
|
|
113
|
-
const allowed = new Set(["sample_product", "sample_variant", "quantity"]);
|
|
114
|
-
if (entries.length !== 3 || entries.some(([name]) => !allowed.has(name)) ||
|
|
115
|
-
new Set(entries.map(([name]) => name)).size !== 3) {
|
|
116
|
-
return cartDenial("signature", "Invalid cart.", 422);
|
|
117
|
-
}
|
|
118
|
-
const quantity = form.get("quantity") as string;
|
|
119
|
-
if (!/^[1-9][0-9]*$/.test(quantity) || !Number.isSafeInteger(Number(quantity)) || Number(quantity) > MAX_CART_QUANTITY) {
|
|
120
|
-
return cartDenial("quantity", "Invalid quantity.", 422);
|
|
121
|
-
}
|
|
122
|
-
const product = await context.locals.tot.getProductByHandle(form.get("sample_product") as string);
|
|
123
|
-
const variant = product?.variants.find((v) => v.id === form.get("sample_variant"));
|
|
124
|
-
if (!product || product.status !== "active" || product.visibility === "hidden" || product.visibility === "unlisted" ||
|
|
125
|
-
!variant || !variant.inventory.available || product.tenant_id !== tenant.tot_data_scope ||
|
|
126
|
-
variant.tenant_id !== tenant.tot_data_scope) {
|
|
127
|
-
return cartDenial("signature", "Product is unavailable.", 422);
|
|
128
|
-
}
|
|
129
|
-
const payload = await buildVariantCartPayload(product, variant, {
|
|
130
|
-
mode: "signed", secret: signing.secret, tenantId: commerceTenantId(tenant),
|
|
131
|
-
});
|
|
132
|
-
entries.splice(0, entries.length, ...payload.hidden.map((f): [string, string] => [f.name, f.value]), [payload.quantityName, quantity]);
|
|
133
|
-
}
|
|
134
|
-
const result = await enforceCartSubmission({
|
|
135
|
-
entries,
|
|
136
|
-
mode: commerce.mode,
|
|
137
|
-
secret: signing.secret,
|
|
138
|
-
trustedTenantId: commerceTenantId(tenant),
|
|
139
|
-
});
|
|
140
|
-
if (!result.ok) {
|
|
141
|
-
const status = result.code === "signing-unavailable" ? 503 : 422;
|
|
142
|
-
return cartDenial(result.code, shopperCartDenialMessage(result.code, result.message), status);
|
|
143
|
-
}
|
|
144
|
-
return new Response(null, {
|
|
145
|
-
status: 303,
|
|
146
|
-
headers: {
|
|
147
|
-
location: foxyCartRedirectUrl(commerce.cartUrl, result.fields),
|
|
148
|
-
"cache-control": "no-store",
|
|
149
|
-
"x-tot-payment-authorized": "false",
|
|
150
|
-
},
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
export async function POST(context: APIContext): Promise<Response> {
|
|
155
|
-
if (isCartForm(context.request)) return cartFormPost(context);
|
|
156
|
-
const { tenant, browserEnvironment, commerce } = context.locals;
|
|
157
|
-
|
|
158
|
-
// No managed checkout on this tenant → nothing to attribute.
|
|
159
|
-
if (!commerce) {
|
|
160
|
-
return json({ attributed: false, reason: "no-store" });
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
let body: HandoffBody;
|
|
164
|
-
try {
|
|
165
|
-
body = (await context.request.json()) as HandoffBody;
|
|
166
|
-
} catch {
|
|
167
|
-
body = {};
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// Fail-closed: an absent/forged/tampered snapshot is default-deny (essential only).
|
|
171
|
-
const consent = parseConsentSnapshot(body.consent) ?? defaultConsentSnapshot();
|
|
172
|
-
|
|
173
|
-
const result = await resolveCheckoutHandoffCorrelation({
|
|
174
|
-
tenant: tenant.tenant_id,
|
|
175
|
-
// Env-consistency: the request's own resolved stage, never a client value.
|
|
176
|
-
environment: browserEnvironment.environment,
|
|
177
|
-
consent,
|
|
178
|
-
// Opaque per-handoff logical id the order join threads back to.
|
|
179
|
-
eventId: crypto.randomUUID(),
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
if (result.attributed) {
|
|
183
|
-
// Return ONLY the opaque passthrough field; the signing secret never leaves here.
|
|
184
|
-
return json({ attributed: true, cartField: result.cartField });
|
|
185
|
-
}
|
|
186
|
-
return json({ attributed: false, reason: result.reason });
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
export async function GET(): Promise<Response> {
|
|
190
|
-
return json({ error: "method_not_allowed" }, 405);
|
|
191
|
-
}
|
|
2
|
+
export { GET, POST } from "@/lib/checkout/commerce/checkoutHandoffRoute";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.4",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|