@tokenoftrust/storefront-runner 4.7.5 → 4.7.7

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.
@@ -150,6 +150,7 @@ body { overflow-x: clip; }
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;
152
152
  }
153
+ .header-actions .cart .count[hidden], .nav-mobile-actions .cart .count[hidden] { display: none; }
153
154
  .header-cta {
154
155
  border-radius: var(--radius-full); padding: 8px 16px;
155
156
  font-family: var(--font-display, inherit); font-size: .875rem; font-weight: 500;
@@ -0,0 +1,34 @@
1
+ ---
2
+ /**
3
+ * <CartState> — the header cart count and the add-to-cart confirmation.
4
+ *
5
+ * Rendered for commerce tenants with a store, on every Layout page, next to the
6
+ * checkout loader. It carries the store's cart URL, session route and cookie path as
7
+ * data, and `cartState.ts` does the rest in the browser: no cart, no request. The
8
+ * same markup is served by the Worker and by the statically published CloudFront/S3
9
+ * output. See `lib/checkout/shared/cartState.ts`.
10
+ */
11
+ import CartAddedDialog from "@/components/checkout/storefront/islands/CartAddedDialog.tsx";
12
+ import { cartSessionCookiePath } from "@/lib/checkout/commerce/cartSession";
13
+
14
+ const { commerce, apiBasePath, basePath } = Astro.locals;
15
+ const apiBase = apiBasePath ?? basePath ?? "";
16
+ ---
17
+ {
18
+ commerce && (
19
+ <>
20
+ <div
21
+ hidden
22
+ data-cart-state
23
+ data-cart-url={commerce.cartUrl}
24
+ data-session-href={`${apiBase}/api/commerce/cart-session`}
25
+ data-cookie-path={cartSessionCookiePath(apiBase)}
26
+ />
27
+ <CartAddedDialog client:idle />
28
+ <script>
29
+ import { bootCartStateFromDom } from "@/lib/checkout/shared/cartState";
30
+ bootCartStateFromDom(window);
31
+ </script>
32
+ </>
33
+ )
34
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * <CartAddedDialog> — the "Item added to your cart" confirmation.
3
+ *
4
+ * Opens when `cartState.ts` reports a finished add (the shopper stays on the page):
5
+ * the product, its selected options, the quantity added, a View my cart (n) link to
6
+ * the managed cart, and Continue shopping. When the provider could not confirm the
7
+ * add, the same dialog says so and offers the cart, so the shopper is never left
8
+ * guessing. Focus moves into the dialog, Tab stays inside it, Escape and the
9
+ * backdrop close it, and focus returns to the control that opened it.
10
+ *
11
+ * Styled from the theme tokens the compliance dialog uses, so it follows the tenant.
12
+ */
13
+ import { useEffect, useRef, useState } from "preact/hooks";
14
+ import {
15
+ CART_ADDED_EVENT,
16
+ takePendingCartAdded,
17
+ type CartAddedDetail,
18
+ } from "../../../../lib/checkout/shared/cartState.js";
19
+
20
+ const FOCUSABLE = 'a[href], button:not([disabled])';
21
+
22
+ export default function CartAddedDialog() {
23
+ const [detail, setDetail] = useState<CartAddedDetail | null>(null);
24
+ const panel = useRef<HTMLDivElement>(null);
25
+ const opener = useRef<Element | null>(null);
26
+
27
+ useEffect(() => {
28
+ const show = (next: CartAddedDetail | null) => {
29
+ if (!next) return;
30
+ opener.current = document.activeElement;
31
+ setDetail(next);
32
+ };
33
+ const onAdded = (event: Event) => show((event as CustomEvent<CartAddedDetail>).detail);
34
+ window.addEventListener(CART_ADDED_EVENT, onAdded);
35
+ show(takePendingCartAdded(window));
36
+ return () => window.removeEventListener(CART_ADDED_EVENT, onAdded);
37
+ }, []);
38
+
39
+ useEffect(() => {
40
+ if (!detail) return;
41
+ document.documentElement.style.overflow = "hidden";
42
+ panel.current?.querySelector<HTMLElement>(FOCUSABLE)?.focus();
43
+ const close = () => setDetail(null);
44
+ const onKey = (event: KeyboardEvent) => {
45
+ if (event.key === "Escape") {
46
+ close();
47
+ return;
48
+ }
49
+ if (event.key !== "Tab" || !panel.current) return;
50
+ const items = [...panel.current.querySelectorAll<HTMLElement>(FOCUSABLE)];
51
+ const first = items[0];
52
+ const last = items[items.length - 1];
53
+ if (!first || !last) return;
54
+ if (event.shiftKey && document.activeElement === first) {
55
+ event.preventDefault();
56
+ last.focus();
57
+ } else if (!event.shiftKey && document.activeElement === last) {
58
+ event.preventDefault();
59
+ first.focus();
60
+ }
61
+ };
62
+ document.addEventListener("keydown", onKey);
63
+ return () => {
64
+ document.removeEventListener("keydown", onKey);
65
+ document.documentElement.style.overflow = "";
66
+ if (opener.current instanceof HTMLElement) opener.current.focus();
67
+ };
68
+ }, [detail]);
69
+
70
+ if (!detail) return null;
71
+ const close = () => setDetail(null);
72
+ const added = detail.status === "added";
73
+ const buttonClass =
74
+ "rounded-full px-6 py-3 text-center font-display text-sm font-medium uppercase tracking-[0.08em] transition-opacity hover:opacity-90";
75
+
76
+ return (
77
+ <div
78
+ class="fixed inset-0 z-[110] bg-[color-mix(in_srgb,var(--color-text)_75%,transparent)] backdrop-blur-sm"
79
+ role="dialog"
80
+ aria-modal="true"
81
+ aria-labelledby="cart-added-title"
82
+ data-cart-added={detail.status}
83
+ onClick={(e) => {
84
+ if (e.target === e.currentTarget || (e.target as HTMLElement).hasAttribute("data-viewport-frame")) close();
85
+ }}
86
+ >
87
+ <div data-viewport-frame class="absolute left-0 top-0 flex h-[100svh] w-[100vw] overflow-y-auto p-5">
88
+ <div
89
+ ref={panel}
90
+ class="m-auto w-[min(28rem,100%)] rounded-[var(--radius-lg)] bg-[var(--color-surface)] p-7 text-left shadow-[var(--shadow-lg)]"
91
+ >
92
+ <h2 id="cart-added-title" class="display-sm">
93
+ {added ? "Item added to your cart" : "We couldn't confirm your cart"}
94
+ </h2>
95
+
96
+ {detail.status === "added" ? (
97
+ <div class="mt-5 flex gap-4" data-cart-added-item>
98
+ {detail.image && (
99
+ <img
100
+ src={detail.image}
101
+ alt=""
102
+ width="72"
103
+ height="72"
104
+ class="h-[72px] w-[72px] flex-none rounded-[var(--radius-md)] border border-[var(--color-border)] object-cover"
105
+ />
106
+ )}
107
+ <div class="min-w-0">
108
+ <p class="font-display text-base font-semibold text-[var(--color-text)]">{detail.title}</p>
109
+ {detail.options.length > 0 && (
110
+ <dl class="mt-1 text-sm text-[var(--color-muted)]">
111
+ {detail.options.map((option) => (
112
+ <div key={option.name} class="flex gap-1">
113
+ <dt>{option.name}:</dt>
114
+ <dd>{option.value}</dd>
115
+ </div>
116
+ ))}
117
+ </dl>
118
+ )}
119
+ <p class="mt-1 text-sm text-[var(--color-muted)]">Quantity: {detail.quantity}</p>
120
+ </div>
121
+ </div>
122
+ ) : (
123
+ <p class="mt-3 text-sm text-[var(--color-text)]">
124
+ Check your cart to see whether the item was added.
125
+ </p>
126
+ )}
127
+
128
+ <div class="mt-6 flex flex-col gap-3">
129
+ <a
130
+ href={detail.cartHref}
131
+ class={`${buttonClass} bg-[var(--color-primary)] text-[var(--color-primary-contrast)]`}
132
+ >
133
+ {detail.status === "added" ? `View my cart (${detail.count})` : "View my cart"}
134
+ </a>
135
+ <button
136
+ type="button"
137
+ onClick={close}
138
+ class={`${buttonClass} border border-[var(--color-border)] text-[var(--color-text)] hover:border-[var(--color-text)]`}
139
+ >
140
+ Continue shopping
141
+ </button>
142
+ </div>
143
+ </div>
144
+ </div>
145
+ </div>
146
+ );
147
+ }
@@ -11,7 +11,8 @@
11
11
  * session/feature/theme state and passes it down; this component stays pure.
12
12
  *
13
13
  * COMMERCE BOUNDARY: the cart is a LINK only — no cart state, no add-to-cart.
14
- * The count is a static affordance the managed cart can hydrate later.
14
+ * The count renders hidden at zero; `lib/checkout/shared/cartState.ts` fills the
15
+ * `[data-cart-count]` badge from the managed cart after load and after an add.
15
16
  */
16
17
  import type { ChromeNavItem, ChromeLogo, ChromeCta, HeaderVariant } from "@/lib/content/chrome/model";
17
18
  import MegaMenu from "@/components/content/storefront/chrome/MegaMenu.astro";
@@ -336,7 +337,9 @@ const currentPath = Astro.url.pathname;
336
337
  </svg>
337
338
  <span
338
339
  data-cart-count
339
- class="absolute -right-0.5 -top-0.5 grid h-4 min-w-4 place-items-center rounded-full bg-[var(--color-sale)] px-1 font-mono text-[0.6rem] font-medium text-white"
340
+ aria-live="polite"
341
+ hidden={cartCount === 0}
342
+ class="absolute -right-0.5 -top-0.5 grid h-4 min-w-4 place-items-center rounded-full bg-[var(--color-sale)] px-1 font-mono text-[0.6rem] font-medium text-white [&[hidden]]:hidden"
340
343
  >
341
344
  {cartCount}
342
345
  </span>
@@ -14,6 +14,7 @@ import SiteHeader from "@/components/content/storefront/chrome/SiteHeader.astro"
14
14
  import SiteFooter from "@/components/content/storefront/chrome/SiteFooter.astro";
15
15
  import QuickView from "@/components/catalog/storefront/islands/QuickView.tsx";
16
16
  import CheckoutLoader from "@/components/checkout/storefront/commerce/CheckoutLoader.astro";
17
+ import CartState from "@/components/checkout/storefront/commerce/CartState.astro";
17
18
  import CartDrawerStyles from "@/components/checkout/storefront/commerce/CartDrawerStyles.astro";
18
19
  import NicotineWarning from "@/components/compliance/storefront/compliance/NicotineWarning.astro";
19
20
  import PactActNotice from "@/components/compliance/storefront/compliance/PactActNotice.astro";
@@ -518,6 +519,7 @@ const footerWidgets = await renderWidgetSlot(
518
519
  {/* Checkout loader — turns add-to-cart forms into the cart drawer + hosted
519
520
  checkout. Only for commerce tenants with a store. */}
520
521
  <CheckoutLoader nonce={cspNonce} />
522
+ <CartState />
521
523
 
522
524
  {/* Brand the in-page cart drawer to the tenant theme (baseline + optional
523
525
  per-tenant commerce.checkout.cartDrawerCss override). Commerce tenants only. */}
@@ -220,6 +220,18 @@ async function cartFormPost(context: APIContext): Promise<Response> {
220
220
  });
221
221
  const location = await sessionedCartLocation(context, commerce, tenant, returned);
222
222
  if (!location) return cartDenial("signing-unavailable", CART_UNAVAILABLE_MESSAGE, 503);
223
+ // The page adds the line itself (cartState.ts) and asks for the signed cart URL as data.
224
+ if ((context.request.headers.get("accept") ?? "").includes("application/json")) {
225
+ return new Response(JSON.stringify({ location }), {
226
+ status: 200,
227
+ headers: {
228
+ "content-type": "application/json; charset=utf-8",
229
+ "cache-control": "no-store",
230
+ "referrer-policy": CHECKOUT_NAVIGATION_REFERRER_POLICY,
231
+ "x-tot-payment-authorized": "false",
232
+ },
233
+ });
234
+ }
223
235
  return new Response(null, {
224
236
  status: 303,
225
237
  headers: {
@@ -0,0 +1,511 @@
1
+ /**
2
+ * Browser cart state for a statically served store: the header cart count and the
3
+ * add-to-cart confirmation.
4
+ *
5
+ * The cart session lives on the managed checkout's origin, so a store page cannot
6
+ * read it with `fetch` (the provider's cart JSON carries no CORS headers). The
7
+ * provider answers a `callback` parameter with a script, so the page reads the cart
8
+ * the way the provider's own drawer does: a script tag to the checkout origin,
9
+ * carrying this store's session id. That id comes from the store's own
10
+ * `/api/commerce/cart-session` route, which the checkout loader already calls.
11
+ *
12
+ * Nothing here puts compute in front of a public page. A visitor with no cart makes
13
+ * no request at all: the last known count is kept in this browser's own storage and
14
+ * a read happens only when that count is above zero. The same code and markup are
15
+ * served by the Cloudflare Worker and by the statically published CloudFront/S3
16
+ * output; both reach the checkout origin and `/api/*` the same way.
17
+ *
18
+ * An add-to-cart submit that the compliance gate and the checkout loader have
19
+ * released is sent to the store's cart route as JSON, the signed provider cart URL
20
+ * it returns is loaded as a script (which adds the line and returns the cart), and
21
+ * the shopper stays on the page with a confirmation. Any failure before the provider
22
+ * is asked hands the form back to the native submit, which opens the cart page. A
23
+ * failure after the provider is asked never resubmits: that could add the line twice.
24
+ */
25
+
26
+ /** Window event carrying a {@link CartAddedDetail}. */
27
+ export const CART_ADDED_EVENT = "tot:cart-added";
28
+
29
+ /** Mirrors `INTERNAL_CART_FIELDS` in `@tot/public-runtime`; a test pins the two together. */
30
+ export const INTERNAL_CART_OPTIONS: readonly string[] = [
31
+ "currency",
32
+ "tenant",
33
+ "verification",
34
+ "shipping_policy",
35
+ "return_url",
36
+ "content_version",
37
+ ];
38
+
39
+ const MANAGED_CART_FORM = "form[data-managed-cart]";
40
+ const COUNT_SELECTOR = "[data-cart-count], .cart .count";
41
+ const JSONP_TIMEOUT_MS = 8000;
42
+ const SESSION_TIMEOUT_MS = 5000;
43
+
44
+ export interface CartStateConfig {
45
+ /** `https://<checkout origin>/cart`. */
46
+ cartUrl: string;
47
+ /** Same-origin URL returning this store's `{ fcsid }`. */
48
+ sessionHref: string;
49
+ /** The store's cookie path; keys the stored count so tenants on one host stay apart. */
50
+ cookiePath: string;
51
+ }
52
+
53
+ export interface CartOption {
54
+ name: string;
55
+ value: string;
56
+ }
57
+
58
+ export interface CartLine {
59
+ code: string;
60
+ name: string;
61
+ quantity: number;
62
+ image: string | null;
63
+ options: CartOption[];
64
+ }
65
+
66
+ export interface ParsedCart {
67
+ count: number;
68
+ sessionId: string | null;
69
+ lines: CartLine[];
70
+ /** Messages the provider refused the request with. Empty when it accepted. */
71
+ errors: string[];
72
+ }
73
+
74
+ export type CartAddedDetail =
75
+ | {
76
+ status: "added";
77
+ title: string;
78
+ options: CartOption[];
79
+ quantity: number;
80
+ image: string | null;
81
+ count: number;
82
+ cartHref: string;
83
+ }
84
+ | { status: "error"; cartHref: string };
85
+
86
+ function record(value: unknown): Record<string, unknown> | null {
87
+ return value && typeof value === "object" && !Array.isArray(value)
88
+ ? (value as Record<string, unknown>)
89
+ : null;
90
+ }
91
+
92
+ /** An option name as one key: signed suffix dropped, spaces and underscores equal. */
93
+ export function optionKey(raw: string): string {
94
+ return (raw.split("||")[0] ?? "").trim().toLowerCase().replace(/[\s_]+/g, "_");
95
+ }
96
+
97
+ function optionLabel(raw: string): string {
98
+ return (raw.split("||")[0] ?? "").trim().replace(/_/g, " ");
99
+ }
100
+
101
+ function readOptions(raw: unknown): CartOption[] {
102
+ const out: CartOption[] = [];
103
+ const push = (name: unknown, value: unknown) => {
104
+ if (typeof name !== "string" || typeof value !== "string") return;
105
+ const key = optionKey(name);
106
+ const text = value.trim();
107
+ if (!key || !text || INTERNAL_CART_OPTIONS.includes(key)) return;
108
+ out.push({ name: optionLabel(name), value: text });
109
+ };
110
+ if (Array.isArray(raw)) {
111
+ for (const entry of raw) {
112
+ const option = record(entry);
113
+ if (option) push(option.name ?? option.class, option.value);
114
+ }
115
+ } else {
116
+ const map = record(raw);
117
+ if (map) for (const [name, value] of Object.entries(map)) push(name, value);
118
+ }
119
+ return out;
120
+ }
121
+
122
+ function imageUrl(raw: unknown): string | null {
123
+ if (typeof raw !== "string" || !raw) return null;
124
+ if (raw.startsWith("/") && !raw.startsWith("//")) return raw;
125
+ return /^https:\/\/[^\s/]+/.test(raw) ? raw : null;
126
+ }
127
+
128
+ function wholeNumber(raw: unknown): number {
129
+ const n = typeof raw === "number" ? raw : Number(raw);
130
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
131
+ }
132
+
133
+ /** The provider's cart JSON as a typed cart, or null when the body is not a cart. */
134
+ export function parseCart(body: unknown): ParsedCart | null {
135
+ const cart = record(body);
136
+ if (!cart) return null;
137
+ const rawItems = Array.isArray(cart.items) ? cart.items : null;
138
+ if (!rawItems && typeof cart.item_count !== "number") return null;
139
+ const lines: CartLine[] = [];
140
+ for (const entry of rawItems ?? []) {
141
+ const item = record(entry);
142
+ if (!item) continue;
143
+ lines.push({
144
+ code: typeof item.code === "string" ? item.code : "",
145
+ name: typeof item.name === "string" ? item.name : "",
146
+ quantity: wholeNumber(item.quantity),
147
+ image: imageUrl(item.image),
148
+ options: readOptions(item.options),
149
+ });
150
+ }
151
+ const count = lines.length > 0
152
+ ? lines.reduce((sum, line) => sum + line.quantity, 0)
153
+ : wholeNumber(cart.item_count);
154
+ const messages = record(cart.messages);
155
+ const errors = Array.isArray(messages?.errors)
156
+ ? messages.errors.flatMap((e) => {
157
+ const message = record(e)?.message;
158
+ return typeof message === "string" ? [message] : [];
159
+ })
160
+ : [];
161
+ const session = cart.session_id;
162
+ return {
163
+ count,
164
+ sessionId: typeof session === "string" && /^[a-z0-9]{10,64}$/.test(session) ? session : null,
165
+ lines,
166
+ errors,
167
+ };
168
+ }
169
+
170
+ export function cartAriaLabel(count: number): string {
171
+ return count > 0 ? `Cart, ${count} ${count === 1 ? "item" : "items"}` : "Cart";
172
+ }
173
+
174
+ /**
175
+ * Show `count` in every header cart badge: text, hidden at zero, polite live region,
176
+ * and the cart link's own label when it carries one.
177
+ */
178
+ export function renderCartCount(doc: Document, count: number): void {
179
+ for (const badge of doc.querySelectorAll<HTMLElement>(COUNT_SELECTOR)) {
180
+ badge.textContent = String(count);
181
+ badge.hidden = count === 0;
182
+ // A tenant stylesheet that sets `display` on the badge outranks [hidden].
183
+ badge.style.display = count === 0 ? "none" : "";
184
+ badge.setAttribute("aria-live", "polite");
185
+ const link = badge.closest("a");
186
+ if (link && /^Cart\b/i.test(link.getAttribute("aria-label") ?? "")) {
187
+ link.setAttribute("aria-label", cartAriaLabel(count));
188
+ }
189
+ }
190
+ }
191
+
192
+ export function storedCountKey(cookiePath: string): string {
193
+ return `tot:cart-count:${cookiePath}`;
194
+ }
195
+
196
+ export function readStoredCount(storage: Pick<Storage, "getItem"> | null, key: string): number {
197
+ try {
198
+ return wholeNumber(storage?.getItem(key));
199
+ } catch {
200
+ return 0;
201
+ }
202
+ }
203
+
204
+ export function writeStoredCount(
205
+ storage: Pick<Storage, "setItem" | "removeItem"> | null,
206
+ key: string,
207
+ count: number,
208
+ ): void {
209
+ try {
210
+ if (count > 0) storage?.setItem(key, String(count));
211
+ else storage?.removeItem(key);
212
+ } catch {
213
+ /* storage disabled: the count then lives only until the next read */
214
+ }
215
+ }
216
+
217
+ export interface CartEnv {
218
+ doc: Document;
219
+ win: Window;
220
+ fetch: typeof fetch;
221
+ storage: Storage | null;
222
+ }
223
+
224
+ type JsonpHost = Window & Record<string, unknown>;
225
+
226
+ /**
227
+ * Load `url` (a checkout-origin cart URL) as a script that answers with the cart JSON.
228
+ * Rejects when the URL is not on the checkout origin, the script fails, or it is silent.
229
+ */
230
+ export function loadCartJsonp(
231
+ url: string,
232
+ cartOrigin: string,
233
+ env: Pick<CartEnv, "doc" | "win">,
234
+ timeoutMs = JSONP_TIMEOUT_MS,
235
+ ): Promise<unknown> {
236
+ return new Promise((resolve, reject) => {
237
+ let target: URL;
238
+ try {
239
+ target = new URL(url);
240
+ } catch {
241
+ reject(new Error("cart-url"));
242
+ return;
243
+ }
244
+ if (target.origin !== cartOrigin) {
245
+ reject(new Error("cart-origin"));
246
+ return;
247
+ }
248
+ const host = env.win as JsonpHost;
249
+ const name = `totCart${Math.random().toString(36).slice(2, 10)}`;
250
+ const script = env.doc.createElement("script");
251
+ let timer: ReturnType<typeof setTimeout> | undefined;
252
+ const done = () => {
253
+ if (timer) clearTimeout(timer);
254
+ delete host[name];
255
+ script.remove();
256
+ };
257
+ host[name] = (body: unknown) => {
258
+ done();
259
+ resolve(body);
260
+ };
261
+ target.searchParams.set("output", "json");
262
+ target.searchParams.set("callback", name);
263
+ script.src = target.toString();
264
+ script.async = true;
265
+ script.addEventListener("error", () => {
266
+ done();
267
+ reject(new Error("cart-script"));
268
+ });
269
+ timer = setTimeout(() => {
270
+ done();
271
+ reject(new Error("cart-timeout"));
272
+ }, timeoutMs);
273
+ env.doc.head.appendChild(script);
274
+ });
275
+ }
276
+
277
+ /** A read that failed is not an empty cart: callers keep what they showed. */
278
+ export type CartRead =
279
+ | { status: "cart"; cart: ParsedCart }
280
+ | { status: "unreadable"; reason: string };
281
+
282
+ function reasonOf(error: unknown): string {
283
+ return error instanceof Error ? error.message || error.name : "error";
284
+ }
285
+
286
+ async function readSessionId(
287
+ config: CartStateConfig,
288
+ env: CartEnv,
289
+ ): Promise<{ status: "session"; id: string } | { status: "unreadable"; reason: string }> {
290
+ try {
291
+ const res = await env.fetch(config.sessionHref, {
292
+ credentials: "same-origin",
293
+ cache: "no-store",
294
+ signal: AbortSignal.timeout(SESSION_TIMEOUT_MS),
295
+ });
296
+ if (!res.ok) return { status: "unreadable", reason: `session-${res.status}` };
297
+ const id = record(await res.json())?.fcsid;
298
+ return typeof id === "string" && /^[a-z0-9]{10,64}$/.test(id)
299
+ ? { status: "session", id }
300
+ : { status: "unreadable", reason: "session-id" };
301
+ } catch (error) {
302
+ return { status: "unreadable", reason: reasonOf(error) };
303
+ }
304
+ }
305
+
306
+ /** This store's cart as the provider holds it. */
307
+ export async function readCart(config: CartStateConfig, env: CartEnv): Promise<CartRead> {
308
+ const session = await readSessionId(config, env);
309
+ if (session.status !== "session") return session;
310
+ const url = new URL(config.cartUrl);
311
+ url.searchParams.set("fcsid", session.id);
312
+ try {
313
+ const cart = parseCart(await loadCartJsonp(url.toString(), url.origin, env));
314
+ if (!cart) return { status: "unreadable", reason: "not-a-cart" };
315
+ if (cart.errors.length > 0) return { status: "unreadable", reason: "refused" };
316
+ return { status: "cart", cart };
317
+ } catch (error) {
318
+ return { status: "unreadable", reason: reasonOf(error) };
319
+ }
320
+ }
321
+
322
+ function fieldBase(name: string): string {
323
+ return name.split("||")[0] ?? "";
324
+ }
325
+
326
+ function submittedField(data: FormData, base: string): string {
327
+ for (const [name, value] of data.entries()) {
328
+ if (fieldBase(name) === base && typeof value === "string") return value;
329
+ }
330
+ return "";
331
+ }
332
+
333
+ function cartHrefFor(config: CartStateConfig, sessionId: string | null): string {
334
+ if (!sessionId) return config.cartUrl;
335
+ const url = new URL(config.cartUrl);
336
+ url.searchParams.set("fcsid", sessionId);
337
+ return url.toString();
338
+ }
339
+
340
+ /**
341
+ * The cart line a submitted form produced: the line carrying its `code`, else the
342
+ * last line the cart holds.
343
+ */
344
+ export function addedLine(cart: ParsedCart, code: string): CartLine | null {
345
+ const match = code ? cart.lines.find((line) => line.code === code) : undefined;
346
+ return match ?? cart.lines[cart.lines.length - 1] ?? null;
347
+ }
348
+
349
+ export function describeAdded(
350
+ cart: ParsedCart,
351
+ form: { title: string; code: string; quantity: number },
352
+ cartHref: string,
353
+ ): CartAddedDetail {
354
+ const line = addedLine(cart, form.code);
355
+ return {
356
+ status: "added",
357
+ title: form.title || line?.name || "Item",
358
+ options: line?.options ?? [],
359
+ quantity: form.quantity,
360
+ image: line?.image ?? null,
361
+ count: cart.count,
362
+ cartHref,
363
+ };
364
+ }
365
+
366
+ function announce(env: CartEnv, detail: CartAddedDetail): void {
367
+ (env.win as JsonpHost).__totPendingCartAdded = detail;
368
+ env.win.dispatchEvent(new CustomEvent(CART_ADDED_EVENT, { detail }));
369
+ }
370
+
371
+ /** The confirmation raised before its dialog was ready, handed over once. */
372
+ export function takePendingCartAdded(win: Window): CartAddedDetail | null {
373
+ const host = win as JsonpHost;
374
+ const detail = host.__totPendingCartAdded as CartAddedDetail | undefined;
375
+ delete host.__totPendingCartAdded;
376
+ return detail ?? null;
377
+ }
378
+
379
+ /**
380
+ * Add `form`'s line without leaving the page. Resolves false when nothing was sent
381
+ * to the provider, so the caller hands the form to the native submit; true once the
382
+ * provider was asked, whatever it answered.
383
+ */
384
+ export async function addFromForm(
385
+ form: HTMLFormElement,
386
+ config: CartStateConfig,
387
+ env: CartEnv,
388
+ ): Promise<boolean> {
389
+ const cartOrigin = new URL(config.cartUrl).origin;
390
+ const data = new FormData(form);
391
+ let location: string;
392
+ try {
393
+ const res = await env.fetch(form.action, {
394
+ method: "POST",
395
+ body: data,
396
+ headers: { accept: "application/json" },
397
+ credentials: "same-origin",
398
+ });
399
+ if (!res.ok) return false;
400
+ const next = record(await res.json())?.location;
401
+ if (typeof next !== "string" || new URL(next).origin !== cartOrigin) return false;
402
+ location = next;
403
+ } catch {
404
+ return false;
405
+ }
406
+ const key = storedCountKey(config.cookiePath);
407
+ const quantity = wholeNumber(submittedField(data, "quantity")) || 1;
408
+ try {
409
+ const cart = parseCart(await loadCartJsonp(location, cartOrigin, env));
410
+ if (!cart || cart.errors.length > 0) {
411
+ announce(env, { status: "error", cartHref: config.cartUrl });
412
+ return true;
413
+ }
414
+ writeStoredCount(env.storage, key, cart.count);
415
+ renderCartCount(env.doc, cart.count);
416
+ announce(
417
+ env,
418
+ describeAdded(
419
+ cart,
420
+ { title: form.dataset.productTitle ?? "", code: submittedField(data, "code"), quantity },
421
+ cartHrefFor(config, cart.sessionId),
422
+ ),
423
+ );
424
+ } catch {
425
+ announce(env, { status: "error", cartHref: config.cartUrl });
426
+ }
427
+ return true;
428
+ }
429
+
430
+ /**
431
+ * Boot from the `[data-cart-state]` element the layout renders with this store's
432
+ * cart URL, session route and cookie path. No element, no cart: nothing runs.
433
+ */
434
+ export function bootCartStateFromDom(win: Window): void {
435
+ const el = win.document.querySelector<HTMLElement>("[data-cart-state]");
436
+ const { cartUrl, sessionHref, cookiePath } = el?.dataset ?? {};
437
+ if (!cartUrl || !sessionHref || !cookiePath) return;
438
+ let storage: Storage | null = null;
439
+ try {
440
+ storage = win.localStorage;
441
+ } catch {
442
+ storage = null;
443
+ }
444
+ bootCartState(
445
+ { cartUrl, sessionHref, cookiePath },
446
+ { doc: win.document, win, fetch: win.fetch.bind(win), storage },
447
+ );
448
+ }
449
+
450
+ /**
451
+ * Wire the page: show the last known count at once, refresh it from the cart when
452
+ * it is above zero, keep it current across tabs and back-forward restores, and take
453
+ * over released add-to-cart submits.
454
+ */
455
+ export function bootCartState(config: CartStateConfig, env: CartEnv): () => void {
456
+ const key = storedCountKey(config.cookiePath);
457
+ const known = () => readStoredCount(env.storage, key);
458
+ renderCartCount(env.doc, known());
459
+
460
+ async function refresh(): Promise<void> {
461
+ if (known() === 0) return;
462
+ const read = await readCart(config, env);
463
+ if (read.status !== "cart") return;
464
+ writeStoredCount(env.storage, key, read.cart.count);
465
+ renderCartCount(env.doc, read.cart.count);
466
+ }
467
+ void refresh();
468
+
469
+ const onStorage = (event: StorageEvent) => {
470
+ if (event.key === key) renderCartCount(env.doc, known());
471
+ };
472
+ const onPageShow = (event: PageTransitionEvent) => {
473
+ if (event.persisted) void refresh();
474
+ };
475
+ const onVisible = () => {
476
+ if (env.doc.visibilityState === "visible") void refresh();
477
+ };
478
+ // The checkout loader releases a managed form by setting `cartSession` and
479
+ // submitting it again; that second submit is the one taken here. Registered on the
480
+ // document, ahead of the loader's window listener, so a prevented event ends its flow.
481
+ const onSubmit = (event: Event) => {
482
+ const form = event.target;
483
+ if (!(form instanceof HTMLFormElement) || !form.matches(MANAGED_CART_FORM)) return;
484
+ if (form.dataset.cartSession !== "1" || form.dataset.cartNative === "1") return;
485
+ event.preventDefault();
486
+ if (form.getAttribute("aria-busy") === "true") return;
487
+ form.setAttribute("aria-busy", "true");
488
+ void addFromForm(form, config, env).then((handled) => {
489
+ form.removeAttribute("aria-busy");
490
+ if (handled) return;
491
+ // Nothing reached the provider: the native submit, which opens the cart page.
492
+ form.dataset.cartNative = "1";
493
+ try {
494
+ if (typeof form.requestSubmit === "function") form.requestSubmit();
495
+ else form.submit();
496
+ } finally {
497
+ delete form.dataset.cartNative;
498
+ }
499
+ });
500
+ };
501
+ env.win.addEventListener("storage", onStorage);
502
+ env.win.addEventListener("pageshow", onPageShow);
503
+ env.doc.addEventListener("visibilitychange", onVisible);
504
+ env.doc.addEventListener("submit", onSubmit);
505
+ return () => {
506
+ env.win.removeEventListener("storage", onStorage);
507
+ env.win.removeEventListener("pageshow", onPageShow);
508
+ env.doc.removeEventListener("visibilitychange", onVisible);
509
+ env.doc.removeEventListener("submit", onSubmit);
510
+ };
511
+ }
@@ -301,6 +301,12 @@ function renderSearchForm(search: ChromeSearch, placement: "row" | "menu" | "pho
301
301
  );
302
302
  }
303
303
 
304
+ /**
305
+ * The header cart badge: hidden at zero, a polite live region.
306
+ * `lib/checkout/shared/cartState.ts` fills it from the managed cart.
307
+ */
308
+ const CART_COUNT_BADGE = `<span class="count" aria-live="polite" hidden>0</span>`;
309
+
304
310
  /**
305
311
  * Labelled header action links, in authored order. A `kind: "cart"` link carries
306
312
  * the `cart` class, so bindManagedCartAnchors (lib/content/chrome/managedCart.ts) binds it
@@ -320,7 +326,7 @@ function renderActionLinks(links: ChromeHeaderActionLink[]): string {
320
326
  index: i,
321
327
  });
322
328
  const cls = kind === "cart" ? "header-action cart" : "header-action";
323
- const count = kind === "cart" ? `<span class="count">0</span>` : "";
329
+ const count = kind === "cart" ? CART_COUNT_BADGE : "";
324
330
  return (
325
331
  `<a class="${cls}" data-kind="${kind}" href="${esc(l.href)}"${relFor(l)}${stamp}>` +
326
332
  `${icon}<span class="header-action__label">${esc(l.label)}</span>${count}</a>`
@@ -348,7 +354,7 @@ function renderHeader(header: ChromeHeader, variant: HeaderVariant, currentPath?
348
354
  ? `<a href="${esc(header.accountHref)}" aria-label="Account">${ICON_ACCOUNT}</a>`
349
355
  : "";
350
356
  const cart = !actionLinks && header.cartHref
351
- ? `<a class="cart" href="${esc(header.cartHref)}" aria-label="Cart">${ICON_CART}<span class="count">0</span></a>`
357
+ ? `<a class="cart" href="${esc(header.cartHref)}" aria-label="Cart">${ICON_CART}${CART_COUNT_BADGE}</a>`
352
358
  : "";
353
359
  const compact = variant === "compact-centered";
354
360
  // "minimal" (e.g. checkout/policy pages): brand + a reduced action set only —
@@ -169,9 +169,18 @@ export async function sitemapIndexResponse(context: APIContext): Promise<Respons
169
169
  return new Response(renderSitemapIndexV1(context.locals.canonicalBase, children), { headers: XML_HEADERS });
170
170
  }
171
171
 
172
+ /**
173
+ * The child path a request names: the last segment of the URL path. Read from the URL, never from
174
+ * `context.params`: a path-routed request (`/<domain>/sitemap-pages-1.xml`) reaches this endpoint
175
+ * through the middleware's rewrite, which leaves the catch-all's params on the context.
176
+ */
177
+ export function sitemapChildPathOf(context: APIContext): string {
178
+ return `/${context.url.pathname.split("/").pop() ?? ""}`;
179
+ }
180
+
172
181
  /** `/sitemap-<group>-<page>.xml`: one child's `<urlset>`, or 404 when the index names no such child. */
173
- export async function sitemapChildResponse(context: APIContext, childPath: string): Promise<Response> {
174
- const named = parseSitemapChildPathV1(childPath);
182
+ export async function sitemapChildResponse(context: APIContext): Promise<Response> {
183
+ const named = parseSitemapChildPathV1(sitemapChildPathOf(context));
175
184
  if (!named) return new Response("Not found", { status: 404, headers: { "content-type": "text/plain; charset=utf-8" } });
176
185
  const children = await tenantSitemapChildren(context.locals);
177
186
  const child = children.find((c) => c.group === named.group && c.page === named.page);
@@ -6,5 +6,5 @@ import type { APIContext } from "astro";
6
6
  import { sitemapChildResponse } from "./_sitemap.js";
7
7
 
8
8
  export function GET(context: APIContext): Promise<Response> {
9
- return sitemapChildResponse(context, `/sitemap-${String(context.params.child ?? "")}.xml`);
9
+ return sitemapChildResponse(context);
10
10
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "4.7.5",
3
+ "version": "4.7.7",
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",
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from "node:fs";
19
19
  import { dirname, join } from "node:path";
20
+ import { parseSitemapChildPathV1 } from "./sitemap-index.mjs";
20
21
 
21
22
  /** Where `tot clone` puts each mounted space, relative to the root checkout: `spaces/<spaceId>/`. */
22
23
  export const SPACES_DIR = "spaces";
@@ -26,7 +27,9 @@ export const TOPOLOGY_VERSION = 1;
26
27
 
27
28
  /**
28
29
  * Platform-owned paths no space serves (packages/public-runtime PUBLICATION_SPACE_RESERVED_MOUNTS;
29
- * the ownership parity test holds the two equal).
30
+ * the ownership parity test holds the two equal). Child sitemaps (`/sitemap-<group>-<page>.xml`) are
31
+ * platform-owned too, recognized by `parseSitemapChildPathV1` (a byte-identical copy of
32
+ * packages/public-runtime/src/sitemap-index.mjs, pinned by test/sitemap-index-copy.test.mjs).
30
33
  */
31
34
  export const PLATFORM_PATHS = Object.freeze([
32
35
  "/.well-known", "/__evidence", "/__preview", "/_a", "/_astro", "/_style-guide", "/admin", "/api",
@@ -167,7 +170,9 @@ export function owningSpace(topology, urlPath) {
167
170
  const path = normalizeRequestPath(urlPath);
168
171
  if (path === null) return null;
169
172
  const folded = path.toLowerCase();
170
- if (PLATFORM_PATHS.some((reserved) => mountMatchesPath(reserved, folded))) return { space: null, via: "platform" };
173
+ if (PLATFORM_PATHS.some((reserved) => mountMatchesPath(reserved, folded)) || parseSitemapChildPathV1(folded) !== null) {
174
+ return { space: null, via: "platform" };
175
+ }
171
176
  /** @type {{ space: TopologySpace, via: "mount"|"alias", mount: string } | null} */
172
177
  let best = null;
173
178
  for (const space of topology.spaces) {
@@ -44,8 +44,9 @@ export function sitemapChildPathV1(group, page) {
44
44
  export function parseSitemapChildPathV1(path) {
45
45
  if (typeof path !== "string") return null;
46
46
  const match = CHILD_PATH.exec(path);
47
- if (!match || !SITEMAP_GROUPS_V1.includes(match[1])) return null;
48
- return { group: match[1], page: Number(match[2]) };
47
+ const group = match?.[1];
48
+ if (!match || group === undefined || !SITEMAP_GROUPS_V1.includes(group)) return null;
49
+ return { group, page: Number(match[2]) };
49
50
  }
50
51
 
51
52
  /** True for `/sitemap.xml` and for every well-formed child sitemap path. @param {string} path */
@@ -139,7 +140,7 @@ export function isSitemapIndexXmlV1(xml) {
139
140
  */
140
141
  export function sitemapIndexLocsV1(xml) {
141
142
  if (!isSitemapIndexXmlV1(xml)) return [];
142
- return [...xml.matchAll(/<sitemap>\s*<loc>([^<]*)<\/loc>/g)].map((match) => match[1].trim());
143
+ return [...xml.matchAll(/<sitemap>\s*<loc>([^<]*)<\/loc>/g)].map((match) => (match[1] ?? "").trim());
143
144
  }
144
145
 
145
146
  /**