@tokenoftrust/storefront-runner 6.3.3 → 6.3.5

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
  }
@@ -2,7 +2,7 @@
2
2
  * Scope validation — the compliance-floor gate for Storefront Private Apps.
3
3
  * See `docs/private-apps/feature-brief.md` §"The
4
4
  * compliance floor" and the machine-readable contract:
5
- * - `docs/private-apps/contract/scopes.json` — the 8-scope V1 catalog.
5
+ * - `docs/private-apps/contract/scopes.json` — the 10-scope V1 catalog.
6
6
  * - `docs/private-apps/contract/forbidden-scopes.json` — the 8 ids that map
7
7
  * to floor clauses (`checkout:mutate`, `tax:mutate`, …) and must NEVER be
8
8
  * grantable.
@@ -28,6 +28,8 @@ export const V1_SCOPES = [
28
28
  "reports:read",
29
29
  "attribution:write",
30
30
  "widgets:launch",
31
+ "fulfillment:process",
32
+ "orders:read:recipient",
31
33
  ] as const;
32
34
  export type V1Scope = (typeof V1_SCOPES)[number];
33
35
 
@@ -50,7 +50,9 @@ export type WebhookTopic =
50
50
  | "order.created"
51
51
  | "order.fulfilled"
52
52
  | "customer.marketing_consent.updated"
53
- | "attribution.finalized";
53
+ | "attribution.finalized"
54
+ | "fulfillment.assignment.created"
55
+ | "fulfillment.assignment.updated";
54
56
 
55
57
  /** Mirrors `Delivery.state` in `docs/private-apps/contract/openapi.yaml` EXACTLY. */
56
58
  export type DeliveryState = "pending" | "retrying" | "delivered" | "dead-lettered";
@@ -29,6 +29,8 @@ export const TOPIC_REQUIRED_SCOPE: Record<WebhookTopic, string | null> = {
29
29
  "order.fulfilled": "orders:webhook",
30
30
  "customer.marketing_consent.updated": "customers:read:minimal",
31
31
  "attribution.finalized": "attribution:write",
32
+ "fulfillment.assignment.created": "fulfillment:process",
33
+ "fulfillment.assignment.updated": "fulfillment:process",
32
34
  };
33
35
 
34
36
  /** `type`'s reverse-DNS prefix (asyncapi.yaml `CloudEventEnvelope.type`). */
@@ -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.3",
3
+ "version": "6.3.5",
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",