@tokenoftrust/storefront-runner 2.4.40 → 2.4.42
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 +17 -0
- package/apps/storefront/package.json +1 -1
- package/apps/storefront/src/lib/admin/catalogSettingsClient.ts +35 -86
- package/apps/storefront/src/lib/admin/catalogSettingsState.ts +4 -0
- package/apps/storefront/src/lib/admin/productsTabClient.ts +46 -9
- package/package.json +1 -1
- package/packages/public-runtime/src/product.ts +16 -0
|
@@ -82,6 +82,18 @@ export const CATALOG_MODEL = {
|
|
|
82
82
|
"unit?": "string",
|
|
83
83
|
},
|
|
84
84
|
},
|
|
85
|
+
CatalogSkuReference: {
|
|
86
|
+
union: [
|
|
87
|
+
{ status: '"assigned" | "retired"', sku: "string", profileId: "string", profileVersion: "number",
|
|
88
|
+
acceptedRevisionId: "string", revisionStatus: '"current" | "historical"', itemId: "string",
|
|
89
|
+
provenance: '"catalog-reviewed-assignment"', findings: "string[]" },
|
|
90
|
+
{ status: '"suggested"', sku: "string", profileId: "string", profileVersion: "number",
|
|
91
|
+
acceptedRevisionId: "string", itemId: "string", provenance: '"catalog-preview"', findings: "string[]" },
|
|
92
|
+
{ status: '"review-needed"', sku: "null", profileId: "string | null", profileVersion: "number | null",
|
|
93
|
+
acceptedRevisionId: "string", itemId: "string", provenance: '"catalog-preview"', findings: "string[]" },
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
MerchantSkuOrigin: { alias: '"tot-adopted" | "merchant-override"' },
|
|
85
97
|
CatalogLink: {
|
|
86
98
|
fields: {
|
|
87
99
|
item_id: "string",
|
|
@@ -91,6 +103,7 @@ export const CATALOG_MODEL = {
|
|
|
91
103
|
"locale?": "string",
|
|
92
104
|
accepted_at: "string",
|
|
93
105
|
facts: "CatalogFact[]",
|
|
106
|
+
"sku_reference?": "CatalogSkuReference",
|
|
94
107
|
"setup_policy?": "CatalogSetupProvenance",
|
|
95
108
|
},
|
|
96
109
|
},
|
|
@@ -110,6 +123,7 @@ export const CATALOG_MODEL = {
|
|
|
110
123
|
product_id: "string",
|
|
111
124
|
source: "SourceRef",
|
|
112
125
|
"sku?": "string",
|
|
126
|
+
"sku_origin?": "MerchantSkuOrigin",
|
|
113
127
|
title: "string",
|
|
114
128
|
price: "number",
|
|
115
129
|
"compare_at_price?": "number",
|
|
@@ -288,6 +302,9 @@ function checkType(type, value, path, issues) {
|
|
|
288
302
|
return;
|
|
289
303
|
}
|
|
290
304
|
switch (t) {
|
|
305
|
+
case "null":
|
|
306
|
+
if (value !== null) issues.push({ path, message: "expected null" });
|
|
307
|
+
return;
|
|
291
308
|
case "unknown":
|
|
292
309
|
return;
|
|
293
310
|
case "string":
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"preview": "wrangler dev",
|
|
15
15
|
"typecheck": "astro check && tsc --noEmit",
|
|
16
16
|
"astro": "astro",
|
|
17
|
-
"test": "NODE_OPTIONS=--experimental-sqlite vitest run && pnpm run test:pipeline && pnpm run test:pipeline-faults"
|
|
17
|
+
"test": "NODE_OPTIONS=--experimental-sqlite vitest run --exclude src/lib/apps/v84CleanCloneReplay.test.ts --exclude src/lib/apps/v84NonportableRouteDogfood.test.ts --exclude src/lib/apps/v84CloneLifecycleEvidence.test.ts && NODE_OPTIONS=--experimental-sqlite vitest run src/lib/apps/v84CleanCloneReplay.test.ts src/lib/apps/v84NonportableRouteDogfood.test.ts src/lib/apps/v84CloneLifecycleEvidence.test.ts --fileParallelism=false && pnpm run test:pipeline && pnpm run test:pipeline-faults"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"@astrojs/cloudflare": "^14.0.1",
|
|
@@ -9,7 +9,6 @@ interface MethodOption { id: string; title: string; reason: string }
|
|
|
9
9
|
interface SettingsResponse extends SettingsPayload {
|
|
10
10
|
ok: true;
|
|
11
11
|
pricingMethods: MethodOption[];
|
|
12
|
-
skuMethods: MethodOption[];
|
|
13
12
|
history: Array<{ revision: number; savedAt: string; actor: string }>;
|
|
14
13
|
historyUnavailable: boolean;
|
|
15
14
|
}
|
|
@@ -19,10 +18,8 @@ interface PreviewResponse {
|
|
|
19
18
|
price: number | null; cost: number | null; profit: number | null; margin: number | null;
|
|
20
19
|
calculation: string; issues: string[]; ruleExplanation: string;
|
|
21
20
|
} };
|
|
22
|
-
sku?: { status: string; value?: string };
|
|
23
21
|
}
|
|
24
22
|
type Child = Node | string | null | undefined | false;
|
|
25
|
-
type Section = "pricing" | "sku";
|
|
26
23
|
type RecordValue = Record<string, unknown>;
|
|
27
24
|
|
|
28
25
|
function h<K extends keyof HTMLElementTagNameMap>(tag: K, attrs: Record<string, string | boolean | undefined> = {}, ...children: Child[]): HTMLElementTagNameMap[K] {
|
|
@@ -39,13 +36,12 @@ const METHOD_FIELDS: Record<string, [string, string, string]> = {
|
|
|
39
36
|
};
|
|
40
37
|
const METHOD_TITLES: Record<string, string> = {
|
|
41
38
|
msrp: "Supplier MSRP", margin: "Target margin", markup: "Markup over cost", "msrp-adjustment": "Adjust MSRP", fixed: "Add a fixed amount",
|
|
42
|
-
sequential: "Simple numbers", family: "Product families", hybrid: "Category + number", attributes: "Product attributes", supplier: "Supplier codes", random: "Short random codes", deterministic: "Stable identity codes",
|
|
43
39
|
};
|
|
44
40
|
const FIELD_LABELS: Record<string, string> = {
|
|
45
41
|
targetMargin: "Target gross margin", markup: "Markup over cost", msrpAdjustment: "MSRP adjustment", fixedMarkup: "Amount added to cost",
|
|
46
42
|
additionalCost: "Additional cost per item", additionalCostPercent: "Additional cost percentage", minimumPrice: "Minimum price", maximumPrice: "Maximum price", minimumMargin: "Minimum gross margin",
|
|
47
43
|
costBasis: "Cost basis", rounding: "Price format", increment: "Rounding increment", ending: "Price ending", direction: "Round direction", missingData: "Missing price source", fallbackMethod: "Fallback pricing method", updatePolicy: "Update preference",
|
|
48
|
-
|
|
44
|
+
method: "Method", productKey: "Product ID or SKU", selectedRuleId: "Matching rule",
|
|
49
45
|
name: "Rule name", scope: "Match by", match: "Matching value", minimumCost: "Cost from", maximumCost: "Cost below", price: "Selling price",
|
|
50
46
|
wholesale: "Wholesale cost", msrp: "Supplier MSRP", minimum: "Product minimum price", maximum: "Product maximum price",
|
|
51
47
|
};
|
|
@@ -76,10 +72,8 @@ export function initCatalogSettingsPage(root: HTMLElement): void {
|
|
|
76
72
|
let state: SettingsEditorState | null = null;
|
|
77
73
|
let storeCurrency = root.dataset.currency ?? "";
|
|
78
74
|
let pricingMethods: MethodOption[] = [];
|
|
79
|
-
let skuMethods: MethodOption[] = [];
|
|
80
75
|
let history: SettingsResponse["history"] = [];
|
|
81
76
|
let historyUnavailable = false;
|
|
82
|
-
let activeTab: Section = window.location.hash === "#sku" ? "sku" : "pricing";
|
|
83
77
|
let saving = false;
|
|
84
78
|
let loading = false;
|
|
85
79
|
let feedback = "";
|
|
@@ -93,19 +87,19 @@ export function initCatalogSettingsPage(root: HTMLElement): void {
|
|
|
93
87
|
const saveStatus = h("div", { class: "save-status", "aria-live": "polite" });
|
|
94
88
|
const saveButton = h("button", { type: "button", class: "btn primary", "data-save": "true" }, "Save defaults");
|
|
95
89
|
const discardButton = h("button", { type: "button", class: "btn secondary", "data-discard": "true" }, "Discard changes");
|
|
96
|
-
const tabs = h("div", { class: "settings-tabs", role: "tablist", "aria-label": "Catalog defaults" });
|
|
97
90
|
const money = (value: number, currency = state?.currency ?? storeCurrency) => {
|
|
98
91
|
try { return new Intl.NumberFormat(undefined, { style: "currency", currency }).format(value); }
|
|
99
92
|
catch { return `${currency} ${value.toFixed(2)}`; }
|
|
100
93
|
};
|
|
101
94
|
const currencyMismatch = () => Boolean(state && storeCurrency && state.currency !== storeCurrency);
|
|
102
|
-
const isConfigured = (
|
|
103
|
-
const methodTitle = (id: string) => METHOD_TITLES[id] ??
|
|
95
|
+
const isConfigured = () => state?.draft.pricingConfigured === true;
|
|
96
|
+
const methodTitle = (id: string) => METHOD_TITLES[id] ?? pricingMethods.find((method) => method.id === id)?.title ?? "Saved method";
|
|
104
97
|
|
|
105
98
|
function paintStatus(): void {
|
|
106
99
|
if (!state) return;
|
|
107
100
|
const dirty = draftIsDirty(state);
|
|
108
101
|
const notices: Node[] = [];
|
|
102
|
+
if (state.savedEngineVersion && state.savedEngineVersion !== state.engineVersion) notices.push(h("p", { class: "notice", role: "status" }, "Saved pricing defaults use an older calculation engine. Review your pricing method before saving this revision under the current engine. Existing listings and merchant SKUs stay unchanged."));
|
|
109
103
|
if (currencyMismatch()) notices.push(h("p", { class: "notice error", role: "alert" }, `Saved defaults use ${state.currency}; this store uses ${storeCurrency}. Saving and preview are paused. No currency conversion is applied.`));
|
|
110
104
|
if (state.conflict) {
|
|
111
105
|
const reload = h("button", { type: "button", class: "btn secondary", disabled: saving || loading, "data-reload": "true" }, "Load saved defaults");
|
|
@@ -163,45 +157,42 @@ export function initCatalogSettingsPage(root: HTMLElement): void {
|
|
|
163
157
|
const field = METHOD_FIELDS[fieldString(record, "method")];
|
|
164
158
|
return field ? numberField(record, field[0], field[1], touch, field[2], `${prefix}${field[0]}`) : null;
|
|
165
159
|
}
|
|
166
|
-
function
|
|
167
|
-
return section === "pricing" ? {
|
|
168
|
-
msrp: `${money(60)} MSRP → ${money(60)}`, margin: `${money(40)} cost · 40% → ${money(66.67)}`,
|
|
169
|
-
markup: `${money(40)} cost · 50% → ${money(60)}`, "msrp-adjustment": `${money(60)} MSRP · −10% → ${money(54)}`, fixed: `${money(40)} cost + ${money(15)} → ${money(55)}`,
|
|
170
|
-
} : { sequential: "SKU-004821", family: "P004821-V03", hybrid: "TSH-004821", attributes: "AC-TS42-BLK-M", supplier: "SUP7-AX204", random: "K7MX4R9T", deterministic: "P-7K3M9X2Q" };
|
|
171
|
-
}
|
|
172
|
-
function methodPicker(section: Section): HTMLElement {
|
|
160
|
+
function methodPicker(): HTMLElement {
|
|
173
161
|
if (!state) return h("div");
|
|
174
|
-
const record = state.draft
|
|
175
|
-
const configured = isConfigured(
|
|
176
|
-
const
|
|
177
|
-
const
|
|
178
|
-
const
|
|
179
|
-
const
|
|
180
|
-
|
|
162
|
+
const record = state.draft.pricing;
|
|
163
|
+
const configured = isConfigured();
|
|
164
|
+
const commonIds = ["msrp", "margin", "markup"];
|
|
165
|
+
const common = pricingMethods.filter((method) => commonIds.includes(method.id));
|
|
166
|
+
const more = pricingMethods.filter((method) => !commonIds.includes(method.id));
|
|
167
|
+
const sampleExamples: Record<string, string> = {
|
|
168
|
+
msrp: `${money(60)} MSRP → ${money(60)}`, margin: `${money(40)} cost · 40% → ${money(66.67)}`,
|
|
169
|
+
markup: `${money(40)} cost · 50% → ${money(60)}`, "msrp-adjustment": `${money(60)} MSRP · −10% → ${money(54)}`,
|
|
170
|
+
fixed: `${money(40)} cost + ${money(15)} → ${money(55)}`,
|
|
171
|
+
};
|
|
181
172
|
const reasons: Record<string, string> = {
|
|
182
|
-
msrp: "Start with the supplier’s suggested retail price.", margin: "Keep a consistent share of every sale.", markup: "Add a percentage to what you paid.",
|
|
173
|
+
msrp: "Start with the supplier’s suggested retail price.", margin: "Keep a consistent share of every sale.", markup: "Add a percentage to what you paid.",
|
|
183
174
|
};
|
|
184
175
|
const card = (method: MethodOption) => {
|
|
185
176
|
const selected = configured && record.method === method.id;
|
|
186
|
-
const radio = h("input", { type: "radio", name:
|
|
177
|
+
const radio = h("input", { type: "radio", name: "pricing-method", value: method.id, "data-focus": `pricing-${method.id}` });
|
|
187
178
|
radio.checked = selected;
|
|
188
|
-
radio.addEventListener("change", () => { if (state) { selectDraftMethod(state,
|
|
179
|
+
radio.addEventListener("change", () => { if (state) { selectDraftMethod(state, "pricing", method.id); touch(); renderEditor(); } });
|
|
189
180
|
return h("label", { class: `method-card${selected ? " selected" : ""}` }, radio,
|
|
190
181
|
h("span", { class: "method-title" }, methodTitle(method.id)), h("span", { class: "method-example" }, sampleExamples[method.id] ?? "Sample format"), h("span", { class: "method-reason" }, reasons[method.id] ?? method.reason));
|
|
191
182
|
};
|
|
192
|
-
const manual = h("button", { type: "button", class: "text-button", "data-focus":
|
|
183
|
+
const manual = h("button", { type: "button", class: "text-button", "data-focus": "pricing-manual", "aria-pressed": String(!configured) }, "Use manual prices instead");
|
|
193
184
|
manual.addEventListener("click", () => {
|
|
194
185
|
if (!state) return;
|
|
195
|
-
state.draft
|
|
186
|
+
state.draft.pricingConfigured = false;
|
|
196
187
|
touch(); renderEditor();
|
|
197
188
|
});
|
|
198
|
-
return h("section", { class: "editor-panel method-panel", "aria-labelledby":
|
|
199
|
-
h("div", { class: "section-heading" }, h("span", { class: "step-number", "aria-hidden": "true" }, "1"), h("h2", { id:
|
|
200
|
-
h("p", { class: "section-intro" },
|
|
201
|
-
!configured ? h("p", { class: "manual-note" },
|
|
189
|
+
return h("section", { class: "editor-panel method-panel", "aria-labelledby": "pricing-method-heading" },
|
|
190
|
+
h("div", { class: "section-heading" }, h("span", { class: "step-number", "aria-hidden": "true" }, "1"), h("h2", { id: "pricing-method-heading" }, "Choose your pricing method")),
|
|
191
|
+
h("p", { class: "section-intro" }, "One starting point for every new product."),
|
|
192
|
+
!configured ? h("p", { class: "manual-note" }, "Manual pricing is on. Choose a method to set a default.") : null,
|
|
202
193
|
h("div", { class: "method-grid" }, ...common.map(card)),
|
|
203
|
-
disclosure(
|
|
204
|
-
configured &&
|
|
194
|
+
disclosure("pricing-more", "More methods", "Adjust MSRP or add a fixed amount", [h("div", { class: "method-grid more-grid" }, ...more.map(card))], configured && more.some((method) => method.id === record.method)),
|
|
195
|
+
configured && METHOD_FIELDS[String(record.method)] ? h("div", { class: "method-parameter" }, parameter(record)) : null,
|
|
205
196
|
configured ? manual : null);
|
|
206
197
|
}
|
|
207
198
|
|
|
@@ -225,7 +216,7 @@ export function initCatalogSettingsPage(root: HTMLElement): void {
|
|
|
225
216
|
}));
|
|
226
217
|
}
|
|
227
218
|
function pricingControls(): HTMLElement[] {
|
|
228
|
-
if (!state || !isConfigured(
|
|
219
|
+
if (!state || !isConfigured()) return [];
|
|
229
220
|
const pricing = state.draft.pricing;
|
|
230
221
|
const repaint = () => { touch(); renderEditor(); };
|
|
231
222
|
const priceFormat = h("section", { class: "editor-panel" },
|
|
@@ -276,25 +267,6 @@ export function initCatalogSettingsPage(root: HTMLElement): void {
|
|
|
276
267
|
]);
|
|
277
268
|
return [priceFormat, advanced, rules];
|
|
278
269
|
}
|
|
279
|
-
function skuControls(): HTMLElement[] {
|
|
280
|
-
if (!state || !isConfigured("sku")) return [];
|
|
281
|
-
const sku = state.draft.sku;
|
|
282
|
-
const numbered = ["sequential", "family", "hybrid"].includes(String(sku.method));
|
|
283
|
-
const separated = sku.method !== "random";
|
|
284
|
-
const hasPrefix = !["attributes", "random"].includes(String(sku.method));
|
|
285
|
-
return [h("section", { class: "editor-panel" },
|
|
286
|
-
h("div", { class: "section-heading" }, h("span", { class: "step-number", "aria-hidden": "true" }, "2"), h("h2", {}, "Fine-tune the format")),
|
|
287
|
-
h("div", { class: "fields-grid" },
|
|
288
|
-
hasPrefix ? textField(sku, "prefix", sku.method === "supplier" ? "Supplier prefix" : sku.method === "hybrid" ? "Category prefix" : "Prefix", touch, { maxlength: "12", spellcheck: "false" }) : null,
|
|
289
|
-
numbered ? numberField(sku, "startingNumber", "Starting number", touch, "A whole number. The preview pads it to six digits.") : null,
|
|
290
|
-
separated ? select(sku, "separator", "Separator", [{ value: "-", label: "Hyphen (–)" }, { value: "_", label: "Underscore (_)" }, { value: "", label: "None" }]) : null),
|
|
291
|
-
hasPrefix ? h("p", { class: "hint" }, "An optional prefix of up to 12 uppercase letters or digits, starting with a letter.") : null,
|
|
292
|
-
!separated ? h("p", { class: "hint" }, "No format options needed. Use eight uppercase letters and digits.") : null,
|
|
293
|
-
sku.method === "attributes" ? h("p", { class: "hint" }, "The sample illustrates brand, model, color and size abbreviations.") : null,
|
|
294
|
-
sku.method === "family" ? h("p", { class: "hint" }, "V03 illustrates a variant within a numbered product family.") : null,
|
|
295
|
-
sku.method === "supplier" ? h("p", { class: "hint" }, "AX204 illustrates a supplier’s item code.") : null,
|
|
296
|
-
sku.method === "deterministic" ? h("p", { class: "hint" }, "The sample uses a fixed item identity. Generated codes still need a uniqueness check.") : null)];
|
|
297
|
-
}
|
|
298
270
|
function savedHistory(): HTMLElement {
|
|
299
271
|
const entries: Node[] = [];
|
|
300
272
|
if (historyUnavailable) entries.push(h("p", { class: "hint" }, "Saved history is unavailable."));
|
|
@@ -313,38 +285,16 @@ export function initCatalogSettingsPage(root: HTMLElement): void {
|
|
|
313
285
|
const focusKey = focused && editor.contains(focused) ? focused.dataset.focus : undefined;
|
|
314
286
|
const selection: [number | null, number | null] | null = focused?.tagName === "INPUT" && ["text", "search"].includes(focused.type) ? [focused.selectionStart, focused.selectionEnd] : null;
|
|
315
287
|
editor.replaceChildren(
|
|
316
|
-
h("div", { id: "settings-pricing-panel"
|
|
317
|
-
h("
|
|
318
|
-
h("p", { class: "preserve-note" }, h("span", { "aria-hidden": "true" }, "✓"), " Saved listings keep their prices and SKUs. These defaults never overwrite them."), savedHistory());
|
|
288
|
+
h("div", { id: "settings-pricing-panel" }, methodPicker(), ...pricingControls()),
|
|
289
|
+
h("p", { class: "preserve-note" }, h("span", { "aria-hidden": "true" }, "✓"), " Saved listings keep their prices and merchant SKUs. Pricing defaults never overwrite them."), savedHistory());
|
|
319
290
|
if (focusKey) {
|
|
320
291
|
const control = Array.from(editor.querySelectorAll<HTMLElement>("[data-focus]")).find((node) => node.dataset.focus === focusKey);
|
|
321
292
|
control?.focus({ preventScroll: true });
|
|
322
293
|
if (selection && control instanceof HTMLInputElement) control.setSelectionRange(selection[0], selection[1]);
|
|
323
|
-
if (!control && focusKey.endsWith("-manual")) editor.querySelector<HTMLInputElement>(`input[name="
|
|
294
|
+
if (!control && focusKey.endsWith("-manual")) editor.querySelector<HTMLInputElement>(`input[name="pricing-method"]`)?.focus({ preventScroll: true });
|
|
324
295
|
}
|
|
325
296
|
refreshRuleChoices();
|
|
326
297
|
}
|
|
327
|
-
function setTab(section: Section, focus = false, updateHash = false): void {
|
|
328
|
-
activeTab = section;
|
|
329
|
-
if (updateHash) window.history.replaceState(window.history.state, "", `#${section}`);
|
|
330
|
-
for (const tab of tabs.querySelectorAll<HTMLButtonElement>("[data-tab]")) {
|
|
331
|
-
const selected = tab.dataset.tab === section;
|
|
332
|
-
tab.setAttribute("aria-selected", String(selected)); tab.tabIndex = selected ? 0 : -1;
|
|
333
|
-
if (selected && focus) tab.focus();
|
|
334
|
-
}
|
|
335
|
-
for (const id of ["pricing", "sku"]) { const pane = editor.querySelector<HTMLElement>(`#settings-${id}-panel`); if (pane) pane.hidden = id !== section; }
|
|
336
|
-
}
|
|
337
|
-
for (const section of ["pricing", "sku"] as const) {
|
|
338
|
-
const tab = h("button", { type: "button", role: "tab", id: `settings-${section}-tab`, "data-tab": section, "aria-controls": `settings-${section}-panel` }, section === "pricing" ? "Pricing" : "SKUs");
|
|
339
|
-
tab.addEventListener("click", () => setTab(section, false, true));
|
|
340
|
-
tab.addEventListener("keydown", (event) => {
|
|
341
|
-
if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) {
|
|
342
|
-
event.preventDefault(); setTab(event.key === "Home" ? "pricing" : event.key === "End" ? "sku" : activeTab === "pricing" ? "sku" : "pricing", true, true);
|
|
343
|
-
}
|
|
344
|
-
});
|
|
345
|
-
tabs.append(tab);
|
|
346
|
-
}
|
|
347
|
-
window.addEventListener("hashchange", () => setTab(window.location.hash === "#sku" ? "sku" : "pricing"));
|
|
348
298
|
const ruleSelect = select(sampleText, "selectedRuleId", "Matching rule", [{ value: "", label: "Automatically match" }, { value: "store", label: "Use store default" }], schedulePreview, "sample-selectedRuleId");
|
|
349
299
|
function refreshRuleChoices(): void {
|
|
350
300
|
const control = ruleSelect.querySelector("select");
|
|
@@ -363,7 +313,7 @@ export function initCatalogSettingsPage(root: HTMLElement): void {
|
|
|
363
313
|
h("div", { class: "fields-grid" }, numberField(sample, "minimum", "Product minimum price", schedulePreview, "", "sample-minimum"), numberField(sample, "maximum", "Product maximum price", schedulePreview, "", "sample-maximum")),
|
|
364
314
|
textField(sampleText, "productKey", "Product ID or SKU", schedulePreview, {}, "sample-productKey"),
|
|
365
315
|
...["assortment", "category", "brand"].map((key) => textField(sampleText, key, key.charAt(0).toUpperCase() + key.slice(1), schedulePreview, {}, `sample-${key}`)), ruleSelect]),
|
|
366
|
-
h("p", { class: "preview-footnote" }, "A sample
|
|
316
|
+
h("p", { class: "preview-footnote" }, "A sample price; saving it does not change catalog references or existing listings."));
|
|
367
317
|
|
|
368
318
|
function paintPreview(result: PreviewStatus<PreviewResponse>): void {
|
|
369
319
|
previewOut.setAttribute("aria-busy", String(result.status === "waiting" || result.status === "loading"));
|
|
@@ -385,8 +335,7 @@ export function initCatalogSettingsPage(root: HTMLElement): void {
|
|
|
385
335
|
previewOut.replaceChildren(
|
|
386
336
|
h("div", { class: "preview-result" }, h("span", { class: "result-label" }, "Selling price"),
|
|
387
337
|
h("strong", { class: `sample-price${!calculated ? " manual-result" : ""}` }, calculated ? calculated.price === null ? "Needs review" : money(calculated.price, body.currency) : "Manual price"),
|
|
388
|
-
h("span", { class: "result-method" }, calculated ? "Your draft price" : "Choose a pricing method to calculate"), metrics,
|
|
389
|
-
h("div", { class: "sku-result" }, h("span", { class: "result-label" }, "Sample SKU"), h("strong", {}, body.sku?.status === "ok" ? body.sku.value ?? "—" : "Entered manually"))),
|
|
338
|
+
h("span", { class: "result-method" }, calculated ? "Your draft price" : "Choose a pricing method to calculate"), metrics),
|
|
390
339
|
...(calculated?.calculation ? [h("p", { class: "calculation hint" }, calculated.calculation)] : []),
|
|
391
340
|
...(calculated?.ruleExplanation && calculated.ruleExplanation !== "Store default" ? [h("p", { class: "hint applied-rule" }, calculated.ruleExplanation)] : []),
|
|
392
341
|
...(calculated?.issues ?? []).map((issue) => h("p", { class: "notice review-note" }, issue)));
|
|
@@ -405,8 +354,8 @@ export function initCatalogSettingsPage(root: HTMLElement): void {
|
|
|
405
354
|
preview.schedule({ ...draft, sample: { ...sample, ...Object.fromEntries(Object.entries(sampleText).filter(([, value]) => value !== "")) } });
|
|
406
355
|
}
|
|
407
356
|
function mount(): void {
|
|
408
|
-
panel.replaceChildren(
|
|
409
|
-
renderEditor();
|
|
357
|
+
panel.replaceChildren(status, h("div", { class: "settings-layout" }, editor, previewPanel), h("div", { class: "save-bar" }, h("div", { class: "save-bar-inner" }, saveStatus, h("div", { class: "save-actions" }, discardButton, saveButton))));
|
|
358
|
+
renderEditor(); paintStatus(); schedulePreview();
|
|
410
359
|
}
|
|
411
360
|
async function load(): Promise<void> {
|
|
412
361
|
if (saving || loading) return;
|
|
@@ -416,7 +365,7 @@ export function initCatalogSettingsPage(root: HTMLElement): void {
|
|
|
416
365
|
const response = await fetch(api, { headers: { accept: "application/json" } });
|
|
417
366
|
const body = await response.json() as SettingsResponse & { message?: string };
|
|
418
367
|
if (!response.ok || !body.ok) throw new Error(body.message || "Your defaults could not be loaded. Try again.");
|
|
419
|
-
pricingMethods = body.pricingMethods;
|
|
368
|
+
pricingMethods = body.pricingMethods; history = body.history; historyUnavailable = body.historyUnavailable;
|
|
420
369
|
state = editorFromPayload(body);
|
|
421
370
|
if (typeof body.storeCurrency === "string") storeCurrency = body.storeCurrency;
|
|
422
371
|
const badge = previewPanel.querySelector(".currency-badge"); if (badge) badge.textContent = state.currency;
|
|
@@ -14,6 +14,7 @@ export interface SettingsPayload {
|
|
|
14
14
|
currency: string;
|
|
15
15
|
storeCurrency?: string;
|
|
16
16
|
engineVersion: string;
|
|
17
|
+
savedEngineVersion?: string | null;
|
|
17
18
|
revision: number | null;
|
|
18
19
|
pricing: SettingsSection | null;
|
|
19
20
|
sku: SettingsSection | null;
|
|
@@ -33,6 +34,7 @@ export interface SettingsEditorState {
|
|
|
33
34
|
savedRevision: number | null;
|
|
34
35
|
currency: string;
|
|
35
36
|
engineVersion: string;
|
|
37
|
+
savedEngineVersion: string | null;
|
|
36
38
|
draft: CalculationDraft;
|
|
37
39
|
saved: CalculationDraft | null;
|
|
38
40
|
conflict: { revision: number | null; message: string } | null;
|
|
@@ -61,6 +63,7 @@ export function editorFromPayload(payload: SettingsPayload): SettingsEditorState
|
|
|
61
63
|
savedRevision: payload.revision,
|
|
62
64
|
currency: payload.currency,
|
|
63
65
|
engineVersion: payload.engineVersion,
|
|
66
|
+
savedEngineVersion: payload.revision === null ? null : payload.savedEngineVersion ?? payload.engineVersion,
|
|
64
67
|
draft,
|
|
65
68
|
saved: structuredClone(draft),
|
|
66
69
|
conflict: null,
|
|
@@ -114,6 +117,7 @@ export function applySaveResult(state: SettingsEditorState, result: SaveOutcome)
|
|
|
114
117
|
contractRevision: state.contractRevision,
|
|
115
118
|
currency: state.currency,
|
|
116
119
|
engineVersion: state.engineVersion,
|
|
120
|
+
savedEngineVersion: state.engineVersion,
|
|
117
121
|
revision: result.revision,
|
|
118
122
|
pricing: result.pricing,
|
|
119
123
|
sku: result.sku,
|
|
@@ -4,6 +4,16 @@
|
|
|
4
4
|
* explicitly as a draft change. Catalog text is set as text, never parsed as HTML.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
/** The fields this review renders; the public runner does not bundle catalog setup. */
|
|
8
|
+
interface CatalogReferenceView {
|
|
9
|
+
status: "assigned" | "retired" | "suggested" | "review-needed";
|
|
10
|
+
sku: string | null;
|
|
11
|
+
profileId: string | null;
|
|
12
|
+
profileVersion: number | null;
|
|
13
|
+
revisionStatus?: "current" | "historical";
|
|
14
|
+
findings: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
7
17
|
interface UpdatePlanProduct {
|
|
8
18
|
handle: string;
|
|
9
19
|
baseRevision: string;
|
|
@@ -14,6 +24,13 @@ interface UpdatesResponse {
|
|
|
14
24
|
ok: boolean;
|
|
15
25
|
message?: string;
|
|
16
26
|
updates: Array<{ title: string; acceptedRevisionId: string; latestRevisionId: string }>;
|
|
27
|
+
referenceUpdates: Array<{
|
|
28
|
+
itemId: string;
|
|
29
|
+
title: string;
|
|
30
|
+
acceptedRevisionId: string;
|
|
31
|
+
saved: CatalogReferenceView | null;
|
|
32
|
+
incoming: CatalogReferenceView;
|
|
33
|
+
}>;
|
|
17
34
|
unavailable?: Array<{ title: string; message: string }>;
|
|
18
35
|
request?: Record<string, unknown>;
|
|
19
36
|
plan: { products: UpdatePlanProduct[] } | null;
|
|
@@ -35,6 +52,15 @@ function el(tag: string, attrs: Record<string, string> = {}, ...children: Array<
|
|
|
35
52
|
return node;
|
|
36
53
|
}
|
|
37
54
|
|
|
55
|
+
function referenceLabel(reference: CatalogReferenceView | null): string {
|
|
56
|
+
if (!reference) return "none saved";
|
|
57
|
+
const code = reference.sku ?? "no approved code";
|
|
58
|
+
const status = reference.status === "suggested" ? "preview only" : reference.status.replace("-", " ");
|
|
59
|
+
const revision = reference.revisionStatus ? `, ${reference.revisionStatus} revision` : "";
|
|
60
|
+
const method = reference.profileId ? `, ${reference.profileId} v${reference.profileVersion}` : "";
|
|
61
|
+
return `${code} (${status}${revision}${method})`;
|
|
62
|
+
}
|
|
63
|
+
|
|
38
64
|
export function initProductsTab(root: HTMLElement): void {
|
|
39
65
|
const api = root.dataset.productsApi ?? "";
|
|
40
66
|
const statusSelect = root.querySelector<HTMLSelectElement>("[data-products-status-select]");
|
|
@@ -81,21 +107,31 @@ export function initProductsTab(root: HTMLElement): void {
|
|
|
81
107
|
return;
|
|
82
108
|
}
|
|
83
109
|
const nodes: Node[] = [];
|
|
110
|
+
const referenceUpdates = body.referenceUpdates ?? [];
|
|
84
111
|
if (body.unavailable?.length) {
|
|
85
112
|
nodes.push(el("p", { class: "subtle" }, `Not checked: ${body.unavailable.map((u) => `${u.title} (${u.message})`).join("; ")}.`));
|
|
86
113
|
}
|
|
87
|
-
if (body.updates.length === 0) {
|
|
88
|
-
nodes.push(el("p", {},
|
|
114
|
+
if (body.updates.length === 0 && referenceUpdates.length === 0) {
|
|
115
|
+
nodes.push(el("p", {}, body.unavailable?.length
|
|
116
|
+
? "No catalog update was found among the packages that could be checked."
|
|
117
|
+
: "Every listed package is at the catalog's latest revision."));
|
|
89
118
|
box.replaceChildren(...nodes);
|
|
90
119
|
return;
|
|
91
120
|
}
|
|
92
|
-
nodes.push(
|
|
121
|
+
if (body.updates.length) nodes.push(
|
|
93
122
|
el("strong", {}, `${body.updates.length} package(s) have a newer catalog revision`),
|
|
94
|
-
el(
|
|
95
|
-
"
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
),
|
|
123
|
+
el("ul", { class: "update-list" },
|
|
124
|
+
...body.updates.map((u) => el("li", {}, `${u.title}: revision ${u.acceptedRevisionId.slice(0, 8)} → ${u.latestRevisionId.slice(0, 8)}`))),
|
|
125
|
+
);
|
|
126
|
+
if (referenceUpdates.length) nodes.push(
|
|
127
|
+
el("strong", {}, `${referenceUpdates.length} catalog reference ${referenceUpdates.length === 1 ? "update" : "updates"} to review`),
|
|
128
|
+
el("ul", { class: "update-list" }, ...referenceUpdates.map((update) => el("li", {},
|
|
129
|
+
`${update.title}: ${referenceLabel(update.saved)} → ${referenceLabel(update.incoming)}. `,
|
|
130
|
+
`Exact package revision ${update.acceptedRevisionId.slice(0, 8)}.`,
|
|
131
|
+
...(update.incoming.findings.length
|
|
132
|
+
? [el("ul", {}, ...update.incoming.findings.map((finding) => el("li", {}, finding)))]
|
|
133
|
+
: []),
|
|
134
|
+
))),
|
|
99
135
|
);
|
|
100
136
|
const plan = body.plan;
|
|
101
137
|
if (!plan || !body.request) {
|
|
@@ -116,7 +152,8 @@ export function initProductsTab(root: HTMLElement): void {
|
|
|
116
152
|
);
|
|
117
153
|
}
|
|
118
154
|
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" },
|
|
155
|
+
const accept = el("button", { type: "button", class: "btn primary" }, referenceUpdates.length && !body.updates.length
|
|
156
|
+
? "Accept catalog reference update as a draft" : "Accept update as a draft") as HTMLButtonElement;
|
|
120
157
|
accept.addEventListener("click", async () => {
|
|
121
158
|
accept.disabled = true;
|
|
122
159
|
const expected = Object.fromEntries(plan.products.map((p) => [p.handle, p.baseRevision]));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.42",
|
|
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",
|
|
@@ -158,6 +158,19 @@ export interface CatalogFact {
|
|
|
158
158
|
* accepted. One variant lists exactly one packaged item. The accepted facts are stored here
|
|
159
159
|
* so the product previews and publishes with the catalog unavailable.
|
|
160
160
|
*/
|
|
161
|
+
/** Server-verified shared catalog code at the exact accepted item revision. */
|
|
162
|
+
export type CatalogSkuReference =
|
|
163
|
+
| { status: "assigned" | "retired"; sku: string; profileId: string; profileVersion: number;
|
|
164
|
+
acceptedRevisionId: string; revisionStatus: "current" | "historical"; itemId: string;
|
|
165
|
+
provenance: "catalog-reviewed-assignment"; findings: string[] }
|
|
166
|
+
| { status: "suggested"; sku: string; profileId: string; profileVersion: number;
|
|
167
|
+
acceptedRevisionId: string; itemId: string; provenance: "catalog-preview"; findings: string[] }
|
|
168
|
+
| { status: "review-needed"; sku: null; profileId: string | null; profileVersion: number | null;
|
|
169
|
+
acceptedRevisionId: string; itemId: string; provenance: "catalog-preview"; findings: string[] };
|
|
170
|
+
|
|
171
|
+
/** Merchant's operational choice; it never changes the shared catalog reference. */
|
|
172
|
+
export type MerchantSkuOrigin = "tot-adopted" | "merchant-override";
|
|
173
|
+
|
|
161
174
|
export interface CatalogLink {
|
|
162
175
|
item_id: string;
|
|
163
176
|
accepted_revision_id: string;
|
|
@@ -168,6 +181,8 @@ export interface CatalogLink {
|
|
|
168
181
|
/** ISO 8601. */
|
|
169
182
|
accepted_at: string;
|
|
170
183
|
facts: CatalogFact[];
|
|
184
|
+
/** Independent of editable merchant SKU and revocable setup-policy acceptance. */
|
|
185
|
+
sku_reference?: CatalogSkuReference;
|
|
171
186
|
/** Server-verified defaults adopted by the merchant; replayable without the current policy. */
|
|
172
187
|
setup_policy?: CatalogSetupProvenance;
|
|
173
188
|
}
|
|
@@ -204,6 +219,7 @@ export interface CatalogVariant {
|
|
|
204
219
|
product_id: string;
|
|
205
220
|
source: SourceRef;
|
|
206
221
|
sku?: string;
|
|
222
|
+
sku_origin?: MerchantSkuOrigin;
|
|
207
223
|
title: string;
|
|
208
224
|
price: number;
|
|
209
225
|
compare_at_price?: number;
|