@tokenoftrust/storefront-runner 2.4.39 → 2.4.41
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/env.d.ts +11 -0
- package/apps/storefront/src/components/chrome/SiteFooter.astro +2 -2
- 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/apps/storefront/src/middleware/renderChain.ts +6 -0
- package/apps/storefront/src/pages/index.astro +2 -2
- package/apps/storefront/src/pages/products/[handle].astro +3 -3
- 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":
|
package/apps/storefront/env.d.ts
CHANGED
|
@@ -141,6 +141,17 @@ declare global {
|
|
|
141
141
|
* inside this prefix on the client.
|
|
142
142
|
*/
|
|
143
143
|
basePath: string;
|
|
144
|
+
/**
|
|
145
|
+
* The tenant's REAL routed prefix, always — never overridden to a candidate/
|
|
146
|
+
* revision-pinned render's own prefix the way {@link basePath} is. A pinned
|
|
147
|
+
* preview (`/preview/render/<tenant>/<changeId>`, `/preview/rev/<sha>`,
|
|
148
|
+
* `/preview/staged/<sha>`) has no API mirror under its prefix (the catch-all
|
|
149
|
+
* preview route only renders pages), so any same-origin fetch to a real
|
|
150
|
+
* backend endpoint (`/api/...`) must be built from THIS, not `basePath`, or
|
|
151
|
+
* it 404s under preview. Nav hrefs still want `basePath` so they stay inside
|
|
152
|
+
* the pinned render.
|
|
153
|
+
*/
|
|
154
|
+
apiBasePath: string;
|
|
144
155
|
/**
|
|
145
156
|
* Which customization environment to serve. "live" on production
|
|
146
157
|
* hosts; "test" only behind a valid, tenant-bound preview token on a
|
|
@@ -50,7 +50,7 @@ const {
|
|
|
50
50
|
class: className,
|
|
51
51
|
} = Astro.props;
|
|
52
52
|
|
|
53
|
-
const { basePath } = Astro.locals;
|
|
53
|
+
const { basePath, apiBasePath } = Astro.locals;
|
|
54
54
|
const hrefOf = (l: ChromeLink) => (l.external ? l.href : withBase(basePath, l.href));
|
|
55
55
|
const showLogo = !!brand?.logo && /^(https?:|\/)/.test(brand.logo.src);
|
|
56
56
|
const columnLinkStamp = (l: ChromeLink, colIndex: number, index: number) =>
|
|
@@ -184,7 +184,7 @@ const collapsible = variant === "light-collapsible";
|
|
|
184
184
|
{newsletter.title && <h3 class="font-display text-base font-semibold">{newsletter.title}</h3>}
|
|
185
185
|
{newsletter.body && <p class="mt-1 text-sm text-muted">{newsletter.body}</p>}
|
|
186
186
|
<form
|
|
187
|
-
action={withBase(
|
|
187
|
+
action={withBase(apiBasePath, newsletter.action ?? "/api/newsletter")}
|
|
188
188
|
method="post"
|
|
189
189
|
class="mt-4 flex max-w-md flex-col gap-3 sm:flex-row sm:flex-wrap"
|
|
190
190
|
>
|
|
@@ -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]));
|
|
@@ -668,6 +668,12 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
|
|
|
668
668
|
// `/preview/render/<tenant>/<changeId>`) so a pinned page's OWN generated
|
|
669
669
|
// links stay inside that candidate instead of escaping to the live host.
|
|
670
670
|
locals.basePath = candidatePin ? candidatePin.basePath : basePath;
|
|
671
|
+
// ALWAYS the real tenant-routed prefix, even under a candidate/revision pin —
|
|
672
|
+
// fb-1791905696516-ubw8a3: a pinned preview's catch-all route only knows how to
|
|
673
|
+
// RENDER a page, so same-origin fetches to a real `/api/...` backend endpoint
|
|
674
|
+
// (checkout handoff, compliance preflight, newsletter) must stay off `basePath`
|
|
675
|
+
// once it's pin-overridden, or they 404 under preview.
|
|
676
|
+
locals.apiBasePath = basePath;
|
|
671
677
|
// Renderer-owned four-stage browser environment (ADR 0014 E1 / 0017), computed
|
|
672
678
|
// ONCE here from authoritative request state and serialized before app code by
|
|
673
679
|
// both document mounts (Layout.astro + rawHtmlResponse). The candidate render
|
|
@@ -43,7 +43,7 @@ import {
|
|
|
43
43
|
type CommerceStaticResolved,
|
|
44
44
|
} from "@/lib/homeVisualParity";
|
|
45
45
|
|
|
46
|
-
const { tenant, tot, content, basePath, canonicalBase, features, capabilities } = Astro.locals;
|
|
46
|
+
const { tenant, tot, content, basePath, apiBasePath, canonicalBase, features, capabilities } = Astro.locals;
|
|
47
47
|
const marketing = !features.catalog;
|
|
48
48
|
|
|
49
49
|
// Raw-HTML home (vendor-injected): serve the tenant's own self-contained
|
|
@@ -296,7 +296,7 @@ const homeSectionWidgets = marketing
|
|
|
296
296
|
</h2>
|
|
297
297
|
{b.body && <p class="mt-3 text-[var(--color-muted)]">{b.body}</p>}
|
|
298
298
|
<form
|
|
299
|
-
action={withBase(
|
|
299
|
+
action={withBase(apiBasePath, "/api/newsletter")}
|
|
300
300
|
method="post"
|
|
301
301
|
class="mx-auto mt-7 flex max-w-md flex-col gap-3 sm:flex-row"
|
|
302
302
|
>
|
|
@@ -49,7 +49,7 @@ import { getRegistryService } from "@/lib/apps/registryAccess.js";
|
|
|
49
49
|
import { renderWidgetSlot } from "@/lib/apps/widgets/renderSlot.js";
|
|
50
50
|
|
|
51
51
|
const { handle } = Astro.params;
|
|
52
|
-
const { tenant, tot, canonicalBase, basePath, commerce } = Astro.locals;
|
|
52
|
+
const { tenant, tot, canonicalBase, basePath, apiBasePath, commerce } = Astro.locals;
|
|
53
53
|
// Commerce-only route: marketing tenants have no product detail pages.
|
|
54
54
|
if (!Astro.locals.features.catalog) return new Response(null, { status: 404 });
|
|
55
55
|
const compliance = tenant.compliance;
|
|
@@ -380,7 +380,7 @@ const jsonLd = [productJsonLd, breadcrumbLd(crumbs, canonicalBase)];
|
|
|
380
380
|
productTitle={product.title}
|
|
381
381
|
checkoutCart={checkoutCart}
|
|
382
382
|
pricing={pricing}
|
|
383
|
-
handoffUrl={withBase(
|
|
383
|
+
handoffUrl={withBase(apiBasePath, "/api/commerce/checkout-handoff")}
|
|
384
384
|
/>
|
|
385
385
|
{/* Regulated-tenant checkout ENFORCEMENT gate. Intercepts the
|
|
386
386
|
managed-cart handoff to clear age + ship-to compliance server-side
|
|
@@ -389,7 +389,7 @@ const jsonLd = [productJsonLd, breadcrumbLd(crumbs, canonicalBase)];
|
|
|
389
389
|
{checkoutCart && isRegulatedTenant(tenant) && compliance?.minAge && (
|
|
390
390
|
<CheckoutComplianceGate
|
|
391
391
|
client:load
|
|
392
|
-
preflightUrl={withBase(
|
|
392
|
+
preflightUrl={withBase(apiBasePath, "/api/compliance/preflight")}
|
|
393
393
|
minAge={compliance.minAge}
|
|
394
394
|
/>
|
|
395
395
|
)}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.41",
|
|
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;
|