@tokenoftrust/storefront-runner 2.4.28 → 2.4.30
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/admin/catalogSettingsClient.ts +384 -288
- package/apps/storefront/src/lib/admin/catalogSettingsPreview.ts +37 -0
- package/apps/storefront/src/lib/admin/catalogSettingsState.ts +25 -0
- package/apps/storefront/src/lib/admin/productSetupPhoto.ts +23 -0
- package/package.json +1 -1
- package/packages/cli/src/publication-spaces.mjs +72 -0
|
@@ -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
|
-
|
|
7
|
-
|
|
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
|
-
|
|
45
|
-
["
|
|
46
|
-
["
|
|
47
|
-
["
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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(
|
|
105
|
+
function paintStatus(): void {
|
|
113
106
|
if (!state) return;
|
|
114
107
|
const dirty = draftIsDirty(state);
|
|
115
|
-
const
|
|
116
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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 (
|
|
125
|
-
status.replaceChildren(...
|
|
126
|
-
|
|
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:
|
|
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
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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[
|
|
177
|
-
|
|
195
|
+
state.draft[section === "pricing" ? "pricingConfigured" : "skuConfigured"] = false;
|
|
196
|
+
touch(); renderEditor();
|
|
178
197
|
});
|
|
179
|
-
return h("
|
|
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:
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
remove
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
})
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
211
|
-
if (!state) return;
|
|
227
|
+
function pricingControls(): HTMLElement[] {
|
|
228
|
+
if (!state || !isConfigured("pricing")) return [];
|
|
212
229
|
const pricing = state.draft.pricing;
|
|
213
|
-
const
|
|
214
|
-
const
|
|
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
|
-
|
|
220
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
h("
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
297
|
-
const
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
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
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.30",
|
|
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",
|
|
@@ -305,3 +305,75 @@ export function describeOwner(topology, urlPath) {
|
|
|
305
305
|
dir: spaceDir(space) || ".",
|
|
306
306
|
};
|
|
307
307
|
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Route changed workspace files to the repository that ships them. Paths are relative to the
|
|
311
|
+
* root checkout (a space's files as `spaces/<id>/...`). Each file lands in its owning space's
|
|
312
|
+
* group; a root file whose URL a mounted space owns is ALSO named as misrouted (it ships with the
|
|
313
|
+
* root and still never reaches a visitor); a file under `spaces/` naming no declared space is
|
|
314
|
+
* unowned. Pure.
|
|
315
|
+
* @param {Topology} topology
|
|
316
|
+
* @param {string[]} files
|
|
317
|
+
* @returns {{ groups: Array<{ space: TopologySpace, files: string[] }>,
|
|
318
|
+
* misrouted: Array<{ file: string, owner: TopologySpace }>, unowned: string[] }}
|
|
319
|
+
*/
|
|
320
|
+
export function routeWorkspaceChanges(topology, files) {
|
|
321
|
+
const root = rootSpace(topology);
|
|
322
|
+
/** @type {Map<string, string[]>} */
|
|
323
|
+
const byId = new Map(topology.spaces.map((space) => [space.id, []]));
|
|
324
|
+
const misrouted = [];
|
|
325
|
+
const unowned = [];
|
|
326
|
+
for (const file of files) {
|
|
327
|
+
const owner = spaceForWorkspaceFile(topology, file);
|
|
328
|
+
if (!owner) { unowned.push(file); continue; }
|
|
329
|
+
/** @type {string[]} */ (byId.get(owner.space.id)).push(file);
|
|
330
|
+
if (owner.space.mount === "/") {
|
|
331
|
+
const urlPath = rootFileUrlPath(root, file);
|
|
332
|
+
const served = urlPath ? owningSpace(topology, urlPath) : null;
|
|
333
|
+
if (served?.space && served.space.mount !== "/") misrouted.push({ file, owner: served.space });
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return {
|
|
337
|
+
groups: topology.spaces.map((space) => ({ space, files: /** @type {string[]} */ (byId.get(space.id)) })),
|
|
338
|
+
misrouted,
|
|
339
|
+
unowned,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Parse `git status --porcelain=v1 -z` into changed paths (a rename yields its new path). Pure.
|
|
345
|
+
* @param {string} out
|
|
346
|
+
* @returns {string[]}
|
|
347
|
+
*/
|
|
348
|
+
export function parsePorcelainZ(out) {
|
|
349
|
+
const parts = String(out).split("\0");
|
|
350
|
+
const paths = [];
|
|
351
|
+
for (let i = 0; i < parts.length; i++) {
|
|
352
|
+
const entry = parts[i];
|
|
353
|
+
if (!entry || entry.length < 4) continue;
|
|
354
|
+
const status = entry.slice(0, 2);
|
|
355
|
+
paths.push(entry.slice(3));
|
|
356
|
+
if (status[0] === "R" || status[0] === "C") i++; // the next field is the rename's source
|
|
357
|
+
}
|
|
358
|
+
return paths;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Every changed file in a workspace, relative to the root checkout: the root repository's own
|
|
363
|
+
* changes plus each cloned space's (`spaces/<id>/...`). `git(dir, args)` runs git in `dir`.
|
|
364
|
+
* @param {string} workspace
|
|
365
|
+
* @param {Topology} topology
|
|
366
|
+
* @param {(dir: string, args: string[]) => string} git
|
|
367
|
+
* @returns {string[]}
|
|
368
|
+
*/
|
|
369
|
+
export function workspaceChangedFiles(workspace, topology, git) {
|
|
370
|
+
const files = parsePorcelainZ(git(workspace, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]));
|
|
371
|
+
for (const space of mountedSpaces(topology)) {
|
|
372
|
+
const dir = join(workspace, spaceDir(space));
|
|
373
|
+
if (!existsSync(join(dir, ".git"))) continue;
|
|
374
|
+
for (const file of parsePorcelainZ(git(dir, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]))) {
|
|
375
|
+
files.push(`${spaceDir(space)}/${file}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return files;
|
|
379
|
+
}
|