@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.
- package/apps/storefront/src/components/catalog/storefront/islands/RecentlyViewed.tsx +110 -36
- package/apps/storefront/src/lib/catalog/shared/catalogQueryView.ts +10 -6
- package/apps/storefront/src/lib/integrations/apps/scopes.ts +3 -1
- package/apps/storefront/src/lib/integrations/webhooks/deliveryStore.ts +3 -1
- package/apps/storefront/src/lib/integrations/webhooks/events.ts +2 -0
- package/apps/storefront/src/pages/products/[handle].astro +1 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +3 -3
- package/package.json +1 -1
|
@@ -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.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
37
|
+
const LEGACY_KEY = "tot:recently-viewed";
|
|
33
38
|
const CAP = 12;
|
|
34
39
|
|
|
35
|
-
function
|
|
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(
|
|
59
|
+
const raw = localStorage.getItem(tenantKey(tenant));
|
|
38
60
|
if (!raw) return [];
|
|
39
61
|
const arr = JSON.parse(raw);
|
|
40
|
-
return Array.isArray(arr) ? (
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
+
const indexRequests = new Map<string, Promise<CatalogIndex>>();
|
|
47
47
|
|
|
48
|
-
/** Fetch the tenant's catalog index once per
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
59
|
+
pending.catch(() => {
|
|
60
|
+
if (indexRequests.get(key) === pending) indexRequests.delete(key);
|
|
58
61
|
});
|
|
59
|
-
|
|
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
|
|
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`). */
|
|
@@ -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
|
|
1447
|
-
<script is:inline nonce={cspNonce} set:html={`localStorage.setItem("tot:
|
|
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
|
+
"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",
|