@tokenoftrust/storefront-runner 2.4.11 → 2.4.12

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.
@@ -20,7 +20,9 @@ export const CATALOG_MODEL = {
20
20
  ProductStatus: { alias: '"active" | "draft" | "archived"' },
21
21
  ProductVisibility: { alias: '"public" | "unlisted" | "hidden"' },
22
22
  Currency: { alias: "string" },
23
- SourcePlatform: { alias: '"shopify" | "bigcommerce" | "directory" | "csv" | "manual" | "synthesized"' },
23
+ SourcePlatform: {
24
+ alias: '"shopify" | "bigcommerce" | "directory" | "csv" | "manual" | "synthesized" | "tot-catalog"',
25
+ },
24
26
  SourceRef: {
25
27
  fields: { platform: "SourcePlatform", source_id: "string", "source_handle?": "string" },
26
28
  },
@@ -70,6 +72,28 @@ export const CATALOG_MODEL = {
70
72
  InventoryState: {
71
73
  fields: { "quantity?": "number", "policy?": "string", available: "boolean" },
72
74
  },
75
+ CatalogFactState: { alias: '"known" | "unknown" | "invalid" | "conflict" | "not_applicable"' },
76
+ CatalogFact: {
77
+ fields: {
78
+ key: "string",
79
+ label: "string",
80
+ state: "CatalogFactState",
81
+ "value?": "string | number | boolean",
82
+ "unit?": "string",
83
+ },
84
+ },
85
+ CatalogLink: {
86
+ fields: {
87
+ item_id: "string",
88
+ accepted_revision_id: "string",
89
+ "group_id?": "string",
90
+ "market?": "string",
91
+ "locale?": "string",
92
+ accepted_at: "string",
93
+ facts: "CatalogFact[]",
94
+ },
95
+ },
96
+ CatalogOverrideField: { alias: '"title" | "description_html" | "images" | "vendor" | "product_type"' },
73
97
  CatalogVariant: {
74
98
  fields: {
75
99
  tenant_id: "string",
@@ -93,6 +117,7 @@ export const CATALOG_MODEL = {
93
117
  "depth?": "number",
94
118
  position: "number",
95
119
  "image_id?": "string",
120
+ "catalog_link?": "CatalogLink",
96
121
  },
97
122
  },
98
123
  CatalogProduct: {
@@ -121,6 +146,7 @@ export const CATALOG_MODEL = {
121
146
  updated_at: "string",
122
147
  migrated_at: "string",
123
148
  source_checksum: "string",
149
+ "catalog_overrides?": "CatalogOverrideField[]",
124
150
  },
125
151
  },
126
152
  CatalogCollection: {
@@ -35,7 +35,8 @@
35
35
  "jose": "^6.2.3",
36
36
  "parse5": "^7.2.1",
37
37
  "preact": "^10.29.2",
38
- "tailwindcss": "^4.3.1"
38
+ "tailwindcss": "^4.3.1",
39
+ "zod": "^4.4.3"
39
40
  },
40
41
  "devDependencies": {
41
42
  "@astrojs/check": "^0.9.4",
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Admin Products tab client: filtering, product selection, and the catalog-update review — check a
3
+ * listed product's packages for newer catalog revisions, show what accepting changes, and accept
4
+ * explicitly as a draft change. Catalog text is set as text, never parsed as HTML.
5
+ */
6
+
7
+ interface UpdatePlanProduct {
8
+ handle: string;
9
+ baseRevision: string;
10
+ fieldChanges: Array<{ field: string; applied: boolean }>;
11
+ }
12
+
13
+ interface UpdatesResponse {
14
+ ok: boolean;
15
+ message?: string;
16
+ updates: Array<{ title: string; acceptedRevisionId: string; latestRevisionId: string }>;
17
+ unchecked?: string[];
18
+ request?: Record<string, unknown>;
19
+ plan: { products: UpdatePlanProduct[] } | null;
20
+ planFailure?: { message?: string; pinFailures?: Array<{ message: string }> };
21
+ }
22
+
23
+ const FIELD_LABEL: Record<string, string> = {
24
+ title: "Title",
25
+ description_html: "Description",
26
+ images: "Photos",
27
+ vendor: "Brand",
28
+ product_type: "Product type",
29
+ };
30
+
31
+ function el(tag: string, attrs: Record<string, string> = {}, ...children: Array<Node | string>): HTMLElement {
32
+ const node = document.createElement(tag);
33
+ for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);
34
+ node.append(...children);
35
+ return node;
36
+ }
37
+
38
+ export function initProductsTab(root: HTMLElement): void {
39
+ const api = root.dataset.productsApi ?? "";
40
+ const statusSelect = root.querySelector<HTMLSelectElement>("[data-products-status-select]");
41
+ const listingSelect = root.querySelector<HTMLSelectElement>("[data-products-listing-select]");
42
+ const searchInput = root.querySelector<HTMLInputElement>("[data-products-search]");
43
+ const rows = [...root.querySelectorAll<HTMLElement>("[data-products-row]")];
44
+ const emptyState = root.querySelector<HTMLElement>("[data-products-empty]");
45
+ const records = [...root.querySelectorAll<HTMLElement>("[data-products-detail-record]")];
46
+
47
+ const applyFilters = () => {
48
+ const status = statusSelect?.value ?? "";
49
+ const listing = listingSelect?.value ?? "";
50
+ const term = (searchInput?.value ?? "").trim().toLowerCase();
51
+ let visible = 0;
52
+ for (const row of rows) {
53
+ const show =
54
+ (!status || row.dataset.status === status) &&
55
+ (!listing || row.dataset.listing === listing) &&
56
+ (!term || (row.dataset.searchHaystack ?? "").includes(term));
57
+ row.hidden = !show;
58
+ if (show) visible += 1;
59
+ }
60
+ if (emptyState) emptyState.hidden = visible > 0;
61
+ };
62
+
63
+ const select = (handle: string) => {
64
+ for (const row of rows) row.classList.toggle("selected", row.dataset.productId === handle);
65
+ for (const r of records) r.hidden = r.dataset.productsDetailRecord !== handle;
66
+ };
67
+
68
+ statusSelect?.addEventListener("change", applyFilters);
69
+ listingSelect?.addEventListener("change", applyFilters);
70
+ searchInput?.addEventListener("input", applyFilters);
71
+ for (const row of rows) row.addEventListener("click", () => row.dataset.productId && select(row.dataset.productId));
72
+
73
+ async function check(handle: string, box: HTMLElement, button: HTMLButtonElement): Promise<void> {
74
+ button.disabled = true;
75
+ box.replaceChildren(el("p", { class: "subtle" }, "Checking the ToT catalog…"));
76
+ try {
77
+ const res = await fetch(`${api}?updates=${encodeURIComponent(handle)}`, { headers: { accept: "application/json" } });
78
+ const body = (await res.json()) as UpdatesResponse;
79
+ if (!res.ok || !body.ok) {
80
+ box.replaceChildren(el("p", { class: "notice-error", role: "alert" }, body.message ?? `Catalog check failed (HTTP ${res.status}).`));
81
+ return;
82
+ }
83
+ const nodes: Node[] = [];
84
+ if (body.unchecked?.length) {
85
+ nodes.push(el("p", { class: "subtle" }, `${body.unchecked.length} listed package(s) could not be checked against the catalog's latest revision.`));
86
+ }
87
+ if (body.updates.length === 0) {
88
+ nodes.push(el("p", {}, "Every listed package is at the catalog's latest revision."));
89
+ box.replaceChildren(...nodes);
90
+ return;
91
+ }
92
+ nodes.push(
93
+ el("strong", {}, `${body.updates.length} package(s) have a newer catalog revision`),
94
+ el(
95
+ "ul",
96
+ { class: "update-list" },
97
+ ...body.updates.map((u) => el("li", {}, `${u.title}: revision ${u.acceptedRevisionId.slice(0, 8)} → ${u.latestRevisionId.slice(0, 8)}`)),
98
+ ),
99
+ );
100
+ const plan = body.plan;
101
+ if (!plan || !body.request) {
102
+ const reason = body.planFailure?.message ?? body.planFailure?.pinFailures?.map((f) => f.message).join("; ");
103
+ nodes.push(el("p", { class: "notice-error", role: "alert" }, `The update cannot be prepared: ${reason || "unknown reason"}.`));
104
+ box.replaceChildren(...nodes);
105
+ return;
106
+ }
107
+ const product = plan.products[0];
108
+ if (product?.fieldChanges.length) {
109
+ nodes.push(
110
+ el("p", { class: "eyebrow" }, "Product fields"),
111
+ el(
112
+ "ul",
113
+ { class: "update-list" },
114
+ ...product.fieldChanges.map((c) => el("li", {}, `${FIELD_LABEL[c.field] ?? c.field}: ${c.applied ? "takes the catalog's new value" : "keeps your value (catalog value differs)"}`)),
115
+ ),
116
+ );
117
+ }
118
+ nodes.push(el("p", { class: "subtle" }, "Your SKUs, prices and the fields you set are kept. Accepting opens a draft change; nothing is published."));
119
+ const accept = el("button", { type: "button", class: "btn primary" }, "Accept update as a draft") as HTMLButtonElement;
120
+ accept.addEventListener("click", async () => {
121
+ accept.disabled = true;
122
+ const expected = Object.fromEntries(plan.products.map((p) => [p.handle, p.baseRevision]));
123
+ const saveRes = await fetch(api, {
124
+ method: "POST",
125
+ headers: { "content-type": "application/json", accept: "application/json" },
126
+ body: JSON.stringify({ action: "save", ...body.request, expected }),
127
+ });
128
+ const saved = (await saveRes.json()) as { ok: boolean; code?: string; message?: string; error?: string; products?: Array<{ status: string }> };
129
+ if (!saveRes.ok || !saved.ok) {
130
+ const msg =
131
+ saved.code === "revision_conflict"
132
+ ? "This product changed since the update was prepared. Check again to review the latest version."
133
+ : saved.error ?? saved.message ?? `Saving failed (HTTP ${saveRes.status}).`;
134
+ box.append(el("p", { class: "notice-error", role: "alert" }, msg));
135
+ accept.disabled = false;
136
+ return;
137
+ }
138
+ box.append(el("p", {}, saved.products?.[0]?.status === "unchanged" ? "Already up to date." : "Saved as a draft change. Preview and publish it from the Publish tab."));
139
+ });
140
+ nodes.push(accept);
141
+ box.replaceChildren(...nodes);
142
+ } catch {
143
+ box.replaceChildren(el("p", { class: "notice-error", role: "alert" }, "The catalog check could not reach the server."));
144
+ } finally {
145
+ button.disabled = false;
146
+ }
147
+ }
148
+
149
+ for (const button of root.querySelectorAll<HTMLButtonElement>("[data-catalog-check]")) {
150
+ const handle = button.dataset.catalogCheck ?? "";
151
+ const box = button.parentElement?.querySelector<HTMLElement>("[data-catalog-result]");
152
+ if (box) button.addEventListener("click", () => void check(handle, box, button));
153
+ }
154
+
155
+ applyFilters();
156
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The admin Products tab's source: the store's own catalog files (ADR 0033's system of record).
3
+ * A store still served from the platform's grandfathered snapshot is shown from it, read-only,
4
+ * because its first catalog file would replace that snapshot on every surface.
5
+ */
6
+ import type { CatalogProduct, TenantConfig } from "@tot/public-runtime";
7
+ import { CatalogFileError } from "@tot/public-runtime/catalog-files";
8
+ import { readGrandfatheredSnapshot } from "@/lib/tot/ToTClient";
9
+ import { readTenantCatalogFiles, type CatalogArtifactSource } from "@/lib/tot/tenantCatalog";
10
+
11
+ export type CatalogOrigin = "files" | "snapshot" | "none";
12
+
13
+ export type AdminCatalog =
14
+ | { ok: true; origin: CatalogOrigin; products: CatalogProduct[] }
15
+ | { ok: false; message: string; failures?: ReadonlyArray<{ path: string; issues: readonly string[] }> };
16
+
17
+ export async function catalogOrigin(tenant: TenantConfig, content: CatalogArtifactSource | undefined): Promise<CatalogOrigin> {
18
+ if (await readTenantCatalogFiles(tenant, content)) return "files";
19
+ return (await readGrandfatheredSnapshot(tenant.tot_data_scope)) ? "snapshot" : "none";
20
+ }
21
+
22
+ export async function loadAdminCatalog(tenant: TenantConfig, content: CatalogArtifactSource | undefined): Promise<AdminCatalog> {
23
+ try {
24
+ const files = await readTenantCatalogFiles(tenant, content);
25
+ if (files) return { ok: true, origin: "files", products: files.products };
26
+ const snapshot = await readGrandfatheredSnapshot(tenant.tot_data_scope);
27
+ return snapshot ? { ok: true, origin: "snapshot", products: snapshot } : { ok: true, origin: "none", products: [] };
28
+ } catch (error) {
29
+ if (error instanceof CatalogFileError) return { ok: false, message: error.message, failures: error.failures };
30
+ return { ok: false, message: error instanceof Error ? error.message : String(error) };
31
+ }
32
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * The admin Products tab's view of the store's real catalog. Two readiness measures stay separate:
3
+ * listing readiness (can this product be listed: active, photographed, every variant priced and
4
+ * SKU'd) and catalog completeness (how many accepted catalog facts are known). Tax readiness is
5
+ * neither, and is not judged here.
6
+ */
7
+ import type { CatalogFact, CatalogProduct } from "@tot/public-runtime";
8
+ import type { AdminCatalog, CatalogOrigin } from "../services/catalogProducts";
9
+
10
+ export interface ListingReadiness {
11
+ ready: boolean;
12
+ missing: string[];
13
+ }
14
+
15
+ export interface CatalogCompleteness {
16
+ linkedVariants: number;
17
+ /** Percent of applicable accepted facts that are known; null when nothing is catalog-linked. */
18
+ percent: number | null;
19
+ unresolved: number;
20
+ }
21
+
22
+ export interface CatalogVariantRow {
23
+ id: string;
24
+ title: string;
25
+ sku: string | null;
26
+ price: string;
27
+ linkedItemId: string | null;
28
+ acceptedRevisionId: string | null;
29
+ acceptedAt: string | null;
30
+ unresolvedFacts: Array<Pick<CatalogFact, "label" | "state">>;
31
+ }
32
+
33
+ export interface CatalogProductRow {
34
+ handle: string;
35
+ title: string;
36
+ productType: string;
37
+ vendor: string;
38
+ status: CatalogProduct["status"];
39
+ variantCount: number;
40
+ priceLabel: string;
41
+ imageUrl: string | null;
42
+ listing: ListingReadiness;
43
+ catalog: CatalogCompleteness;
44
+ }
45
+
46
+ export interface CatalogProductDetail extends CatalogProductRow {
47
+ variants: CatalogVariantRow[];
48
+ images: Array<{ url: string; alt: string; variantScoped: boolean }>;
49
+ overrides: string[];
50
+ collections: string[];
51
+ }
52
+
53
+ export type CatalogProductsViewModel =
54
+ | {
55
+ state: "ready" | "empty";
56
+ origin: CatalogOrigin;
57
+ rows: CatalogProductRow[];
58
+ detailsByHandle: Record<string, CatalogProductDetail>;
59
+ }
60
+ | { state: "error"; message: string; failures: ReadonlyArray<{ path: string; issues: readonly string[] }> };
61
+
62
+ const UNRESOLVED = new Set<CatalogFact["state"]>(["unknown", "invalid", "conflict"]);
63
+
64
+ function money(amount: number, currency: string, locale: string): string {
65
+ try {
66
+ return new Intl.NumberFormat(locale, { style: "currency", currency }).format(amount);
67
+ } catch {
68
+ return `${amount.toFixed(2)} ${currency}`;
69
+ }
70
+ }
71
+
72
+ const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
73
+
74
+ export function listingReadiness(product: CatalogProduct): ListingReadiness {
75
+ const missing: string[] = [];
76
+ if (product.status !== "active") missing.push(`Status is ${product.status}`);
77
+ if (!product.title.trim()) missing.push("No title");
78
+ if (product.images.length === 0) missing.push("No photo");
79
+ if (product.variants.length === 0) missing.push("No variants");
80
+ const noSku = product.variants.filter((v) => !v.sku?.trim()).length;
81
+ const noPrice = product.variants.filter((v) => !(v.price > 0)).length;
82
+ if (noSku) missing.push(`${plural(noSku, "variant")} without a SKU`);
83
+ if (noPrice) missing.push(`${plural(noPrice, "variant")} without a price`);
84
+ return { ready: missing.length === 0, missing };
85
+ }
86
+
87
+ export function catalogCompleteness(product: CatalogProduct): CatalogCompleteness {
88
+ const facts = product.variants.flatMap((v) => v.catalog_link?.facts ?? []);
89
+ const applicable = facts.filter((f) => f.state !== "not_applicable");
90
+ const linkedVariants = product.variants.filter((v) => v.catalog_link).length;
91
+ return {
92
+ linkedVariants,
93
+ percent: linkedVariants && applicable.length ? Math.round((100 * applicable.filter((f) => f.state === "known").length) / applicable.length) : linkedVariants ? 100 : null,
94
+ unresolved: facts.filter((f) => UNRESOLVED.has(f.state)).length,
95
+ };
96
+ }
97
+
98
+ export function toCatalogProductsViewModel(catalog: AdminCatalog, locale: string): CatalogProductsViewModel {
99
+ if (!catalog.ok) return { state: "error", message: catalog.message, failures: catalog.failures ?? [] };
100
+ const rows: CatalogProductRow[] = [];
101
+ const detailsByHandle: Record<string, CatalogProductDetail> = {};
102
+ const sorted = [...catalog.products].sort((a, b) => a.title.localeCompare(b.title));
103
+ for (const p of sorted) {
104
+ const { min, max, currency } = p.price_range;
105
+ const row: CatalogProductRow = {
106
+ handle: p.handle,
107
+ title: p.title,
108
+ productType: p.product_type ?? "",
109
+ vendor: p.vendor ?? "",
110
+ status: p.status,
111
+ variantCount: p.variants.length,
112
+ priceLabel: min === max ? money(min, currency, locale) : `${money(min, currency, locale)} – ${money(max, currency, locale)}`,
113
+ imageUrl: [...p.images].sort((a, b) => a.position - b.position)[0]?.url ?? null,
114
+ listing: listingReadiness(p),
115
+ catalog: catalogCompleteness(p),
116
+ };
117
+ rows.push(row);
118
+ detailsByHandle[p.handle] = {
119
+ ...row,
120
+ variants: [...p.variants]
121
+ .sort((a, b) => a.position - b.position)
122
+ .map((v) => ({
123
+ id: v.id,
124
+ title: v.title,
125
+ sku: v.sku?.trim() || null,
126
+ price: v.price > 0 ? money(v.price, v.currency, locale) : "—",
127
+ linkedItemId: v.catalog_link?.item_id ?? null,
128
+ acceptedRevisionId: v.catalog_link?.accepted_revision_id ?? null,
129
+ acceptedAt: v.catalog_link?.accepted_at ?? null,
130
+ unresolvedFacts: (v.catalog_link?.facts ?? []).filter((f) => UNRESOLVED.has(f.state)).map((f) => ({ label: f.label, state: f.state })),
131
+ })),
132
+ images: [...p.images]
133
+ .sort((a, b) => a.position - b.position)
134
+ .map((img) => ({ url: img.url, alt: img.alt ?? "", variantScoped: Boolean(img.variant_ids?.length) })),
135
+ overrides: p.catalog_overrides ?? [],
136
+ collections: p.collections,
137
+ };
138
+ }
139
+ return { state: rows.length ? "ready" : "empty", origin: catalog.origin, rows, detailsByHandle };
140
+ }
@@ -167,6 +167,11 @@ async function loadFixtures(scope: string): Promise<FixtureBundle> {
167
167
  return bundle;
168
168
  }
169
169
 
170
+ /** The grandfathered in-repo snapshot's products for a data scope, or null when it has none. */
171
+ export async function readGrandfatheredSnapshot(scope: string): Promise<CatalogProduct[] | null> {
172
+ return pickByScope(productGlobs, scope) ? (await loadFixtures(scope)).products : null;
173
+ }
174
+
170
175
  /**
171
176
  * Resolves its backend on the first read, so a page that never touches the catalog
172
177
  * (a blog post, an editorial page) never pays for — or fails on — reading it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.4.11",
3
+ "version": "2.4.12",
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",
@@ -31,7 +31,8 @@ export type SourcePlatform =
31
31
  | "directory"
32
32
  | "csv"
33
33
  | "manual"
34
- | "synthesized";
34
+ | "synthesized"
35
+ | "tot-catalog";
35
36
 
36
37
  export interface SourceRef {
37
38
  /** Origin platform (provenance only; not branched on at runtime). */
@@ -139,6 +140,39 @@ export interface InventoryState {
139
140
  available: boolean;
140
141
  }
141
142
 
143
+ /** How a reviewed ToT catalog fact stood at the revision the merchant accepted. */
144
+ export type CatalogFactState = "known" | "unknown" | "invalid" | "conflict" | "not_applicable";
145
+
146
+ export interface CatalogFact {
147
+ /** The catalog's fact key, e.g. "nicotine_strength". */
148
+ key: string;
149
+ label: string;
150
+ state: CatalogFactState;
151
+ /** Present only when `state` is "known"; never a value inferred from an unknown fact. */
152
+ value?: string | number | boolean;
153
+ unit?: string;
154
+ }
155
+
156
+ /**
157
+ * The reviewed ToT catalog package a variant lists, pinned to the revision the merchant
158
+ * accepted. One variant lists exactly one packaged item. The accepted facts are stored here
159
+ * so the product previews and publishes with the catalog unavailable.
160
+ */
161
+ export interface CatalogLink {
162
+ item_id: string;
163
+ accepted_revision_id: string;
164
+ /** The catalog's explicit canonical grouping; absent when the catalog declares none. */
165
+ group_id?: string;
166
+ market?: string;
167
+ locale?: string;
168
+ /** ISO 8601. */
169
+ accepted_at: string;
170
+ facts: CatalogFact[];
171
+ }
172
+
173
+ /** Product fields a merchant set away from the accepted catalog content. */
174
+ export type CatalogOverrideField = "title" | "description_html" | "images" | "vendor" | "product_type";
175
+
142
176
  export interface CatalogVariant {
143
177
  tenant_id: string;
144
178
  id: string; // stable internal id (tenant-unique)
@@ -163,6 +197,8 @@ export interface CatalogVariant {
163
197
  position: number;
164
198
  /** CatalogImage.id of the variant's featured image, if any. */
165
199
  image_id?: string;
200
+ /** The catalog package this variant lists; absent for a merchant-authored variant. */
201
+ catalog_link?: CatalogLink;
166
202
  }
167
203
 
168
204
  export interface CatalogProduct {
@@ -200,6 +236,11 @@ export interface CatalogProduct {
200
236
  migrated_at: string; // ISO 8601
201
237
  /** Content hash for incremental sync / change detection. */
202
238
  source_checksum: string;
239
+ /**
240
+ * Fields the merchant changed from the accepted catalog content. Accepting a newer
241
+ * catalog revision never replaces them.
242
+ */
243
+ catalog_overrides?: CatalogOverrideField[];
203
244
  }
204
245
 
205
246
  /** Public listings and search include only visible, active products. */
@@ -75,6 +75,9 @@ importers:
75
75
  tailwindcss:
76
76
  specifier: ^4.3.1
77
77
  version: 4.3.1
78
+ zod:
79
+ specifier: ^4.4.3
80
+ version: 4.4.3
78
81
  devDependencies:
79
82
  '@astrojs/check':
80
83
  specifier: ^0.9.4