@tokenoftrust/storefront-runner 2.4.13 → 2.4.15

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.
@@ -91,6 +91,15 @@ export const CATALOG_MODEL = {
91
91
  "locale?": "string",
92
92
  accepted_at: "string",
93
93
  facts: "CatalogFact[]",
94
+ "setup_policy?": "CatalogSetupProvenance",
95
+ },
96
+ },
97
+ CatalogSetupPriceAcceptance: { fields: { engine_version: "string", policy_revision: "number", value: "number", input: "Record<string, unknown>" } },
98
+ CatalogSetupSkuAcceptance: { fields: { engine_version: "string", policy_revision: "number", value: "string", input: "Record<string, unknown>" } },
99
+ CatalogSetupProvenance: {
100
+ fields: {
101
+ tenant_id: "string", currency: "Currency",
102
+ "pricing?": "CatalogSetupPriceAcceptance", "sku?": "CatalogSetupSkuAcceptance",
94
103
  },
95
104
  },
96
105
  CatalogOverrideField: { alias: '"title" | "description_html" | "images" | "vendor" | "product_type"' },
@@ -0,0 +1,31 @@
1
+ -- Current merchant pricing and SKU defaults. One row per tenant. `revision` is the
2
+ -- compare-and-swap token: a create lands only while the tenant has no row, and an
3
+ -- update lands only when `revision` is still the caller's expected value.
4
+ CREATE TABLE `merchant_calculation_settings` (
5
+ `tenant_id` text PRIMARY KEY NOT NULL,
6
+ `revision` integer NOT NULL,
7
+ `currency` text NOT NULL,
8
+ `engine_version` text NOT NULL,
9
+ `pricing_json` text NOT NULL,
10
+ `sku_json` text NOT NULL
11
+ );
12
+ --> statement-breakpoint
13
+ -- Append-only copy of each accepted save. Rows are never updated. The current
14
+ -- table is the only compare-and-swap target.
15
+ CREATE TABLE `merchant_calculation_settings_revisions` (
16
+ `id` text PRIMARY KEY NOT NULL,
17
+ `tenant_id` text NOT NULL,
18
+ `revision` integer NOT NULL,
19
+ `currency` text NOT NULL,
20
+ `engine_version` text NOT NULL,
21
+ `pricing_json` text NOT NULL,
22
+ `sku_json` text NOT NULL,
23
+ `actor` text NOT NULL,
24
+ `saved_at` text NOT NULL
25
+ );
26
+ --> statement-breakpoint
27
+ CREATE UNIQUE INDEX `uq_merchant_calculation_settings_revision`
28
+ ON `merchant_calculation_settings_revisions` (`tenant_id`, `revision`);
29
+ --> statement-breakpoint
30
+ CREATE INDEX `idx_merchant_calculation_settings_revisions_tenant`
31
+ ON `merchant_calculation_settings_revisions` (`tenant_id`);
@@ -29,6 +29,23 @@ import { priceForPurchaseOption } from "@tot/public-runtime";
29
29
  import { formatPrice } from "@/lib/format";
30
30
  import { useCheckoutHandoffCorrelation } from "@/components/islands/useCheckoutHandoffCorrelation";
31
31
 
32
+ export type PublicSelectorVariant = Pick<CatalogVariant,
33
+ "id" | "sku" | "price" | "compare_at_price" | "option_values" | "inventory" | "image_id">;
34
+
35
+ /** An explicit allowlist also excludes future internal variant fields from Astro hydration. */
36
+ export function publicSelectorVariants(variants: readonly CatalogVariant[]): PublicSelectorVariant[] {
37
+ return variants.map((variant) => ({
38
+ id: variant.id,
39
+ ...(variant.sku !== undefined ? { sku: variant.sku } : {}),
40
+ price: variant.price,
41
+ ...(variant.compare_at_price !== undefined ? { compare_at_price: variant.compare_at_price } : {}),
42
+ option_values: { ...variant.option_values },
43
+ inventory: { available: variant.inventory.available,
44
+ ...(variant.inventory.quantity !== undefined ? { quantity: variant.inventory.quantity } : {}) },
45
+ ...(variant.image_id !== undefined ? { image_id: variant.image_id } : {}),
46
+ }));
47
+ }
48
+
32
49
  interface OptionDef {
33
50
  name: string;
34
51
  position: number;
@@ -36,7 +53,7 @@ interface OptionDef {
36
53
  }
37
54
 
38
55
  interface Props {
39
- variants: CatalogVariant[];
56
+ variants: PublicSelectorVariant[];
40
57
  options: OptionDef[];
41
58
  currency: string;
42
59
  locale?: string;
@@ -77,14 +94,14 @@ const LOW_STOCK_THRESHOLD = 5;
77
94
 
78
95
  type Selection = Record<string, string>;
79
96
 
80
- function variantMatches(v: CatalogVariant, sel: Selection): boolean {
97
+ function variantMatches(v: PublicSelectorVariant, sel: Selection): boolean {
81
98
  return Object.entries(sel).every(([name, value]) => v.option_values[name] === value);
82
99
  }
83
100
 
84
101
  function findVariant(
85
- variants: CatalogVariant[],
102
+ variants: PublicSelectorVariant[],
86
103
  sel: Selection,
87
- ): CatalogVariant | undefined {
104
+ ): PublicSelectorVariant | undefined {
88
105
  return variants.find((v) => variantMatches(v, sel));
89
106
  }
90
107
 
@@ -0,0 +1,361 @@
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
+ */
5
+ import {
6
+ applySaveResult,
7
+ draftIsDirty,
8
+ editorFromPayload,
9
+ saveBody,
10
+ type SettingsEditorState,
11
+ type SettingsPayload,
12
+ } from "@/lib/admin/catalogSettingsState";
13
+
14
+ interface MethodOption {
15
+ id: string;
16
+ title: string;
17
+ reason: string;
18
+ }
19
+
20
+ interface SettingsResponse extends SettingsPayload {
21
+ ok: true;
22
+ pricingMethods: MethodOption[];
23
+ skuMethods: MethodOption[];
24
+ history: Array<{ revision: number; savedAt: string; actor: string }>;
25
+ historyUnavailable: boolean;
26
+ }
27
+
28
+ type Child = Node | string | null | undefined | false;
29
+
30
+ function h<K extends keyof HTMLElementTagNameMap>(
31
+ tag: K,
32
+ attrs: Record<string, string | boolean | undefined> = {},
33
+ ...children: Child[]
34
+ ): HTMLElementTagNameMap[K] {
35
+ 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
+ }
40
+ for (const child of children) if (child !== null && child !== undefined && child !== false) node.append(child);
41
+ return node;
42
+ }
43
+
44
+ const PRICING_NUMBERS: Array<[string, string]> = [
45
+ ["targetMargin", "Target margin (%)"],
46
+ ["markup", "Markup (%)"],
47
+ ["msrpAdjustment", "MSRP adjustment (%)"],
48
+ ["fixedMarkup", "Dollar markup"],
49
+ ["additionalCost", "Additional cost"],
50
+ ["additionalCostPercent", "Additional cost (%)"],
51
+ ["increment", "Rounding increment"],
52
+ ["minimumPrice", "Minimum price"],
53
+ ["maximumPrice", "Maximum price"],
54
+ ["minimumMargin", "Minimum margin (%)"],
55
+ ];
56
+
57
+ const PRICING_SELECTS: Array<[string, string, string[]]> = [
58
+ ["costBasis", "Cost basis", ["wholesale", "adjusted"]],
59
+ ["rounding", "Rounding", ["none", "increment", "ending"]],
60
+ ["direction", "Round direction", ["up", "nearest", "down"]],
61
+ ["missingData", "Missing data", ["review", "fallback"]],
62
+ ["fallbackMethod", "Fallback method", ["msrp", "margin", "markup", "fixed"]],
63
+ ["updatePolicy", "When costs change", ["on-add", "manual", "automatic"]],
64
+ ];
65
+
66
+ const PRICING_FLAGS: Array<[string, string]> = [
67
+ ["preserveMsrp", "Preserve MSRP as compare-at"],
68
+ ["capAtMsrp", "Cap the price at MSRP"],
69
+ ["includeManualOverrides", "Include manual product overrides"],
70
+ ];
71
+
72
+ const RULE_NUMBERS: Array<[string, string]> = [
73
+ ["targetMargin", "Target margin (%)"],
74
+ ["markup", "Markup (%)"],
75
+ ["msrpAdjustment", "MSRP adjustment (%)"],
76
+ ["fixedMarkup", "Dollar markup"],
77
+ ];
78
+
79
+ function asRecord(value: unknown): Record<string, unknown> {
80
+ return value && typeof value === "object" ? value as Record<string, unknown> : {};
81
+ }
82
+
83
+ function numberOrBlank(raw: string): number | "" {
84
+ if (raw.trim() === "") return "";
85
+ const value = Number(raw);
86
+ return Number.isFinite(value) ? value : "";
87
+ }
88
+
89
+ function fieldString(record: Record<string, unknown>, key: string): string {
90
+ const value = record[key];
91
+ return typeof value === "number" || typeof value === "string" ? String(value) : "";
92
+ }
93
+
94
+ export function initCatalogSettingsPage(root: HTMLElement): void {
95
+ const found = root.querySelector<HTMLElement>("[data-settings-app]");
96
+ const api = root.dataset.api ?? "";
97
+ const previewUrl = root.dataset.preview ?? "";
98
+ if (!found || !api || !previewUrl) return;
99
+ const panel: HTMLElement = found;
100
+
101
+ let state: SettingsEditorState | null = null;
102
+ let storeCurrency = root.dataset.currency ?? "";
103
+ let pricingMethods: MethodOption[] = [];
104
+ let skuMethods: MethodOption[] = [];
105
+ let history: SettingsResponse["history"] = [];
106
+ let historyUnavailable = false;
107
+ const sample: Record<string, number | ""> = { wholesale: "", msrp: "", minimum: "", maximum: "" };
108
+ const sampleText: Record<string, string> = { productKey: "", assortment: "", category: "", brand: "", selectedRuleId: "" };
109
+ const status = h("div", { "aria-live": "polite" });
110
+ const previewOut = h("div", { class: "preview-out" });
111
+
112
+ function paintStatus(extra?: string): void {
113
+ if (!state) return;
114
+ const dirty = draftIsDirty(state);
115
+ const revision = state.savedRevision === null ? "Not saved yet" : `Saved revision ${state.savedRevision}`;
116
+ const lines: Node[] = [h("p", { class: "muted" }, `${revision} · ${state.currency} · ${dirty ? "Unsaved edits" : "Matches the saved defaults"}`)];
117
+ if (storeCurrency && state.currency !== storeCurrency) {
118
+ lines.push(h("p", { class: "notice error", role: "alert" }, `Saved defaults are in ${state.currency}. This store is ${storeCurrency}. No currency conversion is applied.`));
119
+ }
120
+ if (state.conflict) {
121
+ lines.push(h("p", { class: "notice error", role: "alert" }, state.conflict.message));
122
+ lines.push(h("button", { type: "button", class: "btn", "data-reload": "true" }, "Load the saved revision"));
123
+ }
124
+ if (extra) lines.push(h("p", { class: "notice error", role: "alert" }, extra));
125
+ status.replaceChildren(...lines);
126
+ status.querySelector("[data-reload]")?.addEventListener("click", () => void load());
127
+ }
128
+
129
+ function select(record: Record<string, unknown>, key: string, label: string, options: Array<{ value: string; label: string }>, onChange: () => void): HTMLElement {
130
+ const current = fieldString(record, key);
131
+ const known = options.some((option) => option.value === current);
132
+ const control = h("select", { "aria-label": label }) as HTMLSelectElement;
133
+ if (current && !known) control.append(h("option", { value: current }, current));
134
+ for (const option of options) control.append(h("option", { value: option.value }, option.label));
135
+ control.value = current;
136
+ control.addEventListener("change", () => {
137
+ record[key] = control.value;
138
+ onChange();
139
+ });
140
+ return h("label", {}, label, control);
141
+ }
142
+
143
+ function numberField(record: Record<string, unknown>, key: string, label: string, onChange: () => void): HTMLElement {
144
+ const control = h("input", { type: "text", inputmode: "decimal", "aria-label": label, value: fieldString(record, key) }) as HTMLInputElement;
145
+ control.addEventListener("input", () => {
146
+ record[key] = numberOrBlank(control.value);
147
+ onChange();
148
+ });
149
+ return h("label", {}, label, control);
150
+ }
151
+
152
+ function textField(record: Record<string, unknown>, key: string, label: string, onChange: () => void, attrs: Record<string, string> = {}): HTMLElement {
153
+ const control = h("input", { type: "text", "aria-label": label, value: fieldString(record, key), ...attrs }) as HTMLInputElement;
154
+ control.addEventListener("input", () => {
155
+ record[key] = control.value;
156
+ onChange();
157
+ });
158
+ return h("label", {}, label, control);
159
+ }
160
+
161
+ function flag(record: Record<string, unknown>, key: string, label: string, onChange: () => void): HTMLElement {
162
+ const control = h("input", { type: "checkbox" }) as HTMLInputElement;
163
+ control.checked = record[key] === true;
164
+ control.addEventListener("change", () => {
165
+ record[key] = control.checked;
166
+ onChange();
167
+ });
168
+ return h("label", { class: "inline" }, control, ` ${label}`);
169
+ }
170
+
171
+ function configured(key: "pricingConfigured" | "skuConfigured", label: string): HTMLElement {
172
+ const control = h("input", { type: "checkbox" }) as HTMLInputElement;
173
+ control.checked = state?.draft[key] === true;
174
+ control.addEventListener("change", () => {
175
+ if (!state) return;
176
+ state.draft[key] = control.checked;
177
+ paintStatus();
178
+ });
179
+ return h("label", { class: "inline" }, control, ` ${label}`);
180
+ }
181
+
182
+ function ruleRows(list: Record<string, unknown>[], kind: "exception" | "tier" | "override", onChange: () => void): HTMLElement {
183
+ const rows = list.map((rule, index) => {
184
+ const remove = h("button", { type: "button", class: "btn small" }, "Remove");
185
+ remove.addEventListener("click", () => {
186
+ list.splice(index, 1);
187
+ renderForm();
188
+ });
189
+ const methodOptions = (kind === "tier" ? ["margin", "markup", "fixed"] : ["msrp", "margin", "markup", "msrp-adjustment", "fixed"])
190
+ .map((id) => ({ value: id, label: id }));
191
+ return h(
192
+ "div",
193
+ { class: "rule" },
194
+ textField(rule, "id", "Id", onChange),
195
+ kind !== "override" ? null : textField(rule, "productKey", "Product", onChange),
196
+ kind === "override" ? numberField(rule, "price", "Price", onChange) : null,
197
+ kind === "exception" ? textField(rule, "name", "Name", onChange) : null,
198
+ kind === "exception" ? select(rule, "scope", "Scope", ["assortment", "category", "brand"].map((id) => ({ value: id, label: id })), onChange) : null,
199
+ kind === "exception" ? textField(rule, "match", "Match", onChange) : null,
200
+ kind === "tier" ? numberField(rule, "minimumCost", "Minimum cost", onChange) : null,
201
+ kind === "tier" ? numberField(rule, "maximumCost", "Maximum cost", onChange) : null,
202
+ kind === "override" ? null : select(rule, "method", "Method", methodOptions, onChange),
203
+ kind === "override" ? null : h("div", { class: "grid" }, ...RULE_NUMBERS.filter(([key]) => kind === "exception" || key !== "msrpAdjustment").map(([key, label]) => numberField(rule, key, label, onChange))),
204
+ remove,
205
+ );
206
+ });
207
+ return h("div", { class: "rules" }, ...rows);
208
+ }
209
+
210
+ function renderForm(): void {
211
+ if (!state) return;
212
+ const pricing = state.draft.pricing;
213
+ const sku = state.draft.sku;
214
+ const touch = () => paintStatus();
215
+ const exceptions = Array.isArray(pricing.exceptions) ? pricing.exceptions.map(asRecord) : [];
216
+ const tiers = Array.isArray(pricing.costTiers) ? pricing.costTiers.map(asRecord) : [];
217
+ const overrides = Array.isArray(pricing.productOverrides) ? pricing.productOverrides.map(asRecord) : [];
218
+ pricing.exceptions = exceptions;
219
+ pricing.costTiers = tiers;
220
+ pricing.productOverrides = overrides;
221
+
222
+ const add = (list: Record<string, unknown>[], blank: Record<string, unknown>, label: string) => {
223
+ const button = h("button", { type: "button", class: "btn small" }, label);
224
+ button.addEventListener("click", () => {
225
+ list.push({ ...blank, id: `${label.slice(0, 3).toLowerCase()}-${list.length + 1}` });
226
+ renderForm();
227
+ });
228
+ return button;
229
+ };
230
+
231
+ panel.replaceChildren(
232
+ status,
233
+ h("section", { class: "panel" },
234
+ h("h2", {}, "Pricing"),
235
+ configured("pricingConfigured", "Use these pricing defaults"),
236
+ h("div", { class: "grid" },
237
+ select(pricing, "method", "Method", pricingMethods.map((method) => ({ value: method.id, label: method.title })), touch),
238
+ ...PRICING_NUMBERS.map(([key, label]) => numberField(pricing, key, label, touch)),
239
+ ...PRICING_SELECTS.map(([key, label, values]) => select(pricing, key, label, values.map((value) => ({ value, label: value })), touch)),
240
+ textField(pricing, "ending", "Price ending", touch, { maxlength: "2" }),
241
+ ),
242
+ h("div", { class: "flags" }, ...PRICING_FLAGS.map(([key, label]) => flag(pricing, key, label, touch))),
243
+ h("h3", {}, "Exceptions"),
244
+ ruleRows(exceptions, "exception", touch),
245
+ add(exceptions, { name: "Exception", scope: "brand", match: "", method: "margin", targetMargin: 40, markup: "", msrpAdjustment: "", fixedMarkup: "" }, "Add exception"),
246
+ h("h3", {}, "Cost tiers"),
247
+ ruleRows(tiers, "tier", touch),
248
+ add(tiers, { minimumCost: 0, maximumCost: "", method: "margin", targetMargin: 40, markup: "", fixedMarkup: "" }, "Add cost tier"),
249
+ h("h3", {}, "Product price overrides"),
250
+ ruleRows(overrides, "override", touch),
251
+ add(overrides, { productKey: "", price: 0 }, "Add product override"),
252
+ ),
253
+ h("section", { class: "panel" },
254
+ h("h2", {}, "SKUs"),
255
+ configured("skuConfigured", "Use these SKU defaults"),
256
+ h("div", { class: "grid" },
257
+ select(sku, "method", "Method", skuMethods.map((method) => ({ value: method.id, label: method.title })), touch),
258
+ textField(sku, "prefix", "Prefix", touch, { maxlength: "12" }),
259
+ numberField(sku, "startingNumber", "Starting number", touch),
260
+ select(sku, "separator", "Separator", [{ value: "-", label: "Hyphen" }, { value: "_", label: "Underscore" }, { value: "", label: "None" }], touch),
261
+ ),
262
+ ),
263
+ h("section", { class: "panel" },
264
+ h("h2", {}, "Preview a sample"),
265
+ h("p", { class: "muted small" }, "The server calculates this sample with the open draft. Saving defaults does not change prices or SKUs already on your products."),
266
+ h("div", { class: "grid" },
267
+ ...(["wholesale", "msrp", "minimum", "maximum"] as const).map((key) => {
268
+ const control = h("input", { type: "text", inputmode: "decimal", "aria-label": key, value: String(sample[key] ?? "") }) as HTMLInputElement;
269
+ control.addEventListener("input", () => { sample[key] = numberOrBlank(control.value); });
270
+ return h("label", {}, key, control);
271
+ }),
272
+ ...(["productKey", "assortment", "category", "brand", "selectedRuleId"] as const).map((key) => {
273
+ const control = h("input", { type: "text", "aria-label": key, value: sampleText[key] ?? "" }) as HTMLInputElement;
274
+ control.addEventListener("input", () => { sampleText[key] = control.value; });
275
+ return h("label", {}, key, control);
276
+ }),
277
+ ),
278
+ h("button", { type: "button", class: "btn", "data-preview": "true" }, "Calculate sample"),
279
+ previewOut,
280
+ ),
281
+ h("section", { class: "panel" },
282
+ h("h2", {}, "Saved revisions"),
283
+ historyUnavailable ? h("p", { class: "muted" }, "Saved history is unavailable.") : null,
284
+ history.length === 0 && !historyUnavailable ? h("p", { class: "muted" }, "No saved revision yet.") : null,
285
+ ...history.map((entry) => h("p", { class: "small" }, `Revision ${entry.revision} · ${entry.savedAt}`)),
286
+ ),
287
+ h("div", { class: "row" },
288
+ h("button", { type: "button", class: "btn primary", "data-save": "true" }, "Save defaults"),
289
+ ),
290
+ );
291
+ panel.querySelector("[data-preview]")?.addEventListener("click", () => void preview());
292
+ panel.querySelector("[data-save]")?.addEventListener("click", () => void save());
293
+ paintStatus();
294
+ }
295
+
296
+ async function load(): Promise<void> {
297
+ const response = await fetch(api, { headers: { accept: "application/json" } });
298
+ const body = await response.json() as SettingsResponse & { message?: string };
299
+ if (!response.ok || !body.ok) {
300
+ panel.replaceChildren(h("p", { class: "notice error", role: "alert" }, body.message || "Pricing and SKU defaults could not be loaded."));
301
+ return;
302
+ }
303
+ pricingMethods = body.pricingMethods;
304
+ skuMethods = body.skuMethods;
305
+ history = body.history;
306
+ historyUnavailable = body.historyUnavailable;
307
+ state = editorFromPayload(body);
308
+ if (typeof body.storeCurrency === "string") storeCurrency = body.storeCurrency;
309
+ renderForm();
310
+ }
311
+
312
+ async function save(): Promise<void> {
313
+ if (!state) return;
314
+ const current = state;
315
+ if (storeCurrency && current.currency !== storeCurrency) {
316
+ paintStatus(`Saved defaults are in ${current.currency}. This store is ${storeCurrency}. No currency conversion is applied.`);
317
+ return;
318
+ }
319
+ const response = await fetch(api, { method: "PUT", headers: { "content-type": "application/json", accept: "application/json" }, body: JSON.stringify(saveBody(current)) });
320
+ const body = await response.json() as { ok: boolean; code?: string; revision?: number | null; message?: string; issues?: string[]; pricing?: SettingsPayload["pricing"]; sku?: SettingsPayload["sku"] };
321
+ if (body.code === "revision_conflict") {
322
+ state = applySaveResult(current, { ok: false, code: "revision_conflict", revision: typeof body.revision === "number" ? body.revision : null, message: body.message || "These defaults changed. Your edits are still here." });
323
+ paintStatus();
324
+ return;
325
+ }
326
+ if (!response.ok || !body.ok || typeof body.revision !== "number" || !body.pricing || !body.sku) {
327
+ paintStatus([body.message, ...(body.issues ?? [])].filter(Boolean).join(" "));
328
+ return;
329
+ }
330
+ state = applySaveResult(current, { ok: true, revision: body.revision, pricing: body.pricing, sku: body.sku });
331
+ renderForm();
332
+ }
333
+
334
+ async function preview(): Promise<void> {
335
+ if (!state) return;
336
+ const current = state;
337
+ const draft = saveBody(current);
338
+ delete draft.expectedRevision;
339
+ const response = await fetch(previewUrl, {
340
+ method: "POST",
341
+ headers: { "content-type": "application/json", accept: "application/json" },
342
+ body: JSON.stringify({ ...draft, sample: { ...sample, ...Object.fromEntries(Object.entries(sampleText).filter(([, value]) => value !== "")) } }),
343
+ });
344
+ const body = await response.json() as { ok?: boolean; message?: string; issues?: string[]; pricing?: { status: string; preview?: { price: number | null; calculation: string; issues: string[]; ruleExplanation: string } }; sku?: { status: string; value?: string } };
345
+ previewOut.replaceChildren();
346
+ if (!response.ok || !body.ok) {
347
+ previewOut.append(h("p", { class: "notice error" }, [body.message, ...(body.issues ?? [])].filter(Boolean).join(" ")));
348
+ return;
349
+ }
350
+ if (body.pricing?.status === "ok" && body.pricing.preview) {
351
+ const calculated = body.pricing.preview;
352
+ previewOut.append(h("p", {}, calculated.price === null ? "No price" : `${current.currency} ${calculated.price.toFixed(2)}`));
353
+ if (calculated.calculation) previewOut.append(h("p", { class: "muted small" }, calculated.calculation));
354
+ if (calculated.ruleExplanation) previewOut.append(h("p", { class: "muted small" }, calculated.ruleExplanation));
355
+ for (const issue of calculated.issues) previewOut.append(h("p", { class: "notice error" }, issue));
356
+ } else previewOut.append(h("p", { class: "muted" }, "Pricing defaults are off for this draft."));
357
+ previewOut.append(h("p", {}, body.sku?.status === "ok" ? `SKU ${body.sku.value ?? ""}` : "SKU defaults are off for this draft."));
358
+ }
359
+
360
+ void load();
361
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Pure editor state for pricing and SKU defaults.
3
+ * A revision conflict keeps the unsaved draft object. Loading a saved row is a
4
+ * separate, explicit step.
5
+ */
6
+
7
+ export interface SettingsSection {
8
+ configured: boolean;
9
+ settings?: Record<string, unknown>;
10
+ }
11
+
12
+ export interface SettingsPayload {
13
+ contractRevision: string;
14
+ currency: string;
15
+ storeCurrency?: string;
16
+ engineVersion: string;
17
+ revision: number | null;
18
+ pricing: SettingsSection | null;
19
+ sku: SettingsSection | null;
20
+ draftDefaults: { pricing: Record<string, unknown>; sku: Record<string, unknown> };
21
+ }
22
+
23
+ export interface CalculationDraft {
24
+ pricingConfigured: boolean;
25
+ skuConfigured: boolean;
26
+ pricing: Record<string, unknown>;
27
+ sku: Record<string, unknown>;
28
+ }
29
+
30
+ export interface SettingsEditorState {
31
+ contractRevision: string;
32
+ expectedRevision: number | null;
33
+ savedRevision: number | null;
34
+ currency: string;
35
+ engineVersion: string;
36
+ draft: CalculationDraft;
37
+ saved: CalculationDraft | null;
38
+ conflict: { revision: number | null; message: string } | null;
39
+ }
40
+
41
+ export type SaveOutcome =
42
+ | { ok: true; revision: number; pricing: SettingsSection; sku: SettingsSection }
43
+ | { ok: false; code: "revision_conflict"; revision: number | null; message: string }
44
+ | { ok: false; code: string; message?: string };
45
+
46
+ function sectionSettings(section: SettingsSection | null, fallback: Record<string, unknown>, configured: boolean): Record<string, unknown> {
47
+ if (configured && section?.configured === true && section.settings) return structuredClone(section.settings);
48
+ return structuredClone(fallback);
49
+ }
50
+
51
+ export function editorFromPayload(payload: SettingsPayload): SettingsEditorState {
52
+ const draft: CalculationDraft = {
53
+ pricingConfigured: payload.pricing?.configured === true,
54
+ skuConfigured: payload.sku?.configured === true,
55
+ pricing: sectionSettings(payload.pricing, payload.draftDefaults.pricing, payload.pricing?.configured === true),
56
+ sku: sectionSettings(payload.sku, payload.draftDefaults.sku, payload.sku?.configured === true),
57
+ };
58
+ return {
59
+ contractRevision: payload.contractRevision,
60
+ expectedRevision: payload.revision,
61
+ savedRevision: payload.revision,
62
+ currency: payload.currency,
63
+ engineVersion: payload.engineVersion,
64
+ draft,
65
+ saved: structuredClone(draft),
66
+ conflict: null,
67
+ };
68
+ }
69
+
70
+ export function saveBody(state: SettingsEditorState): Record<string, unknown> {
71
+ return {
72
+ contractRevision: state.contractRevision,
73
+ expectedRevision: state.expectedRevision,
74
+ engineVersion: state.engineVersion,
75
+ currency: state.currency,
76
+ pricing: state.draft.pricingConfigured ? { configured: true, settings: state.draft.pricing } : { configured: false },
77
+ sku: state.draft.skuConfigured ? { configured: true, settings: state.draft.sku } : { configured: false },
78
+ };
79
+ }
80
+
81
+ export function draftIsDirty(state: SettingsEditorState): boolean {
82
+ return JSON.stringify(state.draft) !== JSON.stringify(state.saved);
83
+ }
84
+
85
+ /** A conflict leaves the draft reference in place and does not move the expected revision. */
86
+ export function applySaveResult(state: SettingsEditorState, result: SaveOutcome): SettingsEditorState {
87
+ if (result.ok) {
88
+ const next = editorFromPayload({
89
+ contractRevision: state.contractRevision,
90
+ currency: state.currency,
91
+ engineVersion: state.engineVersion,
92
+ revision: result.revision,
93
+ pricing: result.pricing,
94
+ sku: result.sku,
95
+ draftDefaults: { pricing: state.draft.pricing, sku: state.draft.sku },
96
+ });
97
+ return { ...next, conflict: null };
98
+ }
99
+ if (result.code === "revision_conflict" && "revision" in result) {
100
+ return { ...state, draft: state.draft, conflict: { revision: result.revision, message: result.message } };
101
+ }
102
+ return { ...state, draft: state.draft };
103
+ }
@@ -726,3 +726,42 @@ export const orderAttribution = sqliteTable(
726
726
  },
727
727
  (t) => ({ pk: primaryKey({ columns: [t.tenantId, t.externalOrderId] }) }),
728
728
  );
729
+
730
+ // Merchant pricing and SKU defaults. One current row per tenant is the
731
+ // compare-and-swap target (`revision` must still be the caller's expected
732
+ // value). `merchant_calculation_settings_revisions` is the append-only copy of
733
+ // each accepted save: tenant_id is the isolation boundary, actor is an opaque
734
+ // id, and rows are never updated. Absence of a current row means the merchant
735
+ // has not saved defaults.
736
+ export const merchantCalculationSettings = sqliteTable("merchant_calculation_settings", {
737
+ tenantId: text("tenant_id").primaryKey(),
738
+ revision: integer("revision").notNull(),
739
+ currency: text("currency").notNull(),
740
+ engineVersion: text("engine_version").notNull(),
741
+ pricingJson: text("pricing_json").notNull(),
742
+ skuJson: text("sku_json").notNull(),
743
+ });
744
+
745
+ export const merchantCalculationSettingsRevisions = sqliteTable(
746
+ "merchant_calculation_settings_revisions",
747
+ {
748
+ id: text("id").primaryKey(),
749
+ tenantId: text("tenant_id").notNull(),
750
+ revision: integer("revision").notNull(),
751
+ currency: text("currency").notNull(),
752
+ engineVersion: text("engine_version").notNull(),
753
+ pricingJson: text("pricing_json").notNull(),
754
+ skuJson: text("sku_json").notNull(),
755
+ actor: text("actor").notNull(),
756
+ savedAt: text("saved_at").notNull(),
757
+ },
758
+ (t) => ({
759
+ uqMerchantCalculationSettingsRevision: uniqueIndex("uq_merchant_calculation_settings_revision").on(
760
+ t.tenantId,
761
+ t.revision,
762
+ ),
763
+ idxMerchantCalculationSettingsRevisionsTenant: index("idx_merchant_calculation_settings_revisions_tenant").on(
764
+ t.tenantId,
765
+ ),
766
+ }),
767
+ );
@@ -12,7 +12,7 @@ import Badge from "@/components/Badge.astro";
12
12
  import ProductCard from "@/components/ProductCard.astro";
13
13
  import Section from "@/components/Section.astro";
14
14
  import ImageGallery from "@/components/islands/ImageGallery.tsx";
15
- import VariantSelector from "@/components/islands/VariantSelector.tsx";
15
+ import VariantSelector, { publicSelectorVariants } from "@/components/islands/VariantSelector.tsx";
16
16
  import CheckoutComplianceGate from "@/components/islands/CheckoutComplianceGate.tsx";
17
17
  import RecentlyViewed from "@/components/islands/RecentlyViewed.tsx";
18
18
  import NicotineWarning from "@/components/compliance/NicotineWarning.astro";
@@ -361,7 +361,7 @@ const jsonLd = [productJsonLd, breadcrumbLd(crumbs, canonicalBase)];
361
361
  <div class="mt-7">
362
362
  <VariantSelector
363
363
  client:load
364
- variants={product.variants}
364
+ variants={publicSelectorVariants(product.variants)}
365
365
  options={product.options}
366
366
  currency={product.price_range.currency}
367
367
  locale={tenant.locale}
@@ -68,7 +68,7 @@ import HowItWorksSteps from "@/components/subscription/HowItWorksSteps.astro";
68
68
  import SavingsExplainer from "@/components/subscription/SavingsExplainer.astro";
69
69
  import ManageSubscriptionEntry from "@/components/subscription/ManageSubscriptionEntry.astro";
70
70
  import AgeGate from "@/components/islands/AgeGate.tsx";
71
- import VariantSelector from "@/components/islands/VariantSelector.tsx";
71
+ import VariantSelector, { publicSelectorVariants } from "@/components/islands/VariantSelector.tsx";
72
72
  import QuickView from "@/components/islands/QuickView.tsx";
73
73
  import SearchTypeahead from "@/components/islands/SearchTypeahead.tsx";
74
74
  import MobileNav from "@/components/islands/MobileNav.tsx";
@@ -1424,7 +1424,7 @@ const componentVarsInline = [
1424
1424
  <p class="sg-cap" style="margin:22px 0 8px">VariantSelector — PDP option picker (radiogroup + live price/SKU/stock region)</p>
1425
1425
  <VariantSelector
1426
1426
  client:load
1427
- variants={sampleFromProduct.variants}
1427
+ variants={publicSelectorVariants(sampleFromProduct.variants)}
1428
1428
  options={sampleFromProduct.options}
1429
1429
  currency={sampleFromProduct.price_range.currency}
1430
1430
  locale="en-US"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.4.13",
3
+ "version": "2.4.15",
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",
@@ -168,6 +168,31 @@ export interface CatalogLink {
168
168
  /** ISO 8601. */
169
169
  accepted_at: string;
170
170
  facts: CatalogFact[];
171
+ /** Server-verified defaults adopted by the merchant; replayable without the current policy. */
172
+ setup_policy?: CatalogSetupProvenance;
173
+ }
174
+
175
+ export interface CatalogSetupPriceAcceptance {
176
+ engine_version: string;
177
+ policy_revision: number;
178
+ value: number;
179
+ /** Producer schema validated settings, source facts and resolved sample. */
180
+ input: Record<string, unknown>;
181
+ }
182
+
183
+ export interface CatalogSetupSkuAcceptance {
184
+ engine_version: string;
185
+ policy_revision: number;
186
+ value: string;
187
+ /** Producer schema validated settings and identity/attribute inputs. */
188
+ input: Record<string, unknown>;
189
+ }
190
+
191
+ export interface CatalogSetupProvenance {
192
+ tenant_id: string;
193
+ currency: Currency;
194
+ pricing?: CatalogSetupPriceAcceptance;
195
+ sku?: CatalogSetupSkuAcceptance;
171
196
  }
172
197
 
173
198
  /** Product fields a merchant set away from the accepted catalog content. */