@tokenoftrust/storefront-runner 2.4.29 → 2.4.31

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.
@@ -1,22 +1,11 @@
1
- /**
2
- * /admin/catalog-settings client. Field values are text nodes. A 409 keeps the
3
- * open draft; replacing it requires the explicit reload control.
4
- */
1
+ /** Catalog defaults: progressively revealed controls and an authoritative live sample. */
5
2
  import {
6
- applySaveResult,
7
- draftIsDirty,
8
- editorFromPayload,
9
- saveBody,
10
- type SettingsEditorState,
11
- type SettingsPayload,
3
+ applySaveAfterEdits, draftIsDirty, editorFromPayload, parseSettingsNumber, saveBody, selectDraftMethod,
4
+ type SettingsEditorState, type SettingsPayload,
12
5
  } from "@/lib/admin/catalogSettingsState";
6
+ import { latestPreview, type PreviewStatus } from "@/lib/admin/catalogSettingsPreview";
13
7
 
14
- interface MethodOption {
15
- id: string;
16
- title: string;
17
- reason: string;
18
- }
19
-
8
+ interface MethodOption { id: string; title: string; reason: string }
20
9
  interface SettingsResponse extends SettingsPayload {
21
10
  ok: true;
22
11
  pricingMethods: MethodOption[];
@@ -24,72 +13,59 @@ interface SettingsResponse extends SettingsPayload {
24
13
  history: Array<{ revision: number; savedAt: string; actor: string }>;
25
14
  historyUnavailable: boolean;
26
15
  }
27
-
16
+ interface PreviewResponse {
17
+ ok?: boolean; message?: string; issues?: string[]; currency?: string;
18
+ pricing?: { status: string; preview?: {
19
+ price: number | null; cost: number | null; profit: number | null; margin: number | null;
20
+ calculation: string; issues: string[]; ruleExplanation: string;
21
+ } };
22
+ sku?: { status: string; value?: string };
23
+ }
28
24
  type Child = Node | string | null | undefined | false;
25
+ type Section = "pricing" | "sku";
26
+ type RecordValue = Record<string, unknown>;
29
27
 
30
- function h<K extends keyof HTMLElementTagNameMap>(
31
- tag: K,
32
- attrs: Record<string, string | boolean | undefined> = {},
33
- ...children: Child[]
34
- ): HTMLElementTagNameMap[K] {
28
+ function h<K extends keyof HTMLElementTagNameMap>(tag: K, attrs: Record<string, string | boolean | undefined> = {}, ...children: Child[]): HTMLElementTagNameMap[K] {
35
29
  const node = document.createElement(tag);
36
- for (const [key, value] of Object.entries(attrs)) {
37
- if (value === undefined || value === false) continue;
38
- node.setAttribute(key, value === true ? "" : value);
39
- }
30
+ for (const [key, value] of Object.entries(attrs)) if (value !== undefined && value !== false) node.setAttribute(key, value === true ? "" : value);
40
31
  for (const child of children) if (child !== null && child !== undefined && child !== false) node.append(child);
41
32
  return node;
42
33
  }
43
-
44
- const PRICING_NUMBERS: Array<[string, string]> = [
45
- ["targetMargin", "Target margin (%)"],
46
- ["markup", "Markup (%)"],
47
- ["msrpAdjustment", "MSRP adjustment (%)"],
48
- ["fixedMarkup", "Dollar markup"],
49
- ["additionalCost", "Additional cost"],
50
- ["additionalCostPercent", "Additional cost (%)"],
51
- ["increment", "Rounding increment"],
52
- ["minimumPrice", "Minimum price"],
53
- ["maximumPrice", "Maximum price"],
54
- ["minimumMargin", "Minimum margin (%)"],
55
- ];
56
-
57
- const PRICING_SELECTS: Array<[string, string, string[]]> = [
58
- ["costBasis", "Cost basis", ["wholesale", "adjusted"]],
59
- ["rounding", "Rounding", ["none", "increment", "ending"]],
60
- ["direction", "Round direction", ["up", "nearest", "down"]],
61
- ["missingData", "Missing data", ["review", "fallback"]],
62
- ["fallbackMethod", "Fallback method", ["msrp", "margin", "markup", "fixed"]],
63
- ["updatePolicy", "When costs change", ["on-add", "manual", "automatic"]],
64
- ];
65
-
66
- const PRICING_FLAGS: Array<[string, string]> = [
67
- ["preserveMsrp", "Preserve MSRP as compare-at"],
68
- ["capAtMsrp", "Cap the price at MSRP"],
69
- ["includeManualOverrides", "Include manual product overrides"],
70
- ];
71
-
72
- const RULE_NUMBERS: Array<[string, string]> = [
73
- ["targetMargin", "Target margin (%)"],
74
- ["markup", "Markup (%)"],
75
- ["msrpAdjustment", "MSRP adjustment (%)"],
76
- ["fixedMarkup", "Dollar markup"],
77
- ];
78
-
79
- function asRecord(value: unknown): Record<string, unknown> {
80
- return value && typeof value === "object" ? value as Record<string, unknown> : {};
81
- }
82
-
83
- function numberOrBlank(raw: string): number | "" {
84
- if (raw.trim() === "") return "";
85
- const value = Number(raw);
86
- return Number.isFinite(value) ? value : "";
87
- }
88
-
89
- function fieldString(record: Record<string, unknown>, key: string): string {
34
+ const METHOD_FIELDS: Record<string, [string, string, string]> = {
35
+ margin: ["targetMargin", "Target gross margin (%)", "The share of your selling price left after product cost."],
36
+ markup: ["markup", "Markup over cost (%)", "100% doubles cost. That gives a 50% gross margin."],
37
+ "msrp-adjustment": ["msrpAdjustment", "MSRP adjustment (%)", "−10 is 10% below MSRP; +10 is 10% above."],
38
+ fixed: ["fixedMarkup", "Amount added to cost", "Add this amount to the cost of each item."],
39
+ };
40
+ const METHOD_TITLES: Record<string, string> = {
41
+ 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
+ };
44
+ const FIELD_LABELS: Record<string, string> = {
45
+ targetMargin: "Target gross margin", markup: "Markup over cost", msrpAdjustment: "MSRP adjustment", fixedMarkup: "Amount added to cost",
46
+ additionalCost: "Additional cost per item", additionalCostPercent: "Additional cost percentage", minimumPrice: "Minimum price", maximumPrice: "Maximum price", minimumMargin: "Minimum gross margin",
47
+ 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
+ startingNumber: "Starting number", prefix: "SKU prefix", separator: "SKU separator", method: "Method", productKey: "Product ID or SKU", selectedRuleId: "Matching rule",
49
+ name: "Rule name", scope: "Match by", match: "Matching value", minimumCost: "Cost from", maximumCost: "Cost below", price: "Selling price",
50
+ wholesale: "Wholesale cost", msrp: "Supplier MSRP", minimum: "Product minimum price", maximum: "Product maximum price",
51
+ };
52
+ function asRecord(value: unknown): RecordValue { return value && typeof value === "object" ? value as RecordValue : {}; }
53
+ function fieldString(record: RecordValue, key: string): string {
90
54
  const value = record[key];
91
55
  return typeof value === "number" || typeof value === "string" ? String(value) : "";
92
56
  }
57
+ function responseMessage(body: { message?: string; issues?: string[] }, fallback: string): string {
58
+ const issues = (body.issues ?? []).map((issue) => {
59
+ const separator = issue.indexOf(": ");
60
+ if (separator < 0) return issue;
61
+ const path = issue.slice(0, separator).split(".");
62
+ let label = FIELD_LABELS[path.at(-1) ?? ""] ?? "Settings";
63
+ const collection = path.findIndex((part) => ["exceptions", "costTiers", "productOverrides"].includes(part));
64
+ if (collection >= 0 && /^\d+$/.test(path[collection + 1] ?? "")) label = `${path[collection] === "exceptions" ? "Rule" : path[collection] === "costTiers" ? "Cost tier" : "Product price"} ${Number(path[collection + 1]) + 1} · ${label}`;
65
+ return `${label}: ${issue.slice(separator + 2)}`;
66
+ });
67
+ return [body.message, ...issues].filter(Boolean).join(" ") || fallback;
68
+ }
93
69
 
94
70
  export function initCatalogSettingsPage(root: HTMLElement): void {
95
71
  const found = root.querySelector<HTMLElement>("[data-settings-app]");
@@ -97,265 +73,385 @@ export function initCatalogSettingsPage(root: HTMLElement): void {
97
73
  const previewUrl = root.dataset.preview ?? "";
98
74
  if (!found || !api || !previewUrl) return;
99
75
  const panel: HTMLElement = found;
100
-
101
76
  let state: SettingsEditorState | null = null;
102
77
  let storeCurrency = root.dataset.currency ?? "";
103
78
  let pricingMethods: MethodOption[] = [];
104
79
  let skuMethods: MethodOption[] = [];
105
80
  let history: SettingsResponse["history"] = [];
106
81
  let historyUnavailable = false;
107
- const sample: Record<string, number | ""> = { wholesale: "", msrp: "", minimum: "", maximum: "" };
108
- const sampleText: Record<string, string> = { productKey: "", assortment: "", category: "", brand: "", selectedRuleId: "" };
109
- const status = h("div", { "aria-live": "polite" });
110
- const previewOut = h("div", { class: "preview-out" });
82
+ let activeTab: Section = window.location.hash === "#sku" ? "sku" : "pricing";
83
+ let saving = false;
84
+ let loading = false;
85
+ let feedback = "";
86
+ let feedbackError = false;
87
+ const sample: RecordValue = { wholesale: 40, msrp: 60, minimum: "", maximum: "" };
88
+ const sampleText: RecordValue = { productKey: "", assortment: "", category: "", brand: "", selectedRuleId: "" };
89
+ const openDetails = new Map<string, boolean>();
90
+ const editor = h("div", { class: "settings-editor" });
91
+ const status = h("div", { class: "settings-notices", "aria-live": "polite" });
92
+ const previewOut = h("div", { class: "preview-out", "aria-live": "polite", "aria-atomic": "true" });
93
+ const saveStatus = h("div", { class: "save-status", "aria-live": "polite" });
94
+ const saveButton = h("button", { type: "button", class: "btn primary", "data-save": "true" }, "Save defaults");
95
+ 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
+ const money = (value: number, currency = state?.currency ?? storeCurrency) => {
98
+ try { return new Intl.NumberFormat(undefined, { style: "currency", currency }).format(value); }
99
+ catch { return `${currency} ${value.toFixed(2)}`; }
100
+ };
101
+ const currencyMismatch = () => Boolean(state && storeCurrency && state.currency !== storeCurrency);
102
+ const isConfigured = (section: Section) => state?.draft[section === "pricing" ? "pricingConfigured" : "skuConfigured"] === true;
103
+ const methodTitle = (id: string) => METHOD_TITLES[id] ?? [...pricingMethods, ...skuMethods].find((method) => method.id === id)?.title ?? "Saved method";
111
104
 
112
- function paintStatus(extra?: string): void {
105
+ function paintStatus(): void {
113
106
  if (!state) return;
114
107
  const dirty = draftIsDirty(state);
115
- const revision = state.savedRevision === null ? "Not saved yet" : `Saved revision ${state.savedRevision}`;
116
- const lines: Node[] = [h("p", { class: "muted" }, `${revision} · ${state.currency} · ${dirty ? "Unsaved edits" : "Matches the saved defaults"}`)];
117
- if (storeCurrency && state.currency !== storeCurrency) {
118
- lines.push(h("p", { class: "notice error", role: "alert" }, `Saved defaults are in ${state.currency}. This store is ${storeCurrency}. No currency conversion is applied.`));
119
- }
108
+ const notices: Node[] = [];
109
+ 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.`));
120
110
  if (state.conflict) {
121
- lines.push(h("p", { class: "notice error", role: "alert" }, state.conflict.message));
122
- lines.push(h("button", { type: "button", class: "btn", "data-reload": "true" }, "Load the saved revision"));
111
+ const reload = h("button", { type: "button", class: "btn secondary", disabled: saving || loading, "data-reload": "true" }, "Load saved defaults");
112
+ reload.addEventListener("click", () => void load());
113
+ notices.push(h("div", { class: "notice error", role: "alert" }, h("p", {}, state.conflict.message), reload));
123
114
  }
124
- if (extra) lines.push(h("p", { class: "notice error", role: "alert" }, extra));
125
- status.replaceChildren(...lines);
126
- status.querySelector("[data-reload]")?.addEventListener("click", () => void load());
115
+ if (feedbackError && feedback) notices.push(h("p", { class: "notice error", role: "alert" }, feedback));
116
+ status.replaceChildren(...notices);
117
+ saveStatus.replaceChildren(
118
+ h("strong", { class: dirty ? "is-dirty" : "is-saved" }, saving ? "Saving defaults…" : dirty ? "Unsaved changes" : feedback || "All changes saved"),
119
+ h("span", { class: "muted small" }, state.savedRevision === null ? "Choose a method to set your store defaults." : `Store defaults · Revision ${state.savedRevision}`),
120
+ );
121
+ saveButton.disabled = saving || loading || !dirty || currencyMismatch() || Boolean(state.conflict);
122
+ saveButton.textContent = saving ? "Saving…" : "Save defaults";
123
+ discardButton.disabled = saving || loading || !dirty;
127
124
  }
125
+ function touch(): void { feedback = ""; feedbackError = false; paintStatus(); refreshRuleChoices(); schedulePreview(); }
128
126
 
129
- function select(record: Record<string, unknown>, key: string, label: string, options: Array<{ value: string; label: string }>, onChange: () => void): HTMLElement {
127
+ function select(record: RecordValue, key: string, label: string, options: Array<{ value: string; label: string }>, onChange: () => void = touch, focusKey = key): HTMLElement {
130
128
  const current = fieldString(record, key);
131
- const known = options.some((option) => option.value === current);
132
- const control = h("select", { "aria-label": label }) as HTMLSelectElement;
133
- if (current && !known) control.append(h("option", { value: current }, current));
129
+ const control = h("select", { "data-focus": focusKey });
130
+ if (!options.some((option) => option.value === current)) control.append(h("option", { value: current }, "Saved value — choose an option"));
134
131
  for (const option of options) control.append(h("option", { value: option.value }, option.label));
135
132
  control.value = current;
136
- control.addEventListener("change", () => {
137
- record[key] = control.value;
138
- onChange();
139
- });
140
- return h("label", {}, label, control);
133
+ control.addEventListener("change", () => { record[key] = control.value; onChange(); });
134
+ return h("label", { class: "field" }, h("span", {}, label), control);
141
135
  }
142
-
143
- function numberField(record: Record<string, unknown>, key: string, label: string, onChange: () => void): HTMLElement {
144
- const control = h("input", { type: "text", inputmode: "decimal", "aria-label": label, value: fieldString(record, key) }) as HTMLInputElement;
145
- control.addEventListener("input", () => {
146
- record[key] = numberOrBlank(control.value);
147
- onChange();
148
- });
149
- return h("label", {}, label, control);
136
+ function numberField(record: RecordValue, key: string, label: string, onChange: () => void = touch, hint = "", focusKey = key): HTMLElement {
137
+ const control = h("input", { type: "text", inputmode: "decimal", "data-focus": focusKey, value: fieldString(record, key), autocomplete: "off" });
138
+ control.addEventListener("input", () => { record[key] = parseSettingsNumber(control.value); onChange(); });
139
+ return h("label", { class: "field" }, h("span", {}, label), control, hint ? h("small", {}, hint) : null);
150
140
  }
151
-
152
- function textField(record: Record<string, unknown>, key: string, label: string, onChange: () => void, attrs: Record<string, string> = {}): HTMLElement {
153
- const control = h("input", { type: "text", "aria-label": label, value: fieldString(record, key), ...attrs }) as HTMLInputElement;
141
+ function textField(record: RecordValue, key: string, label: string, onChange: () => void = touch, attrs: Record<string, string> = {}, focusKey = key): HTMLElement {
142
+ const control = h("input", { type: "text", "data-focus": focusKey, value: fieldString(record, key), autocomplete: "off", ...attrs });
154
143
  control.addEventListener("input", () => {
155
- record[key] = control.value;
144
+ record[key] = key === "prefix" ? control.value.toUpperCase() : control.value;
145
+ if (key === "prefix") control.value = String(record[key]);
156
146
  onChange();
157
147
  });
158
- return h("label", {}, label, control);
148
+ return h("label", { class: "field" }, h("span", {}, label), control);
159
149
  }
160
-
161
- function flag(record: Record<string, unknown>, key: string, label: string, onChange: () => void): HTMLElement {
162
- const control = h("input", { type: "checkbox" }) as HTMLInputElement;
150
+ function flag(record: RecordValue, key: string, label: string): HTMLElement {
151
+ const control = h("input", { type: "checkbox", "data-focus": key });
163
152
  control.checked = record[key] === true;
164
- control.addEventListener("change", () => {
165
- record[key] = control.checked;
166
- onChange();
167
- });
168
- return h("label", { class: "inline" }, control, ` ${label}`);
153
+ control.addEventListener("change", () => { record[key] = control.checked; touch(); });
154
+ return h("label", { class: "check-field" }, control, h("span", {}, label));
169
155
  }
170
-
171
- function configured(key: "pricingConfigured" | "skuConfigured", label: string): HTMLElement {
172
- const control = h("input", { type: "checkbox" }) as HTMLInputElement;
173
- control.checked = state?.draft[key] === true;
174
- control.addEventListener("change", () => {
156
+ function disclosure(key: string, title: string, hint: string, children: Child[], initiallyOpen = false): HTMLDetailsElement {
157
+ const details = h("details", { class: "disclosure", "data-disclosure": key },
158
+ h("summary", {}, h("span", { class: "disclosure-title" }, title), h("span", { class: "disclosure-hint" }, hint)), h("div", { class: "disclosure-body" }, ...children));
159
+ details.open = openDetails.get(key) ?? initiallyOpen;
160
+ return details;
161
+ }
162
+ function parameter(record: RecordValue, prefix = ""): HTMLElement | null {
163
+ const field = METHOD_FIELDS[fieldString(record, "method")];
164
+ return field ? numberField(record, field[0], field[1], touch, field[2], `${prefix}${field[0]}`) : null;
165
+ }
166
+ function examples(section: Section): Record<string, string> {
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 {
173
+ if (!state) return h("div");
174
+ const record = state.draft[section];
175
+ const configured = isConfigured(section);
176
+ const all = section === "pricing" ? pricingMethods : skuMethods;
177
+ const commonIds = section === "pricing" ? ["msrp", "margin", "markup"] : ["sequential", "family", "hybrid"];
178
+ const common = all.filter((method) => commonIds.includes(method.id));
179
+ const more = all.filter((method) => !commonIds.includes(method.id));
180
+ const sampleExamples = examples(section);
181
+ 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.", sequential: "A dependable format for any catalog.", family: "Keep related variants easy to recognize.", hybrid: "A readable prefix with a simple number.",
183
+ };
184
+ const card = (method: MethodOption) => {
185
+ const selected = configured && record.method === method.id;
186
+ const radio = h("input", { type: "radio", name: `${section}-method`, value: method.id, "data-focus": `${section}-${method.id}` });
187
+ radio.checked = selected;
188
+ radio.addEventListener("change", () => { if (state) { selectDraftMethod(state, section, method.id); touch(); renderEditor(); } });
189
+ return h("label", { class: `method-card${selected ? " selected" : ""}` }, radio,
190
+ 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
+ };
192
+ const manual = h("button", { type: "button", class: "text-button", "data-focus": `${section}-manual`, "aria-pressed": String(!configured) }, section === "pricing" ? "Use manual prices instead" : "Enter SKUs manually instead");
193
+ manual.addEventListener("click", () => {
175
194
  if (!state) return;
176
- state.draft[key] = control.checked;
177
- paintStatus();
195
+ state.draft[section === "pricing" ? "pricingConfigured" : "skuConfigured"] = false;
196
+ touch(); renderEditor();
178
197
  });
179
- return h("label", { class: "inline" }, control, ` ${label}`);
198
+ return h("section", { class: "editor-panel method-panel", "aria-labelledby": `${section}-method-heading` },
199
+ h("div", { class: "section-heading" }, h("span", { class: "step-number", "aria-hidden": "true" }, "1"), h("h2", { id: `${section}-method-heading` }, section === "pricing" ? "Choose your pricing method" : "Choose your SKU format")),
200
+ h("p", { class: "section-intro" }, section === "pricing" ? "One starting point for every new product." : "An item code that works for the way you sell."),
201
+ !configured ? h("p", { class: "manual-note" }, section === "pricing" ? "Manual pricing is on. Choose a method to set a default." : "SKUs are entered manually. Choose a format to set a default.") : null,
202
+ h("div", { class: "method-grid" }, ...common.map(card)),
203
+ disclosure(`${section}-more`, "More methods", section === "pricing" ? "Adjust MSRP or add a fixed amount" : "Attributes, suppliers and generated codes", [h("div", { class: "method-grid more-grid" }, ...more.map(card))], configured && more.some((method) => method.id === record.method)),
204
+ configured && section === "pricing" && METHOD_FIELDS[String(record.method)] ? h("div", { class: "method-parameter" }, parameter(record)) : null,
205
+ configured ? manual : null);
180
206
  }
181
207
 
182
- function ruleRows(list: Record<string, unknown>[], kind: "exception" | "tier" | "override", onChange: () => void): HTMLElement {
183
- const rows = list.map((rule, index) => {
184
- const remove = h("button", { type: "button", class: "btn small" }, "Remove");
185
- remove.addEventListener("click", () => {
186
- list.splice(index, 1);
187
- renderForm();
188
- });
189
- const methodOptions = (kind === "tier" ? ["margin", "markup", "fixed"] : ["msrp", "margin", "markup", "msrp-adjustment", "fixed"])
190
- .map((id) => ({ value: id, label: id }));
191
- return h(
192
- "div",
193
- { class: "rule" },
194
- textField(rule, "id", "Id", onChange),
195
- kind !== "override" ? null : textField(rule, "productKey", "Product", onChange),
196
- kind === "override" ? numberField(rule, "price", "Price", onChange) : null,
197
- kind === "exception" ? textField(rule, "name", "Name", onChange) : null,
198
- kind === "exception" ? select(rule, "scope", "Scope", ["assortment", "category", "brand"].map((id) => ({ value: id, label: id })), onChange) : null,
199
- kind === "exception" ? textField(rule, "match", "Match", onChange) : null,
200
- kind === "tier" ? numberField(rule, "minimumCost", "Minimum cost", onChange) : null,
201
- kind === "tier" ? numberField(rule, "maximumCost", "Maximum cost", onChange) : null,
202
- kind === "override" ? null : select(rule, "method", "Method", methodOptions, onChange),
203
- kind === "override" ? null : h("div", { class: "grid" }, ...RULE_NUMBERS.filter(([key]) => kind === "exception" || key !== "msrpAdjustment").map(([key, label]) => numberField(rule, key, label, onChange))),
204
- remove,
205
- );
206
- });
207
- return h("div", { class: "rules" }, ...rows);
208
+ function ruleRows(list: RecordValue[], kind: "exception" | "tier" | "override"): HTMLElement {
209
+ return h("div", { class: "rules" }, ...list.map((rule, index) => {
210
+ const prefix = `${kind}-${String(rule.id)}-`;
211
+ const remove = h("button", { type: "button", class: "text-button danger", "aria-label": `Remove ${kind === "exception" ? "rule" : kind === "tier" ? "cost tier" : "product price"} ${index + 1}` }, "Remove");
212
+ remove.addEventListener("click", () => { list.splice(index, 1); touch(); renderEditor(); });
213
+ const methods = (kind === "tier" ? ["margin", "markup", "fixed"] : ["msrp", "margin", "markup", "msrp-adjustment", "fixed"]).map((id) => ({ value: id, label: methodTitle(id) }));
214
+ return h("div", { class: "rule" }, h("div", { class: "rule-heading" }, h("strong", {}, `${kind === "exception" ? "Rule" : kind === "tier" ? "Cost tier" : "Product price"} ${index + 1}`), remove),
215
+ h("div", { class: "fields-grid" },
216
+ kind === "exception" ? textField(rule, "name", "Rule name", touch, {}, `${prefix}name`) : null,
217
+ kind === "exception" ? select(rule, "scope", "Match by", ["assortment", "category", "brand"].map((value) => ({ value, label: value.charAt(0).toUpperCase() + value.slice(1) })), touch, `${prefix}scope`) : null,
218
+ kind === "exception" ? textField(rule, "match", "Matching value", touch, {}, `${prefix}match`) : null,
219
+ kind === "tier" ? numberField(rule, "minimumCost", "Cost from", touch, "Inclusive", `${prefix}minimumCost`) : null,
220
+ kind === "tier" ? numberField(rule, "maximumCost", "Cost below", touch, "Leave blank for no upper limit.", `${prefix}maximumCost`) : null,
221
+ kind === "override" ? textField(rule, "productKey", "Product ID or SKU", touch, {}, `${prefix}productKey`) : null,
222
+ kind === "override" ? numberField(rule, "price", "Selling price", touch, "An exact price for this product.", `${prefix}price`) : null,
223
+ kind !== "override" ? select(rule, "method", "Pricing method", methods, () => { if (rule.method === "margin" && rule.targetMargin === "") rule.targetMargin = 40; touch(); renderEditor(); }, `${prefix}method`) : null,
224
+ kind !== "override" ? parameter(rule, prefix) : null));
225
+ }));
208
226
  }
209
-
210
- function renderForm(): void {
211
- if (!state) return;
227
+ function pricingControls(): HTMLElement[] {
228
+ if (!state || !isConfigured("pricing")) return [];
212
229
  const pricing = state.draft.pricing;
213
- const sku = state.draft.sku;
214
- const touch = () => paintStatus();
230
+ const repaint = () => { touch(); renderEditor(); };
231
+ const priceFormat = h("section", { class: "editor-panel" },
232
+ h("div", { class: "section-heading" }, h("span", { class: "step-number", "aria-hidden": "true" }, "2"), h("h2", {}, "Give prices a consistent finish")),
233
+ h("div", { class: "fields-grid" },
234
+ select(pricing, "rounding", "Price format", [{ value: "none", label: "Exact calculated price" }, { value: "ending", label: "Use a price ending" }, { value: "increment", label: "Round to an increment" }], repaint),
235
+ pricing.rounding === "ending" ? textField(pricing, "ending", "Ending (two digits)", touch, { maxlength: "2", inputmode: "numeric" }) : null,
236
+ pricing.rounding === "increment" ? numberField(pricing, "increment", "Rounding increment", touch, `For example, ${money(1)} or ${money(0.5)}.`) : null,
237
+ pricing.rounding !== "none" ? select(pricing, "direction", "Round direction", [{ value: "up", label: "Up" }, { value: "nearest", label: "Nearest" }, { value: "down", label: "Down" }]) : null),
238
+ pricing.method === "msrp" ? flag(pricing, "preserveMsrp", "Keep supplier MSRP exact (skip price formatting)") : null);
239
+ const fallbackField = METHOD_FIELDS[String(pricing.fallbackMethod)];
240
+ const advanced = disclosure("pricing-advanced", "Advanced pricing", "Costs, limits and missing data", [
241
+ h("h3", {}, "Product cost"),
242
+ h("div", { class: "fields-grid" },
243
+ select(pricing, "costBasis", "Cost basis", [{ value: "wholesale", label: "Wholesale purchase cost" }, { value: "adjusted", label: "Wholesale + additional costs" }], repaint),
244
+ pricing.costBasis === "adjusted" ? numberField(pricing, "additionalCost", "Additional cost per item") : null,
245
+ pricing.costBasis === "adjusted" ? numberField(pricing, "additionalCostPercent", "Additional cost (% of wholesale)") : null),
246
+ h("p", { class: "hint" }, "Include freight, duties or handling. Both adjustments are added to wholesale cost."),
247
+ h("h3", {}, "Price limits"),
248
+ h("div", { class: "fields-grid" }, numberField(pricing, "minimumPrice", "Minimum price", touch, "Optional"), numberField(pricing, "maximumPrice", "Maximum price", touch, "Optional"), numberField(pricing, "minimumMargin", "Minimum gross margin (%)", touch, "Optional")),
249
+ flag(pricing, "capAtMsrp", "Flag prices above supplier MSRP"),
250
+ pricing.method !== "msrp" ? flag(pricing, "preserveMsrp", "Keep supplier MSRP exact when used") : null,
251
+ h("h3", {}, "When a price source is missing"),
252
+ select(pricing, "missingData", "Missing cost or MSRP", [{ value: "review", label: "Flag the product for review" }, { value: "fallback", label: "Use a fallback method" }], () => { if (pricing.missingData === "fallback" && pricing.fallbackMethod === "margin" && pricing.targetMargin === "") pricing.targetMargin = 40; repaint(); }),
253
+ pricing.missingData === "fallback" ? h("div", { class: "fields-grid nested-fields" },
254
+ select(pricing, "fallbackMethod", "Fallback pricing method", ["msrp", "margin", "markup", "fixed"].map((value) => ({ value, label: methodTitle(value) })), () => { if (pricing.fallbackMethod === "margin" && pricing.targetMargin === "") pricing.targetMargin = 40; repaint(); }),
255
+ fallbackField ? numberField(pricing, fallbackField[0], `Fallback ${fallbackField[1].toLowerCase()}`, touch, "Choose a fallback that uses a different price source.", `fallback-${fallbackField[0]}`) : null) : null,
256
+ h("h3", {}, "Update preference"),
257
+ select(pricing, "updatePolicy", "Apply pricing defaults", [{ value: "on-add", label: "When products are added" }, { value: "manual", label: "Only on request" }, { value: "automatic", label: "When costs change" }]),
258
+ h("p", { class: "hint" }, "Saved listings are not automatically repriced by these defaults."),
259
+ flag(pricing, "includeManualOverrides", "Include manually priced products in the update preference"),
260
+ ]);
215
261
  const exceptions = Array.isArray(pricing.exceptions) ? pricing.exceptions.map(asRecord) : [];
216
262
  const tiers = Array.isArray(pricing.costTiers) ? pricing.costTiers.map(asRecord) : [];
217
263
  const overrides = Array.isArray(pricing.productOverrides) ? pricing.productOverrides.map(asRecord) : [];
218
- pricing.exceptions = exceptions;
219
- pricing.costTiers = tiers;
220
- pricing.productOverrides = overrides;
221
-
222
- const add = (list: Record<string, unknown>[], blank: Record<string, unknown>, label: string) => {
223
- const button = h("button", { type: "button", class: "btn small" }, label);
224
- button.addEventListener("click", () => {
225
- list.push({ ...blank, id: `${label.slice(0, 3).toLowerCase()}-${list.length + 1}` });
226
- renderForm();
227
- });
264
+ pricing.exceptions = exceptions; pricing.costTiers = tiers; pricing.productOverrides = overrides;
265
+ const add = (list: RecordValue[], blank: RecordValue, label: string) => {
266
+ const button = h("button", { type: "button", class: "btn secondary small" }, label);
267
+ button.addEventListener("click", () => { list.push({ ...blank, id: crypto.randomUUID() }); touch(); renderEditor(); });
228
268
  return button;
229
269
  };
230
-
231
- panel.replaceChildren(
232
- status,
233
- h("section", { class: "panel" },
234
- h("h2", {}, "Pricing"),
235
- configured("pricingConfigured", "Use these pricing defaults"),
236
- h("div", { class: "grid" },
237
- select(pricing, "method", "Method", pricingMethods.map((method) => ({ value: method.id, label: method.title })), touch),
238
- ...PRICING_NUMBERS.map(([key, label]) => numberField(pricing, key, label, touch)),
239
- ...PRICING_SELECTS.map(([key, label, values]) => select(pricing, key, label, values.map((value) => ({ value, label: value })), touch)),
240
- textField(pricing, "ending", "Price ending", touch, { maxlength: "2" }),
241
- ),
242
- h("div", { class: "flags" }, ...PRICING_FLAGS.map(([key, label]) => flag(pricing, key, label, touch))),
243
- h("h3", {}, "Exceptions"),
244
- ruleRows(exceptions, "exception", touch),
245
- add(exceptions, { name: "Exception", scope: "brand", match: "", method: "margin", targetMargin: 40, markup: "", msrpAdjustment: "", fixedMarkup: "" }, "Add exception"),
246
- h("h3", {}, "Cost tiers"),
247
- ruleRows(tiers, "tier", touch),
248
- add(tiers, { minimumCost: 0, maximumCost: "", method: "margin", targetMargin: 40, markup: "", fixedMarkup: "" }, "Add cost tier"),
249
- h("h3", {}, "Product price overrides"),
250
- ruleRows(overrides, "override", touch),
251
- add(overrides, { productKey: "", price: 0 }, "Add product override"),
252
- ),
253
- h("section", { class: "panel" },
254
- h("h2", {}, "SKUs"),
255
- configured("skuConfigured", "Use these SKU defaults"),
256
- h("div", { class: "grid" },
257
- select(sku, "method", "Method", skuMethods.map((method) => ({ value: method.id, label: method.title })), touch),
258
- textField(sku, "prefix", "Prefix", touch, { maxlength: "12" }),
259
- numberField(sku, "startingNumber", "Starting number", touch),
260
- select(sku, "separator", "Separator", [{ value: "-", label: "Hyphen" }, { value: "_", label: "Underscore" }, { value: "", label: "None" }], touch),
261
- ),
262
- ),
263
- h("section", { class: "panel" },
264
- h("h2", {}, "Preview a sample"),
265
- h("p", { class: "muted small" }, "The server calculates this sample with the open draft. Saving defaults does not change prices or SKUs already on your products."),
266
- h("div", { class: "grid" },
267
- ...(["wholesale", "msrp", "minimum", "maximum"] as const).map((key) => {
268
- const control = h("input", { type: "text", inputmode: "decimal", "aria-label": key, value: String(sample[key] ?? "") }) as HTMLInputElement;
269
- control.addEventListener("input", () => { sample[key] = numberOrBlank(control.value); });
270
- return h("label", {}, key, control);
271
- }),
272
- ...(["productKey", "assortment", "category", "brand", "selectedRuleId"] as const).map((key) => {
273
- const control = h("input", { type: "text", "aria-label": key, value: sampleText[key] ?? "" }) as HTMLInputElement;
274
- control.addEventListener("input", () => { sampleText[key] = control.value; });
275
- return h("label", {}, key, control);
276
- }),
277
- ),
278
- h("button", { type: "button", class: "btn", "data-preview": "true" }, "Calculate sample"),
279
- previewOut,
280
- ),
281
- h("section", { class: "panel" },
282
- h("h2", {}, "Saved revisions"),
283
- historyUnavailable ? h("p", { class: "muted" }, "Saved history is unavailable.") : null,
284
- history.length === 0 && !historyUnavailable ? h("p", { class: "muted" }, "No saved revision yet.") : null,
285
- ...history.map((entry) => h("p", { class: "small" }, `Revision ${entry.revision} · ${entry.savedAt}`)),
286
- ),
287
- h("div", { class: "row" },
288
- h("button", { type: "button", class: "btn primary", "data-save": "true" }, "Save defaults"),
289
- ),
290
- );
291
- panel.querySelector("[data-preview]")?.addEventListener("click", () => void preview());
292
- panel.querySelector("[data-save]")?.addEventListener("click", () => void save());
293
- paintStatus();
270
+ const rules = disclosure("pricing-rules", "Rules & product exceptions", "Customize prices for part of your catalog", [
271
+ h("h3", {}, "Assortment, category or brand rules"), h("p", { class: "hint" }, "Use a different method for matching products. Matching rules are selected individually, never combined."), ruleRows(exceptions, "exception"),
272
+ add(exceptions, { name: "New rule", scope: "brand", match: "", method: "margin", targetMargin: 40, markup: "", msrpAdjustment: "", fixedMarkup: "" }, "Add rule"),
273
+ h("h3", {}, "Cost tiers"), h("p", { class: "hint" }, "Apply different cost-based pricing to different cost ranges."), ruleRows(tiers, "tier"),
274
+ add(tiers, { minimumCost: 0, maximumCost: "", method: "margin", targetMargin: 40, markup: "", fixedMarkup: "" }, "Add cost tier"),
275
+ h("h3", {}, "Product prices"), ruleRows(overrides, "override"), add(overrides, { productKey: "", price: 0 }, "Add product price"),
276
+ ]);
277
+ return [priceFormat, advanced, rules];
294
278
  }
295
-
296
- async function load(): Promise<void> {
297
- const response = await fetch(api, { headers: { accept: "application/json" } });
298
- const body = await response.json() as SettingsResponse & { message?: string };
299
- if (!response.ok || !body.ok) {
300
- panel.replaceChildren(h("p", { class: "notice error", role: "alert" }, body.message || "Pricing and SKU defaults could not be loaded."));
301
- return;
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
+ function savedHistory(): HTMLElement {
299
+ const entries: Node[] = [];
300
+ if (historyUnavailable) entries.push(h("p", { class: "hint" }, "Saved history is unavailable."));
301
+ if (!history.length && state?.savedRevision === null && !historyUnavailable) entries.push(h("p", { class: "hint" }, "Your first save will appear here."));
302
+ if (state?.savedRevision !== null && state?.savedRevision !== undefined && !history.some((entry) => entry.revision === state?.savedRevision)) entries.push(h("p", { class: "history-entry" }, `Revision ${state.savedRevision} · Saved in this session`));
303
+ for (const entry of history) {
304
+ const date = new Date(entry.savedAt);
305
+ entries.push(h("p", { class: "history-entry" }, `Revision ${entry.revision} · ${Number.isNaN(date.getTime()) ? entry.savedAt : date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })}`));
302
306
  }
303
- pricingMethods = body.pricingMethods;
304
- skuMethods = body.skuMethods;
305
- history = body.history;
306
- historyUnavailable = body.historyUnavailable;
307
- state = editorFromPayload(body);
308
- if (typeof body.storeCurrency === "string") storeCurrency = body.storeCurrency;
309
- renderForm();
307
+ return disclosure("history", "Saved history", state?.savedRevision === null ? "No saved defaults yet" : `Current revision ${state?.savedRevision}`, entries);
310
308
  }
311
-
312
- async function save(): Promise<void> {
309
+ function renderEditor(): void {
313
310
  if (!state) return;
314
- const current = state;
315
- if (storeCurrency && current.currency !== storeCurrency) {
316
- paintStatus(`Saved defaults are in ${current.currency}. This store is ${storeCurrency}. No currency conversion is applied.`);
317
- return;
318
- }
319
- const response = await fetch(api, { method: "PUT", headers: { "content-type": "application/json", accept: "application/json" }, body: JSON.stringify(saveBody(current)) });
320
- const body = await response.json() as { ok: boolean; code?: string; revision?: number | null; message?: string; issues?: string[]; pricing?: SettingsPayload["pricing"]; sku?: SettingsPayload["sku"] };
321
- if (body.code === "revision_conflict") {
322
- state = applySaveResult(current, { ok: false, code: "revision_conflict", revision: typeof body.revision === "number" ? body.revision : null, message: body.message || "These defaults changed. Your edits are still here." });
323
- paintStatus();
324
- return;
311
+ for (const details of editor.querySelectorAll<HTMLDetailsElement>("[data-disclosure]")) openDetails.set(details.dataset.disclosure ?? "", details.open);
312
+ const focused = document.activeElement as HTMLInputElement | null;
313
+ const focusKey = focused && editor.contains(focused) ? focused.dataset.focus : undefined;
314
+ const selection: [number | null, number | null] | null = focused?.tagName === "INPUT" && ["text", "search"].includes(focused.type) ? [focused.selectionStart, focused.selectionEnd] : null;
315
+ editor.replaceChildren(
316
+ h("div", { id: "settings-pricing-panel", role: "tabpanel", "aria-labelledby": "settings-pricing-tab", hidden: activeTab !== "pricing" }, methodPicker("pricing"), ...pricingControls()),
317
+ h("div", { id: "settings-sku-panel", role: "tabpanel", "aria-labelledby": "settings-sku-tab", hidden: activeTab !== "sku" }, methodPicker("sku"), ...skuControls()),
318
+ h("p", { class: "preserve-note" }, h("span", { "aria-hidden": "true" }, "✓"), " Saved listings keep their prices and SKUs. These defaults never overwrite them."), savedHistory());
319
+ if (focusKey) {
320
+ const control = Array.from(editor.querySelectorAll<HTMLElement>("[data-focus]")).find((node) => node.dataset.focus === focusKey);
321
+ control?.focus({ preventScroll: true });
322
+ if (selection && control instanceof HTMLInputElement) control.setSelectionRange(selection[0], selection[1]);
323
+ if (!control && focusKey.endsWith("-manual")) editor.querySelector<HTMLInputElement>(`input[name="${activeTab}-method"]`)?.focus({ preventScroll: true });
325
324
  }
326
- if (!response.ok || !body.ok || typeof body.revision !== "number" || !body.pricing || !body.sku) {
327
- paintStatus([body.message, ...(body.issues ?? [])].filter(Boolean).join(" "));
328
- return;
325
+ refreshRuleChoices();
326
+ }
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();
329
334
  }
330
- state = applySaveResult(current, { ok: true, revision: body.revision, pricing: body.pricing, sku: body.sku });
331
- renderForm();
335
+ for (const id of ["pricing", "sku"]) { const pane = editor.querySelector<HTMLElement>(`#settings-${id}-panel`); if (pane) pane.hidden = id !== section; }
332
336
  }
333
-
334
- async function preview(): Promise<void> {
335
- if (!state) return;
336
- const current = state;
337
- const draft = saveBody(current);
338
- delete draft.expectedRevision;
339
- const response = await fetch(previewUrl, {
340
- method: "POST",
341
- headers: { "content-type": "application/json", accept: "application/json" },
342
- body: JSON.stringify({ ...draft, sample: { ...sample, ...Object.fromEntries(Object.entries(sampleText).filter(([, value]) => value !== "")) } }),
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
+ }
343
344
  });
344
- const body = await response.json() as { ok?: boolean; message?: string; issues?: string[]; pricing?: { status: string; preview?: { price: number | null; calculation: string; issues: string[]; ruleExplanation: string } }; sku?: { status: string; value?: string } };
345
- previewOut.replaceChildren();
346
- if (!response.ok || !body.ok) {
347
- previewOut.append(h("p", { class: "notice error" }, [body.message, ...(body.issues ?? [])].filter(Boolean).join(" ")));
348
- return;
349
- }
350
- if (body.pricing?.status === "ok" && body.pricing.preview) {
351
- const calculated = body.pricing.preview;
352
- previewOut.append(h("p", {}, calculated.price === null ? "No price" : `${current.currency} ${calculated.price.toFixed(2)}`));
353
- if (calculated.calculation) previewOut.append(h("p", { class: "muted small" }, calculated.calculation));
354
- if (calculated.ruleExplanation) previewOut.append(h("p", { class: "muted small" }, calculated.ruleExplanation));
355
- for (const issue of calculated.issues) previewOut.append(h("p", { class: "notice error" }, issue));
356
- } else previewOut.append(h("p", { class: "muted" }, "Pricing defaults are off for this draft."));
357
- previewOut.append(h("p", {}, body.sku?.status === "ok" ? `SKU ${body.sku.value ?? ""}` : "SKU defaults are off for this draft."));
345
+ tabs.append(tab);
358
346
  }
347
+ window.addEventListener("hashchange", () => setTab(window.location.hash === "#sku" ? "sku" : "pricing"));
348
+ const ruleSelect = select(sampleText, "selectedRuleId", "Matching rule", [{ value: "", label: "Automatically match" }, { value: "store", label: "Use store default" }], schedulePreview, "sample-selectedRuleId");
349
+ function refreshRuleChoices(): void {
350
+ const control = ruleSelect.querySelector("select");
351
+ if (!control || !state) return;
352
+ const value = fieldString(sampleText, "selectedRuleId");
353
+ const exceptions = Array.isArray(state.draft.pricing.exceptions) ? state.draft.pricing.exceptions.map(asRecord) : [];
354
+ control.replaceChildren(h("option", { value: "" }, "Automatically match"), h("option", { value: "store" }, "Use store default"), ...exceptions.map((rule, index) => h("option", { value: String(rule.id) }, fieldString(rule, "name") || `Rule ${index + 1}`)));
355
+ if (value && value !== "store" && !exceptions.some((rule) => rule.id === value)) control.append(h("option", { value }, "Previously selected rule"));
356
+ control.value = value;
357
+ }
358
+ const previewPanel = h("aside", { class: "preview-panel", "aria-labelledby": "preview-title" },
359
+ h("div", { class: "preview-heading" }, h("span", { class: "eyebrow" }, "Live preview"), h("span", { class: "currency-badge" }, storeCurrency)),
360
+ h("h2", { id: "preview-title" }, "Example product"), h("p", { class: "hint" }, "Try your defaults before you save."), previewOut,
361
+ h("div", { class: "fields-grid sample-fields" }, numberField(sample, "wholesale", "Wholesale cost", schedulePreview, "", "sample-wholesale"), numberField(sample, "msrp", "Supplier MSRP", schedulePreview, "", "sample-msrp")),
362
+ disclosure("sample-context", "Try rules & limits", "Optional product details", [
363
+ h("div", { class: "fields-grid" }, numberField(sample, "minimum", "Product minimum price", schedulePreview, "", "sample-minimum"), numberField(sample, "maximum", "Product maximum price", schedulePreview, "", "sample-maximum")),
364
+ textField(sampleText, "productKey", "Product ID or SKU", schedulePreview, {}, "sample-productKey"),
365
+ ...["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, not a catalog change. The SKU shown has not been reserved."));
359
367
 
368
+ function paintPreview(result: PreviewStatus<PreviewResponse>): void {
369
+ previewOut.setAttribute("aria-busy", String(result.status === "waiting" || result.status === "loading"));
370
+ if (result.status === "waiting" || result.status === "loading") {
371
+ previewOut.replaceChildren(h("div", { class: "preview-result pending" }, h("span", { class: "result-label" }, "Selling price"), h("strong", { class: "sample-price" }, "—"), h("p", { class: "hint" }, "Updating sample…"))); return;
372
+ }
373
+ if (result.status === "error") {
374
+ const retry = h("button", { type: "button", class: "btn secondary small" }, "Try again");
375
+ retry.addEventListener("click", schedulePreview);
376
+ previewOut.replaceChildren(h("div", { class: "preview-result unavailable" }, h("strong", {}, "Preview unavailable"), h("p", { class: "hint" }, result.message), retry)); return;
377
+ }
378
+ const body = result.value;
379
+ const calculated = body.pricing?.status === "ok" ? body.pricing.preview : undefined;
380
+ const metrics = h("dl", { class: "preview-metrics" });
381
+ if (calculated) {
382
+ const values: Array<[string, string]> = [["Product cost", calculated.cost === null ? "—" : money(calculated.cost, body.currency)], ["Gross profit", calculated.profit === null ? "—" : money(calculated.profit, body.currency)], ["Gross margin", calculated.margin === null ? "—" : `${calculated.margin.toFixed(1)}%`]];
383
+ for (const [label, value] of values) metrics.append(h("div", {}, h("dt", {}, label), h("dd", {}, value)));
384
+ }
385
+ previewOut.replaceChildren(
386
+ h("div", { class: "preview-result" }, h("span", { class: "result-label" }, "Selling price"),
387
+ 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"))),
390
+ ...(calculated?.calculation ? [h("p", { class: "calculation hint" }, calculated.calculation)] : []),
391
+ ...(calculated?.ruleExplanation && calculated.ruleExplanation !== "Store default" ? [h("p", { class: "hint applied-rule" }, calculated.ruleExplanation)] : []),
392
+ ...(calculated?.issues ?? []).map((issue) => h("p", { class: "notice review-note" }, issue)));
393
+ }
394
+ const preview = latestPreview<RecordValue, PreviewResponse>(async (draft, signal) => {
395
+ const response = await fetch(previewUrl, { method: "POST", headers: { "content-type": "application/json", accept: "application/json" }, body: JSON.stringify(draft), signal });
396
+ let body: PreviewResponse;
397
+ try { body = await response.json() as PreviewResponse; } catch { throw new Error("The server could not return a sample. Try again."); }
398
+ if (!response.ok || !body.ok) throw new Error(responseMessage(body, "The sample could not be calculated. Check your settings and try again."));
399
+ return body;
400
+ }, paintPreview);
401
+ function schedulePreview(): void {
402
+ if (!state) return;
403
+ if (currencyMismatch()) { preview.cancel(); paintPreview({ status: "error", message: "Saved defaults and this store use different currencies. No conversion is applied." }); return; }
404
+ const draft = structuredClone(saveBody(state)); delete draft.expectedRevision;
405
+ preview.schedule({ ...draft, sample: { ...sample, ...Object.fromEntries(Object.entries(sampleText).filter(([, value]) => value !== "")) } });
406
+ }
407
+ function mount(): void {
408
+ panel.replaceChildren(tabs, 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))));
409
+ renderEditor(); setTab(activeTab); paintStatus(); schedulePreview();
410
+ }
411
+ async function load(): Promise<void> {
412
+ if (saving || loading) return;
413
+ loading = true; editor.inert = true; paintStatus();
414
+ if (!state) panel.replaceChildren(h("p", { class: "loading-note", role: "status" }, "Loading your store defaults…"));
415
+ try {
416
+ const response = await fetch(api, { headers: { accept: "application/json" } });
417
+ const body = await response.json() as SettingsResponse & { message?: string };
418
+ if (!response.ok || !body.ok) throw new Error(body.message || "Your defaults could not be loaded. Try again.");
419
+ pricingMethods = body.pricingMethods; skuMethods = body.skuMethods; history = body.history; historyUnavailable = body.historyUnavailable;
420
+ state = editorFromPayload(body);
421
+ if (typeof body.storeCurrency === "string") storeCurrency = body.storeCurrency;
422
+ const badge = previewPanel.querySelector(".currency-badge"); if (badge) badge.textContent = state.currency;
423
+ feedback = ""; feedbackError = false; loading = false; mount();
424
+ } catch {
425
+ feedback = "Your defaults could not be loaded. Check your connection and try again."; feedbackError = true;
426
+ if (state) paintStatus();
427
+ else {
428
+ const retry = h("button", { type: "button", class: "btn secondary" }, "Try again"); retry.addEventListener("click", () => void load());
429
+ panel.replaceChildren(h("div", { class: "notice error", role: "alert" }, h("p", {}, feedback), retry));
430
+ }
431
+ } finally { loading = false; editor.inert = false; paintStatus(); }
432
+ }
433
+ async function save(): Promise<void> {
434
+ if (!state || saving || loading || currencyMismatch() || state.conflict || !draftIsDirty(state)) return;
435
+ const submitted = structuredClone(state);
436
+ saving = true; feedback = ""; feedbackError = false; paintStatus();
437
+ try {
438
+ const response = await fetch(api, { method: "PUT", headers: { "content-type": "application/json", accept: "application/json" }, body: JSON.stringify(saveBody(submitted)) });
439
+ const body = await response.json() as { ok: boolean; code?: string; revision?: number | null; message?: string; issues?: string[]; pricing?: SettingsPayload["pricing"]; sku?: SettingsPayload["sku"] };
440
+ if (body.code === "revision_conflict") {
441
+ state = applySaveAfterEdits(state, submitted, { ok: false, code: "revision_conflict", revision: typeof body.revision === "number" ? body.revision : null, message: body.message || "These defaults were saved by someone else. Your edits are still here. Load the saved defaults to continue." }); return;
442
+ }
443
+ if (!response.ok || !body.ok || typeof body.revision !== "number" || !body.pricing || !body.sku) throw new Error(responseMessage(body, "Your defaults could not be saved. Your edits are still here."));
444
+ state = applySaveAfterEdits(state, submitted, { ok: true, revision: body.revision, pricing: body.pricing, sku: body.sku });
445
+ feedback = draftIsDirty(state) ? "Saved. Your newer edits are still unsaved." : "Defaults saved";
446
+ renderEditor(); schedulePreview();
447
+ } catch (error) { feedback = error instanceof Error ? error.message : "Your defaults could not be saved. Your edits are still here."; feedbackError = true; }
448
+ finally { saving = false; paintStatus(); }
449
+ }
450
+ saveButton.addEventListener("click", () => void save());
451
+ discardButton.addEventListener("click", () => {
452
+ if (!state?.saved || saving || loading) return;
453
+ state.draft = structuredClone(state.saved); feedback = ""; feedbackError = false;
454
+ renderEditor(); paintStatus(); schedulePreview();
455
+ });
360
456
  void load();
361
457
  }
@@ -0,0 +1,37 @@
1
+ /** Immediately invalidate a sample, debounce requests, and admit only the latest result. */
2
+ export type PreviewStatus<T> =
3
+ | { status: "waiting" }
4
+ | { status: "loading" }
5
+ | { status: "ready"; value: T }
6
+ | { status: "error"; message: string };
7
+
8
+ export function latestPreview<Input, Output>(
9
+ request: (input: Input, signal: AbortSignal) => Promise<Output>,
10
+ publish: (state: PreviewStatus<Output>) => void,
11
+ delay = 250,
12
+ ): { schedule: (input: Input) => void; cancel: () => void } {
13
+ let generation = 0;
14
+ let timer: ReturnType<typeof setTimeout> | undefined;
15
+ let active: AbortController | undefined;
16
+ function cancel(): void {
17
+ generation++;
18
+ clearTimeout(timer);
19
+ active?.abort();
20
+ }
21
+ function schedule(input: Input): void {
22
+ cancel();
23
+ const current = generation;
24
+ publish({ status: "waiting" });
25
+ timer = setTimeout(async () => {
26
+ active = new AbortController();
27
+ publish({ status: "loading" });
28
+ try {
29
+ const value = await request(input, active.signal);
30
+ if (current === generation) publish({ status: "ready", value });
31
+ } catch (error) {
32
+ if (current === generation) publish({ status: "error", message: error instanceof Error ? error.message : "The sample could not be calculated. Try again." });
33
+ }
34
+ }, delay);
35
+ }
36
+ return { schedule, cancel };
37
+ }
@@ -82,6 +82,31 @@ export function draftIsDirty(state: SettingsEditorState): boolean {
82
82
  return JSON.stringify(state.draft) !== JSON.stringify(state.saved);
83
83
  }
84
84
 
85
+ /** Invalid text remains in the draft so the settings schema rejects it visibly. */
86
+ export function parseSettingsNumber(raw: string): number | string {
87
+ if (raw.trim() === "") return "";
88
+ const value = Number(raw);
89
+ return Number.isFinite(value) ? value : raw;
90
+ }
91
+
92
+ /** Choosing a method configures only that section, retaining all inactive fields. */
93
+ export function selectDraftMethod(state: SettingsEditorState, section: "pricing" | "sku", method: string): void {
94
+ state.draft[section].method = method;
95
+ state.draft[section === "pricing" ? "pricingConfigured" : "skuConfigured"] = true;
96
+ if (section === "pricing" && method === "margin" && state.draft.pricing.targetMargin === "") {
97
+ state.draft.pricing.targetMargin = 40;
98
+ }
99
+ }
100
+
101
+ /** The saved baseline belongs to the request snapshot; subsequent edits stay dirty. */
102
+ export function applySaveAfterEdits(state: SettingsEditorState, submitted: SettingsEditorState, result: SaveOutcome): SettingsEditorState {
103
+ if (!result.ok) return applySaveResult(state, result);
104
+ const saved = applySaveResult(submitted, result);
105
+ return JSON.stringify(state.draft) === JSON.stringify(submitted.draft)
106
+ ? saved
107
+ : { ...saved, draft: state.draft };
108
+ }
109
+
85
110
  /** A conflict leaves the draft reference in place and does not move the expected revision. */
86
111
  export function applySaveResult(state: SettingsEditorState, result: SaveOutcome): SettingsEditorState {
87
112
  if (result.ok) {
@@ -0,0 +1,23 @@
1
+ /** One preview attempt, with a stable missing/broken photo placeholder. */
2
+ export function productSetupPhoto(url: string | undefined, size: number, api: string): HTMLElement {
3
+ const box = document.createElement("span");
4
+ box.className = "photo-preview";
5
+ box.style.width = `${size}px`;
6
+ box.style.height = `${size}px`;
7
+ const placeholder = document.createElement("span");
8
+ placeholder.className = "img-empty";
9
+ placeholder.textContent = "No photo";
10
+ box.append(placeholder);
11
+ if (url) {
12
+ const image = document.createElement("img");
13
+ image.alt = "";
14
+ image.width = size;
15
+ image.height = size;
16
+ image.loading = "lazy";
17
+ image.addEventListener("error", () => { image.remove(); placeholder.hidden = false; }, { once: true });
18
+ image.addEventListener("load", () => { placeholder.hidden = true; }, { once: true });
19
+ image.src = url.startsWith("/api/admin/catalog/media?") ? `${api}/media?${url.split("?", 2)[1]}` : url;
20
+ box.append(image);
21
+ }
22
+ return box;
23
+ }
@@ -16,6 +16,7 @@ import VariantSelector, { publicSelectorVariants } from "@/components/islands/Va
16
16
  import CheckoutComplianceGate from "@/components/islands/CheckoutComplianceGate.tsx";
17
17
  import RecentlyViewed from "@/components/islands/RecentlyViewed.tsx";
18
18
  import NicotineWarning from "@/components/compliance/NicotineWarning.astro";
19
+ import Prop65Warning from "@/components/compliance/Prop65Warning.astro";
19
20
  import ReviewSummary from "@/components/commerce/ReviewSummary.astro";
20
21
  import ReviewList from "@/components/commerce/ReviewList.astro";
21
22
  import { isOnSale, isNew, isAvailable, fromPrice } from "@/lib/tot/query";
@@ -348,6 +349,16 @@ const jsonLd = [productJsonLd, breadcrumbLd(crumbs, canonicalBase)];
348
349
  </div>
349
350
  )}
350
351
 
352
+ {/* California Prop 65 (Cal. Code Regs. tit. 27 § 25601+): the safe-harbor
353
+ warning must reach the purchaser before checkout completes, so the PDP
354
+ — the point of sale here (no in-app cart page; checkout is an external
355
+ handoff) — is the mandated placement. */}
356
+ {compliance?.prop65 && (
357
+ <div class="mt-6">
358
+ <Prop65Warning variant="block" config={compliance.prop65} />
359
+ </div>
360
+ )}
361
+
351
362
  {restrictedStates.length > 0 && (
352
363
  <div class="mt-4 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 text-sm" role="note">
353
364
  <p class="font-display font-medium text-[var(--color-text)]">Where we can ship this</p>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.4.29",
3
+ "version": "2.4.31",
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",