@tokenoftrust/storefront-runner 4.3.0 → 4.3.1
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/apps/storefront/src/lib/catalog/admin/catalogSettingsClient.ts +43 -199
- package/apps/storefront/src/lib/catalog/admin/catalogSettingsFields.ts +134 -0
- package/apps/storefront/src/lib/catalog/admin/catalogSettingsPricingView.ts +39 -0
- package/apps/storefront/src/lib/catalog/admin/catalogSettingsRuleView.ts +44 -0
- package/apps/storefront/src/lib/catalog/admin/catalogSettingsSkuView.ts +21 -0
- package/package.json +1 -1
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
/** Catalog defaults: progressively revealed controls and an authoritative live sample. */
|
|
2
2
|
import {
|
|
3
|
-
applySaveAfterEdits, draftIsDirty, editorFromPayload,
|
|
3
|
+
applySaveAfterEdits, draftIsDirty, editorFromPayload, saveBody, selectDraftMethod,
|
|
4
4
|
type SettingsEditorState, type SettingsPayload,
|
|
5
5
|
} from "@/lib/catalog/admin/catalogSettingsState";
|
|
6
|
+
import {
|
|
7
|
+
asRecord, createCatalogSettingsFields, fieldString, h, METHOD_TITLES, responseMessage,
|
|
8
|
+
type MethodOption, type MethodPickerContext, type RecordValue,
|
|
9
|
+
} from "@/lib/catalog/admin/catalogSettingsFields";
|
|
10
|
+
import { renderPricingControls, renderPricingMethodPicker } from "@/lib/catalog/admin/catalogSettingsPricingView";
|
|
11
|
+
import { renderSkuControls, renderSkuMethodPicker } from "@/lib/catalog/admin/catalogSettingsSkuView";
|
|
6
12
|
import { latestPreview, type PreviewStatus } from "@/lib/catalog/admin/catalogSettingsPreview";
|
|
7
13
|
|
|
8
|
-
interface MethodOption { id: string; title: string; reason: string }
|
|
9
14
|
interface SettingsResponse extends SettingsPayload {
|
|
10
15
|
ok: true;
|
|
11
16
|
pricingMethods: MethodOption[];
|
|
17
|
+
skuMethods: MethodOption[];
|
|
12
18
|
history: Array<{ revision: number; savedAt: string; actor: string }>;
|
|
13
19
|
historyUnavailable: boolean;
|
|
14
20
|
}
|
|
@@ -18,49 +24,7 @@ interface PreviewResponse {
|
|
|
18
24
|
price: number | null; cost: number | null; profit: number | null; margin: number | null;
|
|
19
25
|
calculation: string; issues: string[]; ruleExplanation: string;
|
|
20
26
|
} };
|
|
21
|
-
}
|
|
22
|
-
type Child = Node | string | null | undefined | false;
|
|
23
|
-
type RecordValue = Record<string, unknown>;
|
|
24
|
-
|
|
25
|
-
function h<K extends keyof HTMLElementTagNameMap>(tag: K, attrs: Record<string, string | boolean | undefined> = {}, ...children: Child[]): HTMLElementTagNameMap[K] {
|
|
26
|
-
const node = document.createElement(tag);
|
|
27
|
-
for (const [key, value] of Object.entries(attrs)) if (value !== undefined && value !== false) node.setAttribute(key, value === true ? "" : value);
|
|
28
|
-
for (const child of children) if (child !== null && child !== undefined && child !== false) node.append(child);
|
|
29
|
-
return node;
|
|
30
|
-
}
|
|
31
|
-
const METHOD_FIELDS: Record<string, [string, string, string]> = {
|
|
32
|
-
margin: ["targetMargin", "Target gross margin (%)", "The share of your selling price left after product cost."],
|
|
33
|
-
markup: ["markup", "Markup over cost (%)", "100% doubles cost. That gives a 50% gross margin."],
|
|
34
|
-
"msrp-adjustment": ["msrpAdjustment", "MSRP adjustment (%)", "−10 is 10% below MSRP; +10 is 10% above."],
|
|
35
|
-
fixed: ["fixedMarkup", "Amount added to cost", "Add this amount to the cost of each item."],
|
|
36
|
-
};
|
|
37
|
-
const METHOD_TITLES: Record<string, string> = {
|
|
38
|
-
msrp: "Supplier MSRP", margin: "Target margin", markup: "Markup over cost", "msrp-adjustment": "Adjust MSRP", fixed: "Add a fixed amount",
|
|
39
|
-
};
|
|
40
|
-
const FIELD_LABELS: Record<string, string> = {
|
|
41
|
-
targetMargin: "Target gross margin", markup: "Markup over cost", msrpAdjustment: "MSRP adjustment", fixedMarkup: "Amount added to cost",
|
|
42
|
-
additionalCost: "Additional cost per item", additionalCostPercent: "Additional cost percentage", minimumPrice: "Minimum price", maximumPrice: "Maximum price", minimumMargin: "Minimum gross margin",
|
|
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",
|
|
44
|
-
method: "Method", productKey: "Product ID or SKU", selectedRuleId: "Matching rule",
|
|
45
|
-
name: "Rule name", scope: "Match by", match: "Matching value", minimumCost: "Cost from", maximumCost: "Cost below", price: "Selling price",
|
|
46
|
-
wholesale: "Wholesale cost", msrp: "Supplier MSRP", minimum: "Product minimum price", maximum: "Product maximum price",
|
|
47
|
-
};
|
|
48
|
-
function asRecord(value: unknown): RecordValue { return value && typeof value === "object" ? value as RecordValue : {}; }
|
|
49
|
-
function fieldString(record: RecordValue, key: string): string {
|
|
50
|
-
const value = record[key];
|
|
51
|
-
return typeof value === "number" || typeof value === "string" ? String(value) : "";
|
|
52
|
-
}
|
|
53
|
-
function responseMessage(body: { message?: string; issues?: string[] }, fallback: string): string {
|
|
54
|
-
const issues = (body.issues ?? []).map((issue) => {
|
|
55
|
-
const separator = issue.indexOf(": ");
|
|
56
|
-
if (separator < 0) return issue;
|
|
57
|
-
const path = issue.slice(0, separator).split(".");
|
|
58
|
-
let label = FIELD_LABELS[path.at(-1) ?? ""] ?? "Settings";
|
|
59
|
-
const collection = path.findIndex((part) => ["exceptions", "costTiers", "productOverrides"].includes(part));
|
|
60
|
-
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}`;
|
|
61
|
-
return `${label}: ${issue.slice(separator + 2)}`;
|
|
62
|
-
});
|
|
63
|
-
return [body.message, ...issues].filter(Boolean).join(" ") || fallback;
|
|
27
|
+
sku?: { status: string; value?: string; issues?: unknown };
|
|
64
28
|
}
|
|
65
29
|
|
|
66
30
|
export function initCatalogSettingsPage(root: HTMLElement): void {
|
|
@@ -72,6 +36,7 @@ export function initCatalogSettingsPage(root: HTMLElement): void {
|
|
|
72
36
|
let state: SettingsEditorState | null = null;
|
|
73
37
|
let storeCurrency = root.dataset.currency ?? "";
|
|
74
38
|
let pricingMethods: MethodOption[] = [];
|
|
39
|
+
let skuMethods: MethodOption[] = [];
|
|
75
40
|
let history: SettingsResponse["history"] = [];
|
|
76
41
|
let historyUnavailable = false;
|
|
77
42
|
let saving = false;
|
|
@@ -92,8 +57,7 @@ export function initCatalogSettingsPage(root: HTMLElement): void {
|
|
|
92
57
|
catch { return `${currency} ${value.toFixed(2)}`; }
|
|
93
58
|
};
|
|
94
59
|
const currencyMismatch = () => Boolean(state && storeCurrency && state.currency !== storeCurrency);
|
|
95
|
-
const
|
|
96
|
-
const methodTitle = (id: string) => METHOD_TITLES[id] ?? pricingMethods.find((method) => method.id === id)?.title ?? "Saved method";
|
|
60
|
+
const methodTitle = (id: string) => METHOD_TITLES[id] ?? [...pricingMethods, ...skuMethods].find((method) => method.id === id)?.title ?? "Saved method";
|
|
97
61
|
|
|
98
62
|
function paintStatus(): void {
|
|
99
63
|
if (!state) return;
|
|
@@ -118,154 +82,26 @@ export function initCatalogSettingsPage(root: HTMLElement): void {
|
|
|
118
82
|
}
|
|
119
83
|
function touch(): void { feedback = ""; feedbackError = false; paintStatus(); refreshRuleChoices(); schedulePreview(); }
|
|
120
84
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (!
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
function numberField(record: RecordValue, key: string, label: string, onChange: () => void = touch, hint = "", focusKey = key): HTMLElement {
|
|
131
|
-
const control = h("input", { type: "text", inputmode: "decimal", "data-focus": focusKey, value: fieldString(record, key), autocomplete: "off" });
|
|
132
|
-
control.addEventListener("input", () => { record[key] = parseSettingsNumber(control.value); onChange(); });
|
|
133
|
-
return h("label", { class: "field" }, h("span", {}, label), control, hint ? h("small", {}, hint) : null);
|
|
134
|
-
}
|
|
135
|
-
function textField(record: RecordValue, key: string, label: string, onChange: () => void = touch, attrs: Record<string, string> = {}, focusKey = key): HTMLElement {
|
|
136
|
-
const control = h("input", { type: "text", "data-focus": focusKey, value: fieldString(record, key), autocomplete: "off", ...attrs });
|
|
137
|
-
control.addEventListener("input", () => {
|
|
138
|
-
record[key] = key === "prefix" ? control.value.toUpperCase() : control.value;
|
|
139
|
-
if (key === "prefix") control.value = String(record[key]);
|
|
140
|
-
onChange();
|
|
141
|
-
});
|
|
142
|
-
return h("label", { class: "field" }, h("span", {}, label), control);
|
|
143
|
-
}
|
|
144
|
-
function flag(record: RecordValue, key: string, label: string): HTMLElement {
|
|
145
|
-
const control = h("input", { type: "checkbox", "data-focus": key });
|
|
146
|
-
control.checked = record[key] === true;
|
|
147
|
-
control.addEventListener("change", () => { record[key] = control.checked; touch(); });
|
|
148
|
-
return h("label", { class: "check-field" }, control, h("span", {}, label));
|
|
149
|
-
}
|
|
150
|
-
function disclosure(key: string, title: string, hint: string, children: Child[], initiallyOpen = false): HTMLDetailsElement {
|
|
151
|
-
const details = h("details", { class: "disclosure", "data-disclosure": key },
|
|
152
|
-
h("summary", {}, h("span", { class: "disclosure-title" }, title), h("span", { class: "disclosure-hint" }, hint)), h("div", { class: "disclosure-body" }, ...children));
|
|
153
|
-
details.open = openDetails.get(key) ?? initiallyOpen;
|
|
154
|
-
return details;
|
|
155
|
-
}
|
|
156
|
-
function parameter(record: RecordValue, prefix = ""): HTMLElement | null {
|
|
157
|
-
const field = METHOD_FIELDS[fieldString(record, "method")];
|
|
158
|
-
return field ? numberField(record, field[0], field[1], touch, field[2], `${prefix}${field[0]}`) : null;
|
|
159
|
-
}
|
|
160
|
-
function methodPicker(): HTMLElement {
|
|
161
|
-
if (!state) return h("div");
|
|
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
|
-
};
|
|
172
|
-
const reasons: Record<string, string> = {
|
|
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.",
|
|
174
|
-
};
|
|
175
|
-
const card = (method: MethodOption) => {
|
|
176
|
-
const selected = configured && record.method === method.id;
|
|
177
|
-
const radio = h("input", { type: "radio", name: "pricing-method", value: method.id, "data-focus": `pricing-${method.id}` });
|
|
178
|
-
radio.checked = selected;
|
|
179
|
-
radio.addEventListener("change", () => { if (state) { selectDraftMethod(state, "pricing", method.id); touch(); renderEditor(); } });
|
|
180
|
-
return h("label", { class: `method-card${selected ? " selected" : ""}` }, radio,
|
|
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));
|
|
85
|
+
const fields = createCatalogSettingsFields(touch, openDetails);
|
|
86
|
+
const { select, numberField, textField, disclosure } = fields;
|
|
87
|
+
function methodContext(section: "pricing" | "sku"): MethodPickerContext | null {
|
|
88
|
+
if (!state) return null;
|
|
89
|
+
return {
|
|
90
|
+
fields, record: state.draft[section], configured: state.draft[section === "pricing" ? "pricingConfigured" : "skuConfigured"] === true,
|
|
91
|
+
methods: section === "pricing" ? pricingMethods : skuMethods, methodTitle, money,
|
|
92
|
+
selectMethod: (id) => { if (state) { selectDraftMethod(state, section, id); touch(); renderEditor(); } },
|
|
93
|
+
useManual: () => { if (state) { state.draft[section === "pricing" ? "pricingConfigured" : "skuConfigured"] = false; touch(); renderEditor(); } },
|
|
182
94
|
};
|
|
183
|
-
const manual = h("button", { type: "button", class: "text-button", "data-focus": "pricing-manual", "aria-pressed": String(!configured) }, "Use manual prices instead");
|
|
184
|
-
manual.addEventListener("click", () => {
|
|
185
|
-
if (!state) return;
|
|
186
|
-
state.draft.pricingConfigured = false;
|
|
187
|
-
touch(); renderEditor();
|
|
188
|
-
});
|
|
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,
|
|
193
|
-
h("div", { class: "method-grid" }, ...common.map(card)),
|
|
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,
|
|
196
|
-
configured ? manual : null);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function ruleRows(list: RecordValue[], kind: "exception" | "tier" | "override"): HTMLElement {
|
|
200
|
-
return h("div", { class: "rules" }, ...list.map((rule, index) => {
|
|
201
|
-
const prefix = `${kind}-${String(rule.id)}-`;
|
|
202
|
-
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");
|
|
203
|
-
remove.addEventListener("click", () => { list.splice(index, 1); touch(); renderEditor(); });
|
|
204
|
-
const methods = (kind === "tier" ? ["margin", "markup", "fixed"] : ["msrp", "margin", "markup", "msrp-adjustment", "fixed"]).map((id) => ({ value: id, label: methodTitle(id) }));
|
|
205
|
-
return h("div", { class: "rule" }, h("div", { class: "rule-heading" }, h("strong", {}, `${kind === "exception" ? "Rule" : kind === "tier" ? "Cost tier" : "Product price"} ${index + 1}`), remove),
|
|
206
|
-
h("div", { class: "fields-grid" },
|
|
207
|
-
kind === "exception" ? textField(rule, "name", "Rule name", touch, {}, `${prefix}name`) : null,
|
|
208
|
-
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,
|
|
209
|
-
kind === "exception" ? textField(rule, "match", "Matching value", touch, {}, `${prefix}match`) : null,
|
|
210
|
-
kind === "tier" ? numberField(rule, "minimumCost", "Cost from", touch, "Inclusive", `${prefix}minimumCost`) : null,
|
|
211
|
-
kind === "tier" ? numberField(rule, "maximumCost", "Cost below", touch, "Leave blank for no upper limit.", `${prefix}maximumCost`) : null,
|
|
212
|
-
kind === "override" ? textField(rule, "productKey", "Product ID or SKU", touch, {}, `${prefix}productKey`) : null,
|
|
213
|
-
kind === "override" ? numberField(rule, "price", "Selling price", touch, "An exact price for this product.", `${prefix}price`) : null,
|
|
214
|
-
kind !== "override" ? select(rule, "method", "Pricing method", methods, () => { if (rule.method === "margin" && rule.targetMargin === "") rule.targetMargin = 40; touch(); renderEditor(); }, `${prefix}method`) : null,
|
|
215
|
-
kind !== "override" ? parameter(rule, prefix) : null));
|
|
216
|
-
}));
|
|
217
95
|
}
|
|
218
96
|
function pricingControls(): HTMLElement[] {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
pricing.rounding === "increment" ? numberField(pricing, "increment", "Rounding increment", touch, `For example, ${money(1)} or ${money(0.5)}.`) : null,
|
|
228
|
-
pricing.rounding !== "none" ? select(pricing, "direction", "Round direction", [{ value: "up", label: "Up" }, { value: "nearest", label: "Nearest" }, { value: "down", label: "Down" }]) : null),
|
|
229
|
-
pricing.method === "msrp" ? flag(pricing, "preserveMsrp", "Keep supplier MSRP exact (skip price formatting)") : null);
|
|
230
|
-
const fallbackField = METHOD_FIELDS[String(pricing.fallbackMethod)];
|
|
231
|
-
const advanced = disclosure("pricing-advanced", "Advanced pricing", "Costs, limits and missing data", [
|
|
232
|
-
h("h3", {}, "Product cost"),
|
|
233
|
-
h("div", { class: "fields-grid" },
|
|
234
|
-
select(pricing, "costBasis", "Cost basis", [{ value: "wholesale", label: "Wholesale purchase cost" }, { value: "adjusted", label: "Wholesale + additional costs" }], repaint),
|
|
235
|
-
pricing.costBasis === "adjusted" ? numberField(pricing, "additionalCost", "Additional cost per item") : null,
|
|
236
|
-
pricing.costBasis === "adjusted" ? numberField(pricing, "additionalCostPercent", "Additional cost (% of wholesale)") : null),
|
|
237
|
-
h("p", { class: "hint" }, "Include freight, duties or handling. Both adjustments are added to wholesale cost."),
|
|
238
|
-
h("h3", {}, "Price limits"),
|
|
239
|
-
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")),
|
|
240
|
-
flag(pricing, "capAtMsrp", "Flag prices above supplier MSRP"),
|
|
241
|
-
pricing.method !== "msrp" ? flag(pricing, "preserveMsrp", "Keep supplier MSRP exact when used") : null,
|
|
242
|
-
h("h3", {}, "When a price source is missing"),
|
|
243
|
-
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(); }),
|
|
244
|
-
pricing.missingData === "fallback" ? h("div", { class: "fields-grid nested-fields" },
|
|
245
|
-
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(); }),
|
|
246
|
-
fallbackField ? numberField(pricing, fallbackField[0], `Fallback ${fallbackField[1].toLowerCase()}`, touch, "Choose a fallback that uses a different price source.", `fallback-${fallbackField[0]}`) : null) : null,
|
|
247
|
-
h("h3", {}, "Update preference"),
|
|
248
|
-
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" }]),
|
|
249
|
-
h("p", { class: "hint" }, "Saved listings are not automatically repriced by these defaults."),
|
|
250
|
-
flag(pricing, "includeManualOverrides", "Include manually priced products in the update preference"),
|
|
251
|
-
]);
|
|
252
|
-
const exceptions = Array.isArray(pricing.exceptions) ? pricing.exceptions.map(asRecord) : [];
|
|
253
|
-
const tiers = Array.isArray(pricing.costTiers) ? pricing.costTiers.map(asRecord) : [];
|
|
254
|
-
const overrides = Array.isArray(pricing.productOverrides) ? pricing.productOverrides.map(asRecord) : [];
|
|
255
|
-
pricing.exceptions = exceptions; pricing.costTiers = tiers; pricing.productOverrides = overrides;
|
|
256
|
-
const add = (list: RecordValue[], blank: RecordValue, label: string) => {
|
|
257
|
-
const button = h("button", { type: "button", class: "btn secondary small" }, label);
|
|
258
|
-
button.addEventListener("click", () => { list.push({ ...blank, id: crypto.randomUUID() }); touch(); renderEditor(); });
|
|
259
|
-
return button;
|
|
260
|
-
};
|
|
261
|
-
const rules = disclosure("pricing-rules", "Rules & product exceptions", "Customize prices for part of your catalog", [
|
|
262
|
-
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"),
|
|
263
|
-
add(exceptions, { name: "New rule", scope: "brand", match: "", method: "margin", targetMargin: 40, markup: "", msrpAdjustment: "", fixedMarkup: "" }, "Add rule"),
|
|
264
|
-
h("h3", {}, "Cost tiers"), h("p", { class: "hint" }, "Apply different cost-based pricing to different cost ranges."), ruleRows(tiers, "tier"),
|
|
265
|
-
add(tiers, { minimumCost: 0, maximumCost: "", method: "margin", targetMargin: 40, markup: "", fixedMarkup: "" }, "Add cost tier"),
|
|
266
|
-
h("h3", {}, "Product prices"), ruleRows(overrides, "override"), add(overrides, { productKey: "", price: 0 }, "Add product price"),
|
|
267
|
-
]);
|
|
268
|
-
return [priceFormat, advanced, rules];
|
|
97
|
+
return state?.draft.pricingConfigured === true ? renderPricingControls(state.draft.pricing, {
|
|
98
|
+
fields, touch, repaint: () => { touch(); renderEditor(); }, money, methodTitle,
|
|
99
|
+
}) : [];
|
|
100
|
+
}
|
|
101
|
+
function skuSection(): HTMLElement[] {
|
|
102
|
+
const context = skuMethods.length > 0 ? methodContext("sku") : null;
|
|
103
|
+
if (!context || !state) return [];
|
|
104
|
+
return [renderSkuMethodPicker(context), ...(state.draft.skuConfigured ? renderSkuControls(state.draft.sku, fields, touch) : [])];
|
|
269
105
|
}
|
|
270
106
|
function savedHistory(): HTMLElement {
|
|
271
107
|
const entries: Node[] = [];
|
|
@@ -284,14 +120,16 @@ export function initCatalogSettingsPage(root: HTMLElement): void {
|
|
|
284
120
|
const focused = document.activeElement as HTMLInputElement | null;
|
|
285
121
|
const focusKey = focused && editor.contains(focused) ? focused.dataset.focus : undefined;
|
|
286
122
|
const selection: [number | null, number | null] | null = focused?.tagName === "INPUT" && ["text", "search"].includes(focused.type) ? [focused.selectionStart, focused.selectionEnd] : null;
|
|
123
|
+
const pricing = methodContext("pricing");
|
|
287
124
|
editor.replaceChildren(
|
|
288
|
-
h("div", { id: "settings-pricing-panel" },
|
|
125
|
+
h("div", { id: "settings-pricing-panel" }, pricing ? renderPricingMethodPicker(pricing) : null, ...pricingControls()),
|
|
126
|
+
...skuSection(),
|
|
289
127
|
h("p", { class: "preserve-note" }, h("span", { "aria-hidden": "true" }, "✓"), " Saved listings keep their prices and merchant SKUs. Pricing defaults never overwrite them."), savedHistory());
|
|
290
128
|
if (focusKey) {
|
|
291
129
|
const control = Array.from(editor.querySelectorAll<HTMLElement>("[data-focus]")).find((node) => node.dataset.focus === focusKey);
|
|
292
130
|
control?.focus({ preventScroll: true });
|
|
293
131
|
if (selection && control instanceof HTMLInputElement) control.setSelectionRange(selection[0], selection[1]);
|
|
294
|
-
if (!control && focusKey.endsWith("-manual")) editor.querySelector<HTMLInputElement>(`input[name="pricing-method"]`)?.focus({ preventScroll: true });
|
|
132
|
+
if (!control && focusKey.endsWith("-manual")) editor.querySelector<HTMLInputElement>(`input[name="${focusKey.startsWith("sku-") ? "sku" : "pricing"}-method"]`)?.focus({ preventScroll: true });
|
|
295
133
|
}
|
|
296
134
|
refreshRuleChoices();
|
|
297
135
|
}
|
|
@@ -312,7 +150,7 @@ export function initCatalogSettingsPage(root: HTMLElement): void {
|
|
|
312
150
|
disclosure("sample-context", "Try rules & limits", "Optional product details", [
|
|
313
151
|
h("div", { class: "fields-grid" }, numberField(sample, "minimum", "Product minimum price", schedulePreview, "", "sample-minimum"), numberField(sample, "maximum", "Product maximum price", schedulePreview, "", "sample-maximum")),
|
|
314
152
|
textField(sampleText, "productKey", "Product ID or SKU", schedulePreview, {}, "sample-productKey"),
|
|
315
|
-
...["assortment", "category", "brand"].map((key) => textField(sampleText, key, key.charAt(0).toUpperCase() + key.slice(1), schedulePreview, {}, `sample-${key}`)), ruleSelect]),
|
|
153
|
+
...(["assortment", "category", "brand"] as const).map((key) => textField(sampleText, key, key.charAt(0).toUpperCase() + key.slice(1), schedulePreview, {}, `sample-${key}`)), ruleSelect]),
|
|
316
154
|
h("p", { class: "preview-footnote" }, "A sample price; saving it does not change catalog references or existing listings."));
|
|
317
155
|
|
|
318
156
|
function paintPreview(result: PreviewStatus<PreviewResponse>): void {
|
|
@@ -332,13 +170,19 @@ export function initCatalogSettingsPage(root: HTMLElement): void {
|
|
|
332
170
|
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)}%`]];
|
|
333
171
|
for (const [label, value] of values) metrics.append(h("div", {}, h("dt", {}, label), h("dd", {}, value)));
|
|
334
172
|
}
|
|
173
|
+
const skuValue = body.sku?.status === "ok" && typeof body.sku.value === "string" ? body.sku.value : "";
|
|
174
|
+
const skuIssues = body.sku?.status === "review-needed" && Array.isArray(body.sku.issues) && body.sku.issues.every((issue) => typeof issue === "string")
|
|
175
|
+
? body.sku.issues
|
|
176
|
+
: [];
|
|
335
177
|
previewOut.replaceChildren(
|
|
336
178
|
h("div", { class: "preview-result" }, h("span", { class: "result-label" }, "Selling price"),
|
|
337
179
|
h("strong", { class: `sample-price${!calculated ? " manual-result" : ""}` }, calculated ? calculated.price === null ? "Needs review" : money(calculated.price, body.currency) : "Manual price"),
|
|
338
|
-
h("span", { class: "result-method" }, calculated ? "Your draft price" : "Choose a pricing method to calculate"), metrics
|
|
180
|
+
h("span", { class: "result-method" }, calculated ? "Your draft price" : "Choose a pricing method to calculate"), metrics,
|
|
181
|
+
skuValue ? h("span", { class: "sku-result" }, skuValue) : null),
|
|
339
182
|
...(calculated?.calculation ? [h("p", { class: "calculation hint" }, calculated.calculation)] : []),
|
|
340
183
|
...(calculated?.ruleExplanation && calculated.ruleExplanation !== "Store default" ? [h("p", { class: "hint applied-rule" }, calculated.ruleExplanation)] : []),
|
|
341
|
-
...(calculated?.issues ?? []).map((issue) => h("p", { class: "notice review-note" }, issue))
|
|
184
|
+
...(calculated?.issues ?? []).map((issue) => h("p", { class: "notice review-note" }, issue)),
|
|
185
|
+
...skuIssues.map((issue) => h("p", { class: "notice review-note" }, issue)));
|
|
342
186
|
}
|
|
343
187
|
const preview = latestPreview<RecordValue, PreviewResponse>(async (draft, signal) => {
|
|
344
188
|
const response = await fetch(previewUrl, { method: "POST", headers: { "content-type": "application/json", accept: "application/json" }, body: JSON.stringify(draft), signal });
|
|
@@ -364,8 +208,8 @@ export function initCatalogSettingsPage(root: HTMLElement): void {
|
|
|
364
208
|
try {
|
|
365
209
|
const response = await fetch(api, { headers: { accept: "application/json" } });
|
|
366
210
|
const body = await response.json() as SettingsResponse & { message?: string };
|
|
367
|
-
if (!response.ok || !body.ok) throw new Error(body.message || "Your defaults could not be loaded. Try again.");
|
|
368
|
-
pricingMethods = body.pricingMethods; history = body.history; historyUnavailable = body.historyUnavailable;
|
|
211
|
+
if (!response.ok || !body.ok || !Array.isArray(body.skuMethods)) throw new Error(body.message || "Your defaults could not be loaded. Try again.");
|
|
212
|
+
pricingMethods = body.pricingMethods; skuMethods = body.skuMethods; history = body.history; historyUnavailable = body.historyUnavailable;
|
|
369
213
|
state = editorFromPayload(body);
|
|
370
214
|
if (typeof body.storeCurrency === "string") storeCurrency = body.storeCurrency;
|
|
371
215
|
const badge = previewPanel.querySelector(".currency-badge"); if (badge) badge.textContent = state.currency;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/** Text-safe controls and named catalog settings fields. */
|
|
2
|
+
import { parseSettingsNumber } from "@/lib/catalog/admin/catalogSettingsState";
|
|
3
|
+
|
|
4
|
+
export type Child = Node | string | null | undefined | false;
|
|
5
|
+
export type Section = "pricing" | "sku";
|
|
6
|
+
export type RecordValue = Record<string, unknown>;
|
|
7
|
+
|
|
8
|
+
export function h<K extends keyof HTMLElementTagNameMap>(tag: K, attrs: Record<string, string | boolean | undefined> = {}, ...children: Child[]): HTMLElementTagNameMap[K] {
|
|
9
|
+
const node = document.createElement(tag);
|
|
10
|
+
for (const [key, value] of Object.entries(attrs)) if (value !== undefined && value !== false) node.setAttribute(key, value === true ? "" : value);
|
|
11
|
+
for (const child of children) if (child !== null && child !== undefined && child !== false) node.append(child);
|
|
12
|
+
return node;
|
|
13
|
+
}
|
|
14
|
+
export interface MethodParameterField { readonly key: CatalogFieldKey; readonly label: string; readonly hint: string }
|
|
15
|
+
export const METHOD_FIELDS: Readonly<Partial<Record<string, MethodParameterField>>> = {
|
|
16
|
+
margin: { key: "targetMargin", label: "Target gross margin (%)", hint: "The share of your selling price left after product cost." },
|
|
17
|
+
markup: { key: "markup", label: "Markup over cost (%)", hint: "100% doubles cost. That gives a 50% gross margin." },
|
|
18
|
+
"msrp-adjustment": { key: "msrpAdjustment", label: "MSRP adjustment (%)", hint: "−10 is 10% below MSRP; +10 is 10% above." },
|
|
19
|
+
fixed: { key: "fixedMarkup", label: "Amount added to cost", hint: "Add this amount to the cost of each item." },
|
|
20
|
+
};
|
|
21
|
+
export const METHOD_TITLES: Readonly<Record<string, string>> = {
|
|
22
|
+
msrp: "Supplier MSRP", margin: "Target margin", markup: "Markup over cost", "msrp-adjustment": "Adjust MSRP", fixed: "Add a fixed amount",
|
|
23
|
+
sequential: "Simple numbers", family: "Product families", hybrid: "Category + number", attributes: "Product attributes", supplier: "Supplier codes", random: "Short random codes", deterministic: "Stable identity codes",
|
|
24
|
+
};
|
|
25
|
+
export const FIELD_LABELS = {
|
|
26
|
+
targetMargin: "Target gross margin", markup: "Markup over cost", msrpAdjustment: "MSRP adjustment", fixedMarkup: "Amount added to cost",
|
|
27
|
+
additionalCost: "Additional cost per item", additionalCostPercent: "Additional cost percentage", minimumPrice: "Minimum price", maximumPrice: "Maximum price", minimumMargin: "Minimum gross margin",
|
|
28
|
+
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",
|
|
29
|
+
startingNumber: "Starting number", prefix: "SKU prefix", separator: "SKU separator", method: "Method", productKey: "Product ID or SKU", selectedRuleId: "Matching rule",
|
|
30
|
+
name: "Rule name", scope: "Match by", match: "Matching value", minimumCost: "Cost from", maximumCost: "Cost below", price: "Selling price",
|
|
31
|
+
wholesale: "Wholesale cost", msrp: "Supplier MSRP", minimum: "Product minimum price", maximum: "Product maximum price",
|
|
32
|
+
} as const satisfies Readonly<Record<string, string>>;
|
|
33
|
+
export type CatalogFieldKey = keyof typeof FIELD_LABELS | "assortment" | "category" | "brand" | "preserveMsrp" | "capAtMsrp" | "includeManualOverrides";
|
|
34
|
+
export function asRecord(value: unknown): RecordValue { return value && typeof value === "object" ? value as RecordValue : {}; }
|
|
35
|
+
export function fieldString(record: RecordValue, key: string): string {
|
|
36
|
+
const value = record[key];
|
|
37
|
+
return typeof value === "number" || typeof value === "string" ? String(value) : "";
|
|
38
|
+
}
|
|
39
|
+
export function responseMessage(body: { message?: string; issues?: string[] }, fallback: string): string {
|
|
40
|
+
const issues = (body.issues ?? []).map((issue) => {
|
|
41
|
+
const separator = issue.indexOf(": ");
|
|
42
|
+
if (separator < 0) return issue;
|
|
43
|
+
const path = issue.slice(0, separator).split(".");
|
|
44
|
+
let label: string = FIELD_LABELS[(path.at(-1) ?? "") as keyof typeof FIELD_LABELS] ?? "Settings";
|
|
45
|
+
const collection = path.findIndex((part) => ["exceptions", "costTiers", "productOverrides"].includes(part));
|
|
46
|
+
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}`;
|
|
47
|
+
return `${label}: ${issue.slice(separator + 2)}`;
|
|
48
|
+
});
|
|
49
|
+
return [body.message, ...issues].filter(Boolean).join(" ") || fallback;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createCatalogSettingsFields(touch: () => void, openDetails: ReadonlyMap<string, boolean>) {
|
|
53
|
+
function select(record: RecordValue, key: CatalogFieldKey, label: string, options: Array<{ value: string; label: string }>, onChange: () => void = touch, focusKey: string = key): HTMLElement {
|
|
54
|
+
const current = fieldString(record, key);
|
|
55
|
+
const control = h("select", { "data-focus": focusKey });
|
|
56
|
+
if (!options.some((option) => option.value === current)) control.append(h("option", { value: current }, "Saved value — choose an option"));
|
|
57
|
+
for (const option of options) control.append(h("option", { value: option.value }, option.label));
|
|
58
|
+
control.value = current;
|
|
59
|
+
control.addEventListener("change", () => { record[key] = control.value; onChange(); });
|
|
60
|
+
return h("label", { class: "field" }, h("span", {}, label), control);
|
|
61
|
+
}
|
|
62
|
+
function numberField(record: RecordValue, key: CatalogFieldKey, label: string, onChange: () => void = touch, hint = "", focusKey: string = key): HTMLElement {
|
|
63
|
+
const control = h("input", { type: "text", inputmode: "decimal", "data-focus": focusKey, value: fieldString(record, key), autocomplete: "off" });
|
|
64
|
+
control.addEventListener("input", () => { record[key] = parseSettingsNumber(control.value); onChange(); });
|
|
65
|
+
return h("label", { class: "field" }, h("span", {}, label), control, hint ? h("small", {}, hint) : null);
|
|
66
|
+
}
|
|
67
|
+
function textField(record: RecordValue, key: CatalogFieldKey, label: string, onChange: () => void = touch, attrs: Record<string, string> = {}, focusKey: string = key): HTMLElement {
|
|
68
|
+
const control = h("input", { type: "text", "data-focus": focusKey, value: fieldString(record, key), autocomplete: "off", ...attrs });
|
|
69
|
+
control.addEventListener("input", () => {
|
|
70
|
+
record[key] = key === "prefix" ? control.value.toUpperCase() : control.value;
|
|
71
|
+
if (key === "prefix") control.value = String(record[key]);
|
|
72
|
+
onChange();
|
|
73
|
+
});
|
|
74
|
+
return h("label", { class: "field" }, h("span", {}, label), control);
|
|
75
|
+
}
|
|
76
|
+
function flag(record: RecordValue, key: CatalogFieldKey, label: string): HTMLElement {
|
|
77
|
+
const control = h("input", { type: "checkbox", "data-focus": key });
|
|
78
|
+
control.checked = record[key] === true;
|
|
79
|
+
control.addEventListener("change", () => { record[key] = control.checked; touch(); });
|
|
80
|
+
return h("label", { class: "check-field" }, control, h("span", {}, label));
|
|
81
|
+
}
|
|
82
|
+
function disclosure(key: string, title: string, hint: string, children: Child[], initiallyOpen = false): HTMLDetailsElement {
|
|
83
|
+
const details = h("details", { class: "disclosure", "data-disclosure": key },
|
|
84
|
+
h("summary", {}, h("span", { class: "disclosure-title" }, title), h("span", { class: "disclosure-hint" }, hint)), h("div", { class: "disclosure-body" }, ...children));
|
|
85
|
+
details.open = openDetails.get(key) ?? initiallyOpen;
|
|
86
|
+
return details;
|
|
87
|
+
}
|
|
88
|
+
function parameter(record: RecordValue, prefix = ""): HTMLElement | null {
|
|
89
|
+
const field = METHOD_FIELDS[fieldString(record, "method")];
|
|
90
|
+
return field ? numberField(record, field.key, field.label, touch, field.hint, `${prefix}${field.key}`) : null;
|
|
91
|
+
}
|
|
92
|
+
return { select, numberField, textField, flag, disclosure, parameter };
|
|
93
|
+
}
|
|
94
|
+
export type CatalogSettingsFields = ReturnType<typeof createCatalogSettingsFields>;
|
|
95
|
+
export interface MethodOption { id: string; title: string; reason: string }
|
|
96
|
+
export interface MethodPickerContext {
|
|
97
|
+
fields: CatalogSettingsFields; record: RecordValue; configured: boolean; methods: MethodOption[];
|
|
98
|
+
methodTitle: (id: string) => string; money: (value: number) => string;
|
|
99
|
+
selectMethod: (id: string) => void; useManual: () => void;
|
|
100
|
+
}
|
|
101
|
+
function examples(section: Section, money: (value: number) => string): Record<string, string> {
|
|
102
|
+
return section === "pricing" ? {
|
|
103
|
+
msrp: `${money(60)} MSRP → ${money(60)}`, margin: `${money(40)} cost · 40% → ${money(66.67)}`,
|
|
104
|
+
markup: `${money(40)} cost · 50% → ${money(60)}`, "msrp-adjustment": `${money(60)} MSRP · −10% → ${money(54)}`, fixed: `${money(40)} cost + ${money(15)} → ${money(55)}`,
|
|
105
|
+
} : { sequential: "SKU-004821", family: "P004821-V03", hybrid: "TSH-004821", attributes: "AC-TS42-BLK-M", supplier: "SUP7-AX204", random: "K7MX4R9T", deterministic: "P-7K3M9X2Q" };
|
|
106
|
+
}
|
|
107
|
+
export function renderCatalogMethodPicker(section: Section, context: MethodPickerContext): HTMLElement {
|
|
108
|
+
const { fields: { disclosure, parameter }, record, configured, methods: all, methodTitle, money, selectMethod, useManual } = context;
|
|
109
|
+
const commonIds = section === "pricing" ? ["msrp", "margin", "markup"] : ["sequential", "family", "hybrid"];
|
|
110
|
+
const common = all.filter((method) => commonIds.includes(method.id));
|
|
111
|
+
const more = all.filter((method) => !commonIds.includes(method.id));
|
|
112
|
+
const sampleExamples = examples(section, money);
|
|
113
|
+
const reasons: Record<string, string> = {
|
|
114
|
+
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.",
|
|
115
|
+
};
|
|
116
|
+
const card = (method: MethodOption) => {
|
|
117
|
+
const selected = configured && record.method === method.id;
|
|
118
|
+
const radio = h("input", { type: "radio", name: `${section}-method`, value: method.id, "data-focus": `${section}-${method.id}` });
|
|
119
|
+
radio.checked = selected;
|
|
120
|
+
radio.addEventListener("change", () => selectMethod(method.id));
|
|
121
|
+
return h("label", { class: `method-card${selected ? " selected" : ""}` }, radio,
|
|
122
|
+
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));
|
|
123
|
+
};
|
|
124
|
+
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");
|
|
125
|
+
manual.addEventListener("click", useManual);
|
|
126
|
+
return h("section", { class: "editor-panel method-panel", "aria-labelledby": `${section}-method-heading` },
|
|
127
|
+
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")),
|
|
128
|
+
h("p", { class: "section-intro" }, section === "pricing" ? "One starting point for every new product." : "An item code that works for the way you sell."),
|
|
129
|
+
!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,
|
|
130
|
+
h("div", { class: "method-grid" }, ...common.map(card)),
|
|
131
|
+
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)),
|
|
132
|
+
configured && section === "pricing" && METHOD_FIELDS[String(record.method)] ? h("div", { class: "method-parameter" }, parameter(record)) : null,
|
|
133
|
+
configured ? manual : null);
|
|
134
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Pricing methods, finishing, costs and limits. */
|
|
2
|
+
import { h, METHOD_FIELDS, renderCatalogMethodPicker, type MethodPickerContext, type RecordValue } from "@/lib/catalog/admin/catalogSettingsFields";
|
|
3
|
+
import { renderPricingRules, type RuleViewContext } from "@/lib/catalog/admin/catalogSettingsRuleView";
|
|
4
|
+
export interface PricingViewContext extends RuleViewContext { money: (value: number) => string }
|
|
5
|
+
export function renderPricingMethodPicker(context: MethodPickerContext): HTMLElement { return renderCatalogMethodPicker("pricing", context); }
|
|
6
|
+
export function renderPricingControls(pricing: RecordValue, context: PricingViewContext): HTMLElement[] {
|
|
7
|
+
const { fields: { select, textField, numberField, flag, disclosure }, touch, repaint, money, methodTitle } = context;
|
|
8
|
+
const priceFormat = h("section", { class: "editor-panel" },
|
|
9
|
+
h("div", { class: "section-heading" }, h("span", { class: "step-number", "aria-hidden": "true" }, "2"), h("h2", {}, "Give prices a consistent finish")),
|
|
10
|
+
h("div", { class: "fields-grid" },
|
|
11
|
+
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),
|
|
12
|
+
pricing.rounding === "ending" ? textField(pricing, "ending", "Ending (two digits)", touch, { maxlength: "2", inputmode: "numeric" }) : null,
|
|
13
|
+
pricing.rounding === "increment" ? numberField(pricing, "increment", "Rounding increment", touch, `For example, ${money(1)} or ${money(0.5)}.`) : null,
|
|
14
|
+
pricing.rounding !== "none" ? select(pricing, "direction", "Round direction", [{ value: "up", label: "Up" }, { value: "nearest", label: "Nearest" }, { value: "down", label: "Down" }]) : null),
|
|
15
|
+
pricing.method === "msrp" ? flag(pricing, "preserveMsrp", "Keep supplier MSRP exact (skip price formatting)") : null);
|
|
16
|
+
const fallbackField = METHOD_FIELDS[String(pricing.fallbackMethod)];
|
|
17
|
+
const advanced = disclosure("pricing-advanced", "Advanced pricing", "Costs, limits and missing data", [
|
|
18
|
+
h("h3", {}, "Product cost"),
|
|
19
|
+
h("div", { class: "fields-grid" },
|
|
20
|
+
select(pricing, "costBasis", "Cost basis", [{ value: "wholesale", label: "Wholesale purchase cost" }, { value: "adjusted", label: "Wholesale + additional costs" }], repaint),
|
|
21
|
+
pricing.costBasis === "adjusted" ? numberField(pricing, "additionalCost", "Additional cost per item") : null,
|
|
22
|
+
pricing.costBasis === "adjusted" ? numberField(pricing, "additionalCostPercent", "Additional cost (% of wholesale)") : null),
|
|
23
|
+
h("p", { class: "hint" }, "Include freight, duties or handling. Both adjustments are added to wholesale cost."),
|
|
24
|
+
h("h3", {}, "Price limits"),
|
|
25
|
+
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")),
|
|
26
|
+
flag(pricing, "capAtMsrp", "Flag prices above supplier MSRP"),
|
|
27
|
+
pricing.method !== "msrp" ? flag(pricing, "preserveMsrp", "Keep supplier MSRP exact when used") : null,
|
|
28
|
+
h("h3", {}, "When a price source is missing"),
|
|
29
|
+
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(); }),
|
|
30
|
+
pricing.missingData === "fallback" ? h("div", { class: "fields-grid nested-fields" },
|
|
31
|
+
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(); }),
|
|
32
|
+
fallbackField ? numberField(pricing, fallbackField.key, `Fallback ${fallbackField.label.toLowerCase()}`, touch, "Choose a fallback that uses a different price source.", `fallback-${fallbackField.key}`) : null) : null,
|
|
33
|
+
h("h3", {}, "Update preference"),
|
|
34
|
+
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" }]),
|
|
35
|
+
h("p", { class: "hint" }, "Saved listings are not automatically repriced by these defaults."),
|
|
36
|
+
flag(pricing, "includeManualOverrides", "Include manually priced products in the update preference"),
|
|
37
|
+
]);
|
|
38
|
+
return [priceFormat, advanced, renderPricingRules(pricing, context)];
|
|
39
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Rules, cost tiers and product-specific pricing controls. */
|
|
2
|
+
import { h, asRecord, type RecordValue, type CatalogSettingsFields } from "@/lib/catalog/admin/catalogSettingsFields";
|
|
3
|
+
export interface RuleViewContext { fields: CatalogSettingsFields; touch: () => void; repaint: () => void; methodTitle: (id: string) => string }
|
|
4
|
+
export function renderRuleRows(list: RecordValue[], kind: "exception" | "tier" | "override", context: RuleViewContext): HTMLElement {
|
|
5
|
+
const { fields: { textField, select, numberField, parameter }, touch, repaint, methodTitle } = context;
|
|
6
|
+
return h("div", { class: "rules" }, ...list.map((rule, index) => {
|
|
7
|
+
const prefix = `${kind}-${String(rule.id)}-`;
|
|
8
|
+
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");
|
|
9
|
+
remove.addEventListener("click", () => { list.splice(index, 1); repaint(); });
|
|
10
|
+
const methods = (kind === "tier" ? ["margin", "markup", "fixed"] : ["msrp", "margin", "markup", "msrp-adjustment", "fixed"]).map((id) => ({ value: id, label: methodTitle(id) }));
|
|
11
|
+
return h("div", { class: "rule" }, h("div", { class: "rule-heading" }, h("strong", {}, `${kind === "exception" ? "Rule" : kind === "tier" ? "Cost tier" : "Product price"} ${index + 1}`), remove),
|
|
12
|
+
h("div", { class: "fields-grid" },
|
|
13
|
+
kind === "exception" ? textField(rule, "name", "Rule name", touch, {}, `${prefix}name`) : null,
|
|
14
|
+
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,
|
|
15
|
+
kind === "exception" ? textField(rule, "match", "Matching value", touch, {}, `${prefix}match`) : null,
|
|
16
|
+
kind === "tier" ? numberField(rule, "minimumCost", "Cost from", touch, "Inclusive", `${prefix}minimumCost`) : null,
|
|
17
|
+
kind === "tier" ? numberField(rule, "maximumCost", "Cost below", touch, "Leave blank for no upper limit.", `${prefix}maximumCost`) : null,
|
|
18
|
+
kind === "override" ? textField(rule, "productKey", "Product ID or SKU", touch, {}, `${prefix}productKey`) : null,
|
|
19
|
+
kind === "override" ? numberField(rule, "price", "Selling price", touch, "An exact price for this product.", `${prefix}price`) : null,
|
|
20
|
+
kind !== "override" ? select(rule, "method", "Pricing method", methods, () => { if (rule.method === "margin" && rule.targetMargin === "") rule.targetMargin = 40; repaint(); }, `${prefix}method`) : null,
|
|
21
|
+
kind !== "override" ? parameter(rule, prefix) : null));
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function renderPricingRules(pricing: RecordValue, context: RuleViewContext): HTMLDetailsElement {
|
|
26
|
+
const { fields: { disclosure }, repaint } = context;
|
|
27
|
+
const exceptions = Array.isArray(pricing.exceptions) ? pricing.exceptions.map(asRecord) : [];
|
|
28
|
+
const tiers = Array.isArray(pricing.costTiers) ? pricing.costTiers.map(asRecord) : [];
|
|
29
|
+
const overrides = Array.isArray(pricing.productOverrides) ? pricing.productOverrides.map(asRecord) : [];
|
|
30
|
+
pricing.exceptions = exceptions; pricing.costTiers = tiers; pricing.productOverrides = overrides;
|
|
31
|
+
const add = (list: RecordValue[], blank: RecordValue, label: string) => {
|
|
32
|
+
const button = h("button", { type: "button", class: "btn secondary small" }, label);
|
|
33
|
+
button.addEventListener("click", () => { list.push({ ...blank, id: crypto.randomUUID() }); repaint(); });
|
|
34
|
+
return button;
|
|
35
|
+
};
|
|
36
|
+
const rules = disclosure("pricing-rules", "Rules & product exceptions", "Customize prices for part of your catalog", [
|
|
37
|
+
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."), renderRuleRows(exceptions, "exception", context),
|
|
38
|
+
add(exceptions, { name: "New rule", scope: "brand", match: "", method: "margin", targetMargin: 40, markup: "", msrpAdjustment: "", fixedMarkup: "" }, "Add rule"),
|
|
39
|
+
h("h3", {}, "Cost tiers"), h("p", { class: "hint" }, "Apply different cost-based pricing to different cost ranges."), renderRuleRows(tiers, "tier", context),
|
|
40
|
+
add(tiers, { minimumCost: 0, maximumCost: "", method: "margin", targetMargin: 40, markup: "", fixedMarkup: "" }, "Add cost tier"),
|
|
41
|
+
h("h3", {}, "Product prices"), renderRuleRows(overrides, "override", context), add(overrides, { productKey: "", price: 0 }, "Add product price"),
|
|
42
|
+
]);
|
|
43
|
+
return rules;
|
|
44
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** SKU method selection and format controls. */
|
|
2
|
+
import { h, renderCatalogMethodPicker, type MethodPickerContext, type RecordValue, type CatalogSettingsFields } from "@/lib/catalog/admin/catalogSettingsFields";
|
|
3
|
+
export function renderSkuMethodPicker(context: MethodPickerContext): HTMLElement { return renderCatalogMethodPicker("sku", context); }
|
|
4
|
+
export function renderSkuControls(sku: RecordValue, fields: CatalogSettingsFields, touch: () => void): HTMLElement[] {
|
|
5
|
+
const { textField, numberField, select } = fields;
|
|
6
|
+
const numbered = ["sequential", "family", "hybrid"].includes(String(sku.method));
|
|
7
|
+
const separated = sku.method !== "random";
|
|
8
|
+
const hasPrefix = !["attributes", "random"].includes(String(sku.method));
|
|
9
|
+
return [h("section", { class: "editor-panel" },
|
|
10
|
+
h("div", { class: "section-heading" }, h("span", { class: "step-number", "aria-hidden": "true" }, "2"), h("h2", {}, "Fine-tune the format")),
|
|
11
|
+
h("div", { class: "fields-grid" },
|
|
12
|
+
hasPrefix ? textField(sku, "prefix", sku.method === "supplier" ? "Supplier prefix" : sku.method === "hybrid" ? "Category prefix" : "Prefix", touch, { maxlength: "12", spellcheck: "false" }) : null,
|
|
13
|
+
numbered ? numberField(sku, "startingNumber", "Starting number", touch, "A whole number. The preview pads it to six digits.") : null,
|
|
14
|
+
separated ? select(sku, "separator", "Separator", [{ value: "-", label: "Hyphen (–)" }, { value: "_", label: "Underscore (_)" }, { value: "", label: "None" }]) : null),
|
|
15
|
+
hasPrefix ? h("p", { class: "hint" }, "An optional prefix of up to 12 uppercase letters or digits, starting with a letter.") : null,
|
|
16
|
+
!separated ? h("p", { class: "hint" }, "No format options needed. Use eight uppercase letters and digits.") : null,
|
|
17
|
+
sku.method === "attributes" ? h("p", { class: "hint" }, "The sample illustrates brand, model, color and size abbreviations.") : null,
|
|
18
|
+
sku.method === "family" ? h("p", { class: "hint" }, "V03 illustrates a variant within a numbered product family.") : null,
|
|
19
|
+
sku.method === "supplier" ? h("p", { class: "hint" }, "AX204 illustrates a supplier’s item code.") : null,
|
|
20
|
+
sku.method === "deterministic" ? h("p", { class: "hint" }, "The sample uses a fixed item identity. Generated codes still need a uniqueness check.") : null)];
|
|
21
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "4.3.
|
|
3
|
+
"version": "4.3.1",
|
|
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",
|