@tokenoftrust/storefront-runner 4.4.0 → 4.6.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.
Files changed (30) hide show
  1. package/agency-kit/tools/lib/catalog-model/files.mjs +30 -3
  2. package/agency-kit/tools/lib/catalog-model/sku.d.mts +16 -0
  3. package/agency-kit/tools/lib/catalog-model/sku.mjs +146 -0
  4. package/apps/storefront/src/components/catalog/storefront/plp/FacetSidebar.astro +29 -13
  5. package/apps/storefront/src/components/content/storefront/chrome/SiteHeader.astro +20 -2
  6. package/apps/storefront/src/layouts/Layout.astro +20 -2
  7. package/apps/storefront/src/lib/checkout/commerce/finalCartAuthorization.ts +9 -0
  8. package/apps/storefront/src/lib/checkout/shared/lazyCheckoutLoader.ts +120 -29
  9. package/apps/storefront/src/lib/content/shared/rawChrome.ts +15 -2
  10. package/apps/storefront/src/lib/content/shared/rawMarketingHtml.ts +26 -0
  11. package/apps/storefront/src/lib/inventory/allocations.ts +3 -3
  12. package/apps/storefront/src/lib/inventory/mirrorStock.ts +3 -1
  13. package/apps/storefront/src/lib/orders/admin/orderSource.ts +342 -0
  14. package/apps/storefront/src/lib/orders/admin/services/orders.ts +1 -1
  15. package/apps/storefront/src/lib/orders/data/orderMapper.ts +10 -4
  16. package/apps/storefront/src/lib/orders/data/ordersStore.ts +48 -0
  17. package/apps/storefront/src/lib/publication/shared/url.ts +19 -0
  18. package/apps/storefront/src/middleware/renderChain.ts +3 -0
  19. package/apps/storefront/src/pages/api/admin/orders/[id].ts +15 -30
  20. package/apps/storefront/src/pages/api/admin/orders.ts +15 -32
  21. package/apps/storefront/src/pages/api/commerce/final-cart-authorization.ts +39 -15
  22. package/apps/storefront/src/pages/tenants/[id]/[...path].ts +12 -2
  23. package/package.json +1 -1
  24. package/packages/cli/src/declared-config.mjs +43 -7
  25. package/packages/public-runtime/package.json +2 -0
  26. package/packages/public-runtime/src/checkout.ts +13 -3
  27. package/packages/public-runtime/src/preview-url.d.mts +10 -0
  28. package/packages/public-runtime/src/preview-url.mjs +54 -0
  29. package/packages/public-runtime/src/sku.ts +19 -0
  30. package/packages/public-runtime/src/tenant.ts +3 -0
@@ -17,10 +17,24 @@
17
17
  *
18
18
  * A cart-origin link click or form submit that beats the drawer is held, not
19
19
  * lost: it is cancelled, the loader is fetched, and the same control is activated
20
- * again once the drawer reports ready (or after a bounded wait), so it opens in
21
- * the drawer exactly as it would have. Holding runs in the window bubble phase,
22
- * after every page handler, so an action another handler already took over (the
23
- * compliance gate) is left alone.
20
+ * again once the drawer client reports `ready.done`, so it opens in the drawer
21
+ * exactly as it would have. Foxy binds its cart-link handler in `FC.client.init`
22
+ * and initialises the side cart on `ready.done`; before that a cart link is a
23
+ * plain navigation to the full cart page. So a held action waits for that signal,
24
+ * not a clock: it follows the link to the cart page only when the drawer cannot
25
+ * come up (the loader or bundle errors, no client appears, or nothing reports
26
+ * within LAZY_CHECKOUT_READY_TIMEOUT_MS). A held action never outlives the page
27
+ * that held it. Holding runs in the window bubble phase, after every page
28
+ * handler, so an action another handler already took over (the compliance gate,
29
+ * or the drawer itself) is left alone.
30
+ *
31
+ * Every cart action that reaches the window still unhandled once the drawer has
32
+ * settled is about to navigate to the cart page. That is recorded: a
33
+ * `[tot-cart]` console warning, a `tot:cart-fallthrough` window event, and
34
+ * `data-cart-fallthrough="<reason>[:<cause>]"` on the root element. The detail
35
+ * carries only the reason, the cause, elapsed ms, whether the action was held,
36
+ * whether `FC.client` existed, Foxy's own full-page and bypass decisions, the
37
+ * document ready state and viewport/screen/body widths — never a URL or session.
24
38
  *
25
39
  * Every cart open waits for this store's Foxy session. The id comes from the
26
40
  * same-origin session endpoint (an HttpOnly cookie the browser cannot read) and
@@ -43,8 +57,12 @@ import { CART_UNAVAILABLE_MESSAGE } from "@/lib/checkout/commerce/cartSession";
43
57
 
44
58
  export const LAZY_CHECKOUT_LOADER_IDLE_MS = 8000;
45
59
 
46
- /** Upper bound on waiting for the drawer to report ready before replaying a held action. */
47
- export const LAZY_CHECKOUT_READY_TIMEOUT_MS = 4000;
60
+ /**
61
+ * Upper bound on waiting for the drawer client to report ready before a held action
62
+ * follows its link to the full cart page. Long enough for a cold drawer bundle on a
63
+ * slow phone; reached only when the drawer is not coming up at all.
64
+ */
65
+ export const LAZY_CHECKOUT_READY_TIMEOUT_MS = 12000;
48
66
 
49
67
  export const LAZY_CHECKOUT_LOADER_EVENTS = ["pointerdown", "keydown", "touchstart"] as const;
50
68
 
@@ -81,7 +99,11 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
81
99
  var referrerPolicy = ${literal(CHECKOUT_NAVIGATION_REFERRER_POLICY)};
82
100
  var storeNotPublic = ${options.storeNotPublic ? "true" : "false"};
83
101
  var events = ${literal(LAZY_CHECKOUT_LOADER_EVENTS)};
102
+ // idle → loading → ready (the drawer client reported ready.done) | failed (it cannot come up).
84
103
  var state = 'idle';
104
+ var failure = null;
105
+ var armedAt = 0;
106
+ var replaying = null;
85
107
  var waiters = [];
86
108
  var sessionId = null;
87
109
  var sessionPromise = null;
@@ -143,7 +165,10 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
143
165
  document.addEventListener("visibilitychange", function () {
144
166
  if (document.visibilityState === "hidden") endCartNavigation();
145
167
  });
146
- window.addEventListener("pagehide", endCartNavigation);
168
+ window.addEventListener("pagehide", function () {
169
+ endCartNavigation();
170
+ dropHeld();
171
+ });
147
172
  window.addEventListener("pageshow", function (event) {
148
173
  if (event.persisted) endCartNavigation();
149
174
  });
@@ -201,20 +226,75 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
201
226
  });
202
227
  return sessionPromise;
203
228
  }
204
- function markSessionUnavailable() {
229
+ function release(held) {
230
+ if (held.el && held.el.removeAttribute) held.el.removeAttribute('aria-busy');
231
+ }
232
+ function dropHeld() {
233
+ var dropped = waiters;
205
234
  waiters = [];
235
+ for (var i = 0; i < dropped.length; i++) release(dropped[i]);
236
+ }
237
+ function markSessionUnavailable() {
238
+ dropHeld();
206
239
  state = 'idle';
207
240
  document.documentElement.setAttribute('data-cart-session', 'unavailable');
208
241
  for (var i = 0; i < events.length; i++) {
209
242
  document.addEventListener(events[i], onInput, { capture: true, passive: true });
210
243
  }
211
244
  }
245
+ function settled() {
246
+ return state === 'ready' || state === 'failed';
247
+ }
248
+ function runHeld() {
249
+ var run = waiters;
250
+ waiters = [];
251
+ for (var i = 0; i < run.length; i++) {
252
+ release(run[i]);
253
+ run[i].fn();
254
+ }
255
+ }
212
256
  function ready() {
213
257
  if (state === 'ready') return;
214
258
  state = 'ready';
215
- var run = waiters;
216
- waiters = [];
217
- for (var i = 0; i < run.length; i++) run[i]();
259
+ failure = null;
260
+ runHeld();
261
+ }
262
+ function fail(cause) {
263
+ if (state !== 'loading') return;
264
+ state = 'failed';
265
+ failure = cause;
266
+ runHeld();
267
+ }
268
+ // A cart action reaching the window unhandled after the drawer settled navigates
269
+ // to the full cart page. Record why, with no URL or session in the record.
270
+ function fallthrough(kind, el) {
271
+ var fc = window.FC || {};
272
+ var fullpage = null;
273
+ try {
274
+ if (fc.sidecart && typeof fc.sidecart.shouldUseFullpageCart === 'function') fullpage = !!fc.sidecart.shouldUseFullpageCart();
275
+ } catch (e) {}
276
+ var body = document.body;
277
+ function bypassed(node) {
278
+ return !!(node && node.classList && (node.classList.contains('fc-sidecart--bypass') || node.hasAttribute('data-fc-sidecart-bypass')));
279
+ }
280
+ var reason = state === 'ready' ? 'drawer-declined' : 'drawer-unavailable';
281
+ var detail = {
282
+ reason: reason,
283
+ cause: failure,
284
+ kind: kind,
285
+ held: replaying === el,
286
+ elapsedMs: armedAt ? Date.now() - armedAt : null,
287
+ fcClient: !!fc.client,
288
+ fullpage: fullpage,
289
+ bypass: bypassed(body) || bypassed(el),
290
+ docState: document.readyState,
291
+ viewport: window.innerWidth,
292
+ screenWidth: window.screen ? window.screen.availWidth : null,
293
+ bodyWidth: body ? body.clientWidth : null
294
+ };
295
+ document.documentElement.setAttribute('data-cart-fallthrough', reason + (failure ? ':' + failure : ''));
296
+ try { console.warn('[tot-cart] cart fell through to the cart page', detail); } catch (e) {}
297
+ try { window.dispatchEvent(new window.CustomEvent('tot:cart-fallthrough', { detail: detail })); } catch (e) {}
218
298
  }
219
299
  function armDrawer(id) {
220
300
  document.documentElement.removeAttribute('data-cart-session');
@@ -225,23 +305,25 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
225
305
  window.FC.settings.cookiepath = cookiePath;
226
306
  window.FC.settings.sitedomain = location.hostname;
227
307
  };
228
- setTimeout(ready, ${LAZY_CHECKOUT_READY_TIMEOUT_MS});
308
+ armedAt = Date.now();
309
+ setTimeout(function () { fail('ready-timeout'); }, ${LAZY_CHECKOUT_READY_TIMEOUT_MS});
229
310
  var s = document.createElement('script');
230
311
  s.src = src;
231
312
  s.async = true;
232
- // The loader appends the drawer bundle and exposes it as fc_script; the drawer is
233
- // ready once that bundle has run and its client reports ready.done.
313
+ // The loader appends the drawer bundle and exposes it as fc_script. The bundle's
314
+ // FC.client.init binds the cart-link handler and ready.done initialises the side
315
+ // cart, so ready.done is the only signal that a cart link will open the drawer.
234
316
  s.addEventListener('load', function () {
235
317
  var bundle = window.fc_script;
236
- if (!bundle) return ready();
318
+ if (!bundle) return fail('no-bundle');
237
319
  bundle.addEventListener('load', function () {
238
320
  var client = window.FC && window.FC.client;
239
- if (!client || typeof client.on !== 'function') return ready();
321
+ if (!client || typeof client.on !== 'function') return fail('no-client');
240
322
  client.on('ready.done', ready);
241
323
  });
242
- bundle.addEventListener('error', ready);
324
+ bundle.addEventListener('error', function () { fail('bundle-error'); });
243
325
  });
244
- s.addEventListener('error', ready);
326
+ s.addEventListener('error', function () { fail('loader-error'); });
245
327
  document.body.appendChild(s);
246
328
  }
247
329
  function start() {
@@ -253,11 +335,16 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
253
335
  armDrawer(id);
254
336
  });
255
337
  }
256
- function whenReady(fn) {
257
- if (state === 'ready') return fn();
258
- waiters.push(fn);
338
+ function whenReady(el, fn) {
339
+ if (settled()) return fn();
340
+ el.setAttribute('aria-busy', 'true');
341
+ waiters.push({ el: el, fn: fn });
259
342
  start();
260
343
  }
344
+ function replay(el, fn) {
345
+ replaying = el;
346
+ try { fn(); } finally { replaying = null; }
347
+ }
261
348
  function onInput() {
262
349
  if (commerceReachable()) start();
263
350
  }
@@ -292,16 +379,19 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
292
379
  var anchor = event.target && event.target.closest ? event.target.closest('a[href]') : null;
293
380
  if (!anchor || anchor.target === '_blank' || !targetsCart(anchor.href)) return;
294
381
  anchor.referrerPolicy = referrerPolicy;
295
- if (state === 'ready') {
382
+ if (settled()) {
296
383
  stampAnchor(anchor);
297
- if (!event.defaultPrevented) revealForNavigation();
384
+ if (!event.defaultPrevented) {
385
+ fallthrough('link', anchor);
386
+ revealForNavigation();
387
+ }
298
388
  return;
299
389
  }
300
390
  if (event.defaultPrevented) return;
301
391
  event.preventDefault();
302
- whenReady(function () {
392
+ whenReady(anchor, function () {
303
393
  stampAnchor(anchor);
304
- anchor.click();
394
+ replay(anchor, function () { anchor.click(); });
305
395
  });
306
396
  });
307
397
 
@@ -352,10 +442,11 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
352
442
  var cartForm = targetsCart(form.getAttribute('action'));
353
443
  if (!managed && !cartForm) return;
354
444
  if (form.dataset.cartSession === '1') {
355
- if (!cartForm || state === 'ready') {
356
- if (!event.defaultPrevented && state === 'ready') {
445
+ if (!cartForm || settled()) {
446
+ if (!event.defaultPrevented && settled()) {
357
447
  form.referrerPolicy = referrerPolicy;
358
448
  if (cartForm) {
449
+ fallthrough('form', form);
359
450
  form.setAttribute("referrerpolicy", referrerPolicy);
360
451
  loosenReferrer();
361
452
  revealForNavigation();
@@ -364,7 +455,7 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
364
455
  return;
365
456
  }
366
457
  event.preventDefault();
367
- whenReady(function () { stampForm(form); resubmit(form); });
458
+ whenReady(form, function () { stampForm(form); replay(form, function () { resubmit(form); }); });
368
459
  return;
369
460
  }
370
461
  event.preventDefault();
@@ -372,7 +463,7 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
372
463
  openSessionThen(form, function () {
373
464
  if (cartForm) {
374
465
  stampForm(form);
375
- whenReady(function () { resubmit(form); });
466
+ whenReady(form, function () { replay(form, function () { resubmit(form); }); });
376
467
  return;
377
468
  }
378
469
  resubmit(form);
@@ -24,7 +24,12 @@
24
24
  * stray quote/angle-bracket can't break out of an attribute or element; this is
25
25
  * defense-in-depth, not a sanitizer for untrusted input.
26
26
  */
27
- import { CHROME_BODY_MARKER, CHROME_HEAD_MARKER } from "./rawMarketingHtml.js";
27
+ import {
28
+ BRAND_H1_CLASS,
29
+ CHROME_BODY_MARKER,
30
+ CHROME_HEAD_MARKER,
31
+ isHomePath,
32
+ } from "./rawMarketingHtml.js";
28
33
  import { isCurrentNavHref } from "../chrome/navCurrent.js";
29
34
  import { ctaIdentityAttr } from "../chrome/ctaIdentity.js";
30
35
  import { CHROME_PLACEMENT } from "../chrome/trackingPlacements.js";
@@ -325,6 +330,14 @@ function renderActionLinks(links: ChromeHeaderActionLink[]): string {
325
330
  }
326
331
 
327
332
  function renderHeader(header: ChromeHeader, variant: HeaderVariant, currentPath?: string): string {
333
+ // One-h1 contract: on the home route (header only, never the footer) the brand
334
+ // renders inside <h1 class="brand-h1">. When the spliced page body supplies its
335
+ // own <h1>, wrapFragmentInChrome / Layout unwrap this one (demoteBrandH1), so
336
+ // every page carries exactly one h1.
337
+ const brandLink = renderBrand(header.brand);
338
+ const brandBlock = isHomePath(currentPath)
339
+ ? `<h1 class="${BRAND_H1_CLASS}" style="margin:0;font:inherit">${brandLink}</h1>`
340
+ : brandLink;
328
341
  const search = header.search ? renderSearchForm(header.search) : "";
329
342
  const memberCue = header.memberCue
330
343
  ? `<a class="member-cue" href="${esc(header.memberCue.href)}" aria-label="${esc(header.memberCue.label)}">${esc(header.memberCue.label)}</a>`
@@ -424,7 +437,7 @@ function renderHeader(header: ChromeHeader, variant: HeaderVariant, currentPath?
424
437
  ` <div class="container header-row">\n` +
425
438
  (menuLeft && hamburger ? ` ${hamburger}\n` : "") +
426
439
  (compact ? ` ${primaryNav}\n` : "") +
427
- ` ${renderBrand(header.brand)}\n` +
440
+ ` ${brandBlock}\n` +
428
441
  (!minimal && !compact && search ? ` ${search}\n` : "") +
429
442
  ` <div class="header-actions">${memberCue}${ctas}${compactSearch}${actionLinks}${account}${cart}${menuLeft ? "" : hamburger}</div>\n` +
430
443
  ` </div>\n` +
@@ -56,13 +56,39 @@ export function isFullDocument(html: string): boolean {
56
56
  return /^\s*<!doctype/i.test(html) || /<html[\s>]/i.test(html);
57
57
  }
58
58
 
59
+ /** Class of the home-route header brand heading (`<h1 class="brand-h1">`). */
60
+ export const BRAND_H1_CLASS = "brand-h1";
61
+
62
+ /** True for the home route ("/" with optional query/hash); false when no path is known. */
63
+ export function isHomePath(path: string | undefined): boolean {
64
+ return path !== undefined && (path.split(/[?#]/)[0] ?? "") === "/";
65
+ }
66
+
67
+ /** True when page content already supplies an `<h1>`. */
68
+ export function hasContentH1(html: string): boolean {
69
+ return /<h1[\s>]/i.test(html);
70
+ }
71
+
72
+ /**
73
+ * Unwrap the home brand heading (keeping the brand link) so a page whose own
74
+ * content supplies an `<h1>` ends up with exactly one h1.
75
+ */
76
+ export function demoteBrandH1(html: string): string {
77
+ return html.replace(
78
+ new RegExp(`<h1 class="${BRAND_H1_CLASS}"[^>]*>([\\s\\S]*?)</h1>`),
79
+ "$1",
80
+ );
81
+ }
82
+
59
83
  /**
60
84
  * Splice a body-only fragment into a tenant chrome wrapper at CHROME_BODY_MARKER.
85
+ * When the fragment carries its own `<h1>`, the wrapper's brand h1 is unwrapped.
61
86
  * If the wrapper has no marker (misconfigured), the fragment is returned unwrapped
62
87
  * so a page never disappears.
63
88
  */
64
89
  export function wrapFragmentInChrome(fragment: string, chromeWrapper: string): string {
65
90
  if (!chromeWrapper.includes(CHROME_BODY_MARKER)) return fragment;
91
+ if (hasContentH1(fragment)) chromeWrapper = demoteBrandH1(chromeWrapper);
66
92
  // A fragment may lead with a <head>…</head> of page-specific SEO tags. Split it
67
93
  // out so the shared wrapper keeps per-page title/meta/canonical/JSON-LD.
68
94
  let head = "";
@@ -15,12 +15,12 @@ import {
15
15
  type PaymentState,
16
16
  type StockState,
17
17
  } from "@tot/public-runtime";
18
+ import { isStoredSku } from "@tot/public-runtime/sku";
18
19
 
19
20
  export const RESERVATION_AUTHORITY_HEADER = "x-tot-reservation-authority";
20
21
  export const RESERVATION_AUTHORITY_NONE = "none";
21
22
  export const VERIFICATION_PROVIDER_OWNER = "tot-9577-tot-verification-provider";
22
23
 
23
- const SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$/;
24
24
  const ID_RE = /^[a-z0-9][a-z0-9-]{0,80}$/;
25
25
  const TENANT_RE = /^[a-z0-9][a-z0-9.-]{0,253}$/;
26
26
  const LOCATION_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,80}$/;
@@ -153,7 +153,7 @@ export function readMirrorAuthority(value: unknown): MirrorStockAuthority | null
153
153
  const record = value as Record<string, unknown>;
154
154
  if (record.provider !== "mirror" || record.readable !== true) return null;
155
155
  if (typeof record.locationId !== "string" || !LOCATION_RE.test(record.locationId)) return null;
156
- if (typeof record.sku !== "string" || !SKU_RE.test(record.sku)) return null;
156
+ if (typeof record.sku !== "string" || !isStoredSku(record.sku)) return null;
157
157
  if (typeof record.onHand !== "number" || !Number.isSafeInteger(record.onHand) || record.onHand < 0) return null;
158
158
  if (typeof record.asOf !== "string" || record.asOf.trim() === "" || record.asOf.length > 40) return null;
159
159
  return {
@@ -252,7 +252,7 @@ function reserveShape(input: ReserveInput): ReserveResult | null {
252
252
  if (!Number.isSafeInteger(input.amountMinor) || input.amountMinor < 0) return { ok: false, finding: "malformed-request" };
253
253
  if (input.holds.length < 1) return { ok: false, finding: "malformed-request" };
254
254
  for (const hold of input.holds) {
255
- if (!SKU_RE.test(hold.sku) || !LOCATION_RE.test(hold.locationId)) return { ok: false, finding: "malformed-request" };
255
+ if (!isStoredSku(hold.sku) || !LOCATION_RE.test(hold.locationId)) return { ok: false, finding: "malformed-request" };
256
256
  if (!Number.isSafeInteger(hold.quantity) || hold.quantity < 1) return { ok: false, finding: "malformed-request" };
257
257
  if (!Number.isSafeInteger(hold.onHand) || hold.onHand < 0) return { ok: false, finding: "malformed-request" };
258
258
  }
@@ -1,6 +1,7 @@
1
1
  /** Tenant-scoped inventory authority. Catalog availability never supplies a balance. */
2
2
  import type { Queryable } from "../catalog/d1/catalog.js";
3
3
  import type { CommerceStage } from "@tot/public-runtime";
4
+ import { isStoredSku } from "@tot/public-runtime/sku";
4
5
  import type { MirrorStockAuthority } from "./allocations.js";
5
6
 
6
7
  const ID = /^[A-Za-z0-9._:-]{1,128}$/;
@@ -25,9 +26,10 @@ export interface MirrorMovement {
25
26
  }
26
27
  export async function appendMirrorMovement(db: Queryable, input: MirrorMovement): Promise<void> {
27
28
  if (
28
- ![input.tenantId, input.source, input.sourceEventId, input.sku, input.locationId, input.actorId].every((v) =>
29
+ ![input.tenantId, input.source, input.sourceEventId, input.locationId, input.actorId].every((v) =>
29
30
  ID.test(v),
30
31
  ) ||
32
+ !isStoredSku(input.sku) ||
31
33
  !Number.isSafeInteger(input.quantityDelta) ||
32
34
  !Number.isFinite(Date.parse(input.asOf)) ||
33
35
  !["preview", "publication"].includes(input.stage)