@tokenoftrust/storefront-runner 6.3.2 → 6.3.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.
@@ -1,14 +1,15 @@
1
1
  /**
2
2
  * Recently viewed (Shopify-style discovery rail). A small client island that
3
3
  * records the current product to localStorage on a PDP and renders the rest as
4
- * a quiet strip. No server round-trip — snapshots are self-contained, so it
5
- * works against fixtures or live ToT identically. Renders nothing until it has
6
- * something to show (no layout shift, no empty state noise).
4
+ * a quiet strip. History is namespaced by the trusted tenant prop. A missing
5
+ * tenant renders nothing and does not touch storage. Entries render only when
6
+ * the current tenant's catalog index lists their handle.
7
7
  */
8
8
  import { useEffect, useState } from "preact/hooks";
9
+ import { loadCatalogIndex } from "@/lib/catalog/shared/catalogQueryView";
9
10
  import { formatPrice } from "@/lib/content/shared/format";
10
11
  import { placeholderDataUri, isUsableImageUrl } from "@/lib/content/shared/placeholder";
11
- import { withClientBase } from "@/lib/publication/shared/basePath";
12
+ import { clientBasePath, withClientBase } from "@/lib/publication/shared/basePath";
12
13
 
13
14
  export interface RVItem {
14
15
  handle: string;
@@ -22,59 +23,75 @@ export interface RVItem {
22
23
  }
23
24
 
24
25
  interface Props {
26
+ /** Trusted renderer tenant. Missing context renders nothing and touches no storage. */
27
+ tenant?: string;
25
28
  /** When set (on a PDP), this product is recorded and excluded from the rail. */
26
29
  current?: RVItem;
27
30
  locale: string;
28
31
  title?: string;
29
32
  max?: number;
33
+ /** Style-guide samples. Rendered only when `tenant` is absent, and never stored. */
34
+ preview?: RVItem[];
30
35
  }
31
36
 
32
- const KEY = "tot:recently-viewed";
37
+ const LEGACY_KEY = "tot:recently-viewed";
33
38
  const CAP = 12;
34
39
 
35
- function read(): RVItem[] {
40
+ function tenantKey(tenant: string): string {
41
+ return `${LEGACY_KEY}:${tenant}`;
42
+ }
43
+
44
+ function isItem(value: unknown): value is RVItem {
45
+ if (!value || typeof value !== "object") return false;
46
+ const item = value as Partial<RVItem>;
47
+ return typeof item.handle === "string" && item.handle.length > 0
48
+ && typeof item.title === "string"
49
+ && typeof item.price === "number" && Number.isFinite(item.price)
50
+ && typeof item.currency === "string" && item.currency.length > 0
51
+ && (item.vendor === undefined || typeof item.vendor === "string")
52
+ && (item.img === undefined || typeof item.img === "string")
53
+ && (item.rehosted === undefined || typeof item.rehosted === "boolean")
54
+ && typeof item.seed === "string";
55
+ }
56
+
57
+ function readStored(tenant: string): RVItem[] {
36
58
  try {
37
- const raw = localStorage.getItem(KEY);
59
+ const raw = localStorage.getItem(tenantKey(tenant));
38
60
  if (!raw) return [];
39
61
  const arr = JSON.parse(raw);
40
- return Array.isArray(arr) ? (arr as RVItem[]) : [];
62
+ return Array.isArray(arr) ? arr.filter(isItem) : [];
41
63
  } catch (error) {
42
- // Corrupt JSON or blocked storage is an empty rail. Any other failure is a bug.
43
64
  if (error instanceof SyntaxError) return [];
44
65
  if (typeof DOMException !== "undefined" && error instanceof DOMException) return [];
45
66
  throw error;
46
67
  }
47
68
  }
48
69
 
49
- export default function RecentlyViewed({
50
- current,
51
- locale,
52
- title = "Recently viewed",
53
- max = 5,
54
- }: Props) {
55
- const [items, setItems] = useState<RVItem[]>([]);
56
-
57
- useEffect(() => {
58
- let list = read();
59
- if (current) {
60
- list = [current, ...list.filter((i) => i.handle !== current.handle)].slice(
61
- 0,
62
- CAP,
63
- );
64
- try {
65
- localStorage.setItem(KEY, JSON.stringify(list));
66
- } catch {
67
- /* storage full / disabled — degrade silently */
68
- }
69
- }
70
- // Exclude the current product from what we *show*.
71
- setItems(
72
- list.filter((i) => i.handle !== current?.handle).slice(0, max),
73
- );
74
- }, [current?.handle]);
70
+ function writeStored(tenant: string, list: RVItem[]): void {
71
+ try {
72
+ localStorage.setItem(tenantKey(tenant), JSON.stringify(list));
73
+ } catch {
74
+ /* storage full / disabled — degrade silently */
75
+ }
76
+ }
75
77
 
76
- if (items.length === 0) return null;
78
+ function discardLegacy(): void {
79
+ try {
80
+ localStorage.removeItem(LEGACY_KEY);
81
+ } catch {
82
+ /* blocked storage — the legacy key is still never read */
83
+ }
84
+ }
77
85
 
86
+ function Rail({
87
+ items,
88
+ locale,
89
+ title,
90
+ }: {
91
+ items: RVItem[];
92
+ locale: string;
93
+ title: string;
94
+ }) {
78
95
  return (
79
96
  <section
80
97
  class="container-prose border-t border-[var(--color-border)] py-12"
@@ -123,3 +140,60 @@ export default function RecentlyViewed({
123
140
  </section>
124
141
  );
125
142
  }
143
+
144
+ export default function RecentlyViewed({
145
+ tenant,
146
+ current,
147
+ locale,
148
+ title = "Recently viewed",
149
+ max = 5,
150
+ preview,
151
+ }: Props) {
152
+ const scope = tenant && tenant.length > 0 ? tenant : "";
153
+ const [shown, setShown] = useState<{ tenant: string; items: RVItem[] } | null>(null);
154
+ const visible = shown && shown.tenant === scope ? shown.items : [];
155
+
156
+ useEffect(() => {
157
+ if (!scope) return;
158
+ let cancelled = false;
159
+ const list = current
160
+ ? [current, ...readStored(scope).filter((item) => item.handle !== current.handle)].slice(0, CAP)
161
+ : readStored(scope);
162
+ if (current) writeStored(scope, list);
163
+ discardLegacy();
164
+ const candidates = list.filter((item) => item.handle !== current?.handle);
165
+ loadCatalogIndex(clientBasePath()).then((index) => {
166
+ if (cancelled) return;
167
+ if (!Array.isArray(index?.products)) throw new Error("catalog index: products");
168
+ const handles = new Set(index.products.flatMap((product) =>
169
+ typeof product?.handle === "string" && product.handle ? [product.handle] : []));
170
+ setShown({
171
+ tenant: scope,
172
+ items: candidates.filter((item) => handles.has(item.handle)).slice(0, max),
173
+ });
174
+ }).catch(() => {
175
+ if (!cancelled) setShown({ tenant: scope, items: [] });
176
+ });
177
+ return () => {
178
+ cancelled = true;
179
+ };
180
+ }, [
181
+ scope,
182
+ max,
183
+ current?.handle,
184
+ current?.title,
185
+ current?.price,
186
+ current?.currency,
187
+ current?.vendor,
188
+ current?.img,
189
+ current?.rehosted,
190
+ current?.seed,
191
+ ]);
192
+
193
+ if (!scope) {
194
+ const samples = (preview ?? []).filter(isItem).slice(0, max);
195
+ return samples.length > 0 ? <Rail items={samples} locale={locale} title={title} /> : null;
196
+ }
197
+ if (visible.length === 0) return null;
198
+ return <Rail items={visible} locale={locale} title={title} />;
199
+ }
@@ -43,18 +43,22 @@ export function searchFromIndex(index: CatalogIndex, q: string, limit = SEARCH_R
43
43
  .filter(Boolean);
44
44
  }
45
45
 
46
- let indexRequest: Promise<CatalogIndex> | null = null;
46
+ const indexRequests = new Map<string, Promise<CatalogIndex>>();
47
47
 
48
- /** Fetch the tenant's catalog index once per page (`base` is the tenant's client base path). */
48
+ /** Fetch the tenant's catalog index once per base path. A second tenant does not reuse the first. */
49
49
  export function loadCatalogIndex(base: string): Promise<CatalogIndex> {
50
- indexRequest ??= fetch(`${base.replace(/\/+$/, "")}${CATALOG_INDEX_PATH}`, {
50
+ const key = base.replace(/\/+$/, "");
51
+ const cached = indexRequests.get(key);
52
+ if (cached) return cached;
53
+ const pending = fetch(`${key}${CATALOG_INDEX_PATH}`, {
51
54
  headers: { Accept: "application/json" },
52
55
  }).then((res) => {
53
56
  if (!res.ok) throw new Error(`catalog index: HTTP ${res.status}`);
54
57
  return res.json() as Promise<CatalogIndex>;
55
58
  });
56
- indexRequest.catch(() => {
57
- indexRequest = null;
59
+ pending.catch(() => {
60
+ if (indexRequests.get(key) === pending) indexRequests.delete(key);
58
61
  });
59
- return indexRequest;
62
+ indexRequests.set(key, pending);
63
+ return pending;
60
64
  }
@@ -0,0 +1,52 @@
1
+ /** One shopper-readable sentence for every final-cart refusal finding. Closed text only; never cart or tenant data. */
2
+ export const GENERIC_REFUSAL_SENTENCE = "We couldn't confirm your order right now. Please review your cart and try again, or contact the store if it keeps happening.";
3
+
4
+ const SENTENCES: Readonly<Record<string, string>> = {
5
+ "tenant-not-admitted": "This store isn't set up to accept orders yet. Please contact the store for help.",
6
+ "tenant-unregistered": "This store isn't set up to accept orders yet. Please contact the store for help.",
7
+ "shared-store-unavailable": "Checkout is temporarily unavailable. Please try again later.",
8
+ "checkout-unconfigured": "Checkout isn't set up for this store yet. Please contact the store for help.",
9
+ "tenant-own-store": "This store checks out through its own payment page, so this cart can't be confirmed here.",
10
+ "caller-unconfigured": "Checkout is temporarily unavailable. Please try again later.",
11
+ "store-unconfigured": "Checkout is temporarily unavailable. Please try again later.",
12
+ "config-unreadable": "Checkout is temporarily unavailable. Please try again later.",
13
+ "catalog-unavailable": "We couldn't load the product catalog. Please try again in a moment.",
14
+ "checkout-requirements-unreadable": "Checkout is temporarily unavailable. Please try again later.",
15
+ "authority-error": "Something went wrong confirming your order. Please try again.",
16
+ "authority-unavailable": "We couldn't check stock for your cart right now. Please try again in a moment.",
17
+ "malformed-request": "We couldn't read your cart. Please return to the cart and try again.",
18
+ "catalog-cart-mismatch": "Something in your cart is no longer available as listed. Please review your cart and try again.",
19
+ "catalog-not-authority": "Something in your cart is no longer available as listed. Please review your cart and try again.",
20
+ "shipping-unavailable": "We couldn't work out shipping for your cart. Please review your cart and try again.",
21
+ "shipping-changed": "Shipping for your cart has changed. Please review your cart and try again.",
22
+ "stock-unavailable": "One or more items in your cart are out of stock.",
23
+ "stock-unknown": "We couldn't confirm stock for your cart. Please try again in a moment.",
24
+ "provider-cart-conflict": "Your cart changed in another window. Please reload the cart and try again.",
25
+ "provider-cart-changed": "Your cart changed since it was checked. Please review your cart and try again.",
26
+ "admission-consumed": "This cart has already been used for an order. Please start a new cart.",
27
+ "admission-expired": "Your cart check expired. Please return to the cart and try again.",
28
+ "evidence-expired": "Your cart check expired. Please return to the cart and try again.",
29
+ "tenant-changed": "Your cart changed since it was checked. Please review your cart and try again.",
30
+ "stage-changed": "Your cart changed since it was checked. Please review your cart and try again.",
31
+ "currency-changed": "The currency for your cart changed. Please review your cart and try again.",
32
+ "destination-changed": "Your shipping destination changed. Please review your cart and try again.",
33
+ "product-changed": "Your cart changed since it was checked. Please review your cart and try again.",
34
+ "price-changed": "A price in your cart changed. Please review your cart and try again.",
35
+ "quantity-changed": "A quantity in your cart changed. Please review your cart and try again.",
36
+ "evidence-window-changed": "Your cart check expired. Please return to the cart and try again.",
37
+ "cart-unbound": "Your cart changed since it was checked. Please review your cart and try again.",
38
+ "verification-seam-absent": "We couldn't verify your age and eligibility right now. Please try again in a moment.",
39
+ "verification-provider-unconfigured": "Age verification isn't available for this store right now. Please contact the store for help.",
40
+ "verification-refused": "Your order can't be completed because it didn't pass the store's eligibility checks.",
41
+ "payment-reconcile": "We couldn't confirm payment for your order. Please try again.",
42
+ "payment-refused": "Payment for your order couldn't be confirmed. Please try again.",
43
+ "fulfillment-refused": "This order can't be fulfilled as requested. Please review your cart and try again.",
44
+ };
45
+
46
+ /** The sentence for a finding; unknown findings, including a bare code, get the generic sentence. */
47
+ export function refusalSentence(finding: string): string {
48
+ if (Object.hasOwn(SENTENCES, finding)) return SENTENCES[finding]!;
49
+ if (finding.startsWith("verification-refused:")) return SENTENCES["verification-refused"]!;
50
+ if (finding.startsWith("evidence-")) return "We couldn't confirm the checks for your order right now. Please try again in a moment.";
51
+ return GENERIC_REFUSAL_SENTENCE;
52
+ }
@@ -23,6 +23,7 @@ import { DeclaredTenantConfigUnreadError, withDeclaredTenantConfig } from "@/con
23
23
  import { resolveStoreCurrency } from "@tot/public-runtime/declared-tenant-config";
24
24
  import { createContentProvider } from "@/lib/content/storyblok/provider";
25
25
  import { createToTClient } from "@/lib/catalog/tot/ToTClient";
26
+ import { refusalSentence } from "@/lib/commerce/refusalSentence";
26
27
  export const prerender = false;
27
28
  const HEX = /^[a-f0-9]{64}$/;
28
29
  /** The shopper's verification correlation handle, as the signed `verification` cart option carries it. */
@@ -30,7 +31,7 @@ const VERIFICATION_REF = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,199}$/;
30
31
  function json(body: unknown, status = 200): Response {
31
32
  return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" } });
32
33
  }
33
- function deny(finding: string, status = 409, owner?: string): Response { return json({ ok: false, finding, ...(owner ? { owner } : {}) }, status); }
34
+ function deny(finding: string, status = 409, owner?: string): Response { return json({ ok: false, finding, message: refusalSentence(finding), ...(owner ? { owner } : {}) }, status); }
34
35
  /**
35
36
  * Which check refused, and on which cart line (its index in the presented snapshot). Logged on
36
37
  * the decision line only: closed check names and an index, never a SKU, price or cart value.
@@ -329,7 +330,7 @@ async function authorize(context: APIContext): Promise<Response> {
329
330
  trustedIssuers: issuers, expectedAudience: audience, activeKids, stockTracked, authorities });
330
331
  if (!decision.ok) {
331
332
  if (decision.finding.startsWith("verification-refused") && (seamView.flavors || seamView.lines)) {
332
- return json({ ok: false, finding: decision.finding,
333
+ return json({ ok: false, finding: decision.finding, message: refusalSentence(decision.finding),
333
334
  ...(decision.finding === "verification-refused:flavored_prohibited" && seamView.flavors ? { flavors: seamView.flavors } : {}),
334
335
  ...(seamView.lines ? { lines: seamView.lines } : {}) }, 409);
335
336
  }
@@ -626,6 +626,7 @@ const structuredData = {
626
626
  {/* Recently viewed (client island; records this product, shows the rest). */}
627
627
  <RecentlyViewed
628
628
  client:idle
629
+ tenant={tenant.tenant_id}
629
630
  locale={tenant.locale}
630
631
  current={{
631
632
  handle: product.handle,
@@ -1443,9 +1443,9 @@ const componentVarsInline = [
1443
1443
  <MobileNav client:idle nav={sampleMobileNav} announcement="Free US shipping on orders over $100" />
1444
1444
  </div>
1445
1445
 
1446
- <p class="sg-cap" style="margin:22px 0 8px">RecentlyViewed — localStorage discovery rail, seeded here for preview (renders empty on a real first visit until a product is viewed)</p>
1447
- <script is:inline nonce={cspNonce} set:html={`localStorage.setItem("tot:recently-viewed", ${JSON.stringify(JSON.stringify(sampleRecentlyViewed))}); localStorage.setItem("tot:wishlist", ${JSON.stringify(JSON.stringify(sampleRecentlyViewed.slice(0, 2)))});`} />
1448
- <RecentlyViewed client:idle locale="en-US" />
1446
+ <p class="sg-cap" style="margin:22px 0 8px">RecentlyViewed — localStorage discovery rail. These samples are preview-only and are not stored.</p>
1447
+ <script is:inline nonce={cspNonce} set:html={`localStorage.setItem("tot:wishlist", ${JSON.stringify(JSON.stringify(sampleRecentlyViewed.slice(0, 2)))});`} />
1448
+ <RecentlyViewed client:idle locale="en-US" preview={sampleRecentlyViewed} />
1449
1449
 
1450
1450
  <p class="sg-cap" style="margin:22px 0 8px">SavedList — the <code>/saved</code> wishlist, seeded here for preview (empty state shown until an item is saved)</p>
1451
1451
  <SavedList client:only="preact" locale="en-US" />
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "6.3.2",
3
+ "version": "6.3.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",