@tokenoftrust/storefront-runner 2.4.10 → 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.
- package/agency-kit/tools/lib/catalog-model/model.mjs +27 -1
- package/apps/storefront/package.json +2 -1
- package/apps/storefront/src/lib/admin/productsTabClient.ts +156 -0
- package/apps/storefront/src/lib/admin/services/catalogProducts.ts +32 -0
- package/apps/storefront/src/lib/admin/viewmodels/catalogProducts.ts +140 -0
- package/apps/storefront/src/lib/tot/ToTClient.ts +5 -0
- package/package.json +1 -1
- package/packages/public-runtime/src/binary-path.ts +1 -1
- package/packages/public-runtime/src/product.ts +42 -1
- package/pnpm-lock.runner.yaml +3 -0
|
@@ -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: {
|
|
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: {
|
|
@@ -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.
|
|
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",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* - WRITE: `customization-seed.ts#buildSeedFiles` base64-encodes binaries when
|
|
10
10
|
* it materializes the colocated subtree into a flat Gitea-repo seed.
|
|
11
|
-
* - READ: `gitea-source.ts#
|
|
11
|
+
* - READ: `mcp-gitea-source.ts#createMcpGiteaSource().readContent` base64-encodes
|
|
12
12
|
* binaries when it reads them back at a commit for the reconcile/assets sink.
|
|
13
13
|
*
|
|
14
14
|
* Keeping the extension set here (and importing it in both) guarantees the two
|
|
@@ -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. */
|