@tokenoftrust/storefront-runner 2.4.14 → 2.4.16
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/migrations-apps/0015_merchant_calculation_settings.sql +31 -0
- package/apps/storefront/src/config/publicationSpaceConfig.ts +37 -0
- package/apps/storefront/src/config/publicationSpaceRegistry.ts +47 -19
- package/apps/storefront/src/config/publicationSpaceTopology.ts +73 -0
- package/apps/storefront/src/lib/admin/catalogSettingsClient.ts +361 -0
- package/apps/storefront/src/lib/admin/catalogSettingsState.ts +103 -0
- package/apps/storefront/src/lib/d1/schema-apps.ts +39 -0
- package/package.json +1 -1
|
@@ -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`);
|
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
PublicationSpaceAliasV1,
|
|
3
3
|
PublicationSpaceManifestV1,
|
|
4
4
|
PublicationSpaceSourceV1,
|
|
5
|
+
PublicationSpaceVisibilityV1,
|
|
5
6
|
} from "@tot/public-runtime";
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -34,3 +35,39 @@ export function rootPublicationSpaceConfig(
|
|
|
34
35
|
},
|
|
35
36
|
};
|
|
36
37
|
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A mounted publication space for a tenant — public or internal, at any
|
|
41
|
+
* declared, non-root mount. Visibility drives the authorization, discovery,
|
|
42
|
+
* and asset-access triad together, the same invariant
|
|
43
|
+
* `validatePublicationSpaceManifestV1` enforces: public spaces are open and
|
|
44
|
+
* discoverable, internal spaces are capability-gated and excluded from every
|
|
45
|
+
* public discovery surface.
|
|
46
|
+
*/
|
|
47
|
+
export function mountedPublicationSpaceConfig(
|
|
48
|
+
tenant: string,
|
|
49
|
+
id: string,
|
|
50
|
+
source: PublicationSpaceSourceV1,
|
|
51
|
+
mount: string,
|
|
52
|
+
visibility: PublicationSpaceVisibilityV1 = "public",
|
|
53
|
+
aliases: readonly PublicationSpaceAliasV1[] = [],
|
|
54
|
+
): PublicationSpaceManifestV1 {
|
|
55
|
+
const internal = visibility === "internal";
|
|
56
|
+
return {
|
|
57
|
+
version: 1,
|
|
58
|
+
id,
|
|
59
|
+
tenant,
|
|
60
|
+
source,
|
|
61
|
+
routes: aliases.length ? { canonicalMount: mount, aliases: [...aliases] } : { canonicalMount: mount },
|
|
62
|
+
visibility,
|
|
63
|
+
authorization: internal ? { policy: "capability", capability: "internal-content-reader" } : { policy: "public" },
|
|
64
|
+
discovery: internal
|
|
65
|
+
? { sitemap: false, feeds: false, publicSearch: false, llms: false }
|
|
66
|
+
: { sitemap: true, feeds: true, publicSearch: true, llms: true },
|
|
67
|
+
assets: { namespace: id, access: internal ? "authorized" : "public" },
|
|
68
|
+
renderer: {
|
|
69
|
+
contractVersion: "storefront-v1",
|
|
70
|
+
themeContractVersion: "tenant-theme-v1",
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -1,25 +1,53 @@
|
|
|
1
1
|
import type { PublicationSpaceManifestV1, PublicationSpaceSourceV1 } from "@tot/public-runtime";
|
|
2
|
-
import { rootPublicationSpaceConfig } from "./publicationSpaceConfig.js";
|
|
2
|
+
import { mountedPublicationSpaceConfig, rootPublicationSpaceConfig } from "./publicationSpaceConfig.js";
|
|
3
|
+
import { PUBLICATION_SPACE_TOPOLOGY, type PublicationTopologyDeclarationV1 } from "./publicationSpaceTopology.js";
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
5
|
+
/**
|
|
6
|
+
* A declared source belongs to its tenant when the repo IS the tenant id, or is
|
|
7
|
+
* namespaced under it (`<tenant>-<suffix>`) — the naming convention every
|
|
8
|
+
* declared source in `publicationSpaceTopology.ts` already follows. This is the
|
|
9
|
+
* one check that stops a mounted space from naming a different tenant's
|
|
10
|
+
* repository, by construction rather than by a tenant-id branch here.
|
|
11
|
+
*/
|
|
12
|
+
function sourceBelongsToTenant(tenant: string, source: PublicationSpaceSourceV1): boolean {
|
|
13
|
+
return source.repo === tenant || source.repo.startsWith(`${tenant}-`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Build one tenant's full publication-space manifest set — root plus any
|
|
18
|
+
* mounted public or internal spaces — from its declared topology. Pure
|
|
19
|
+
* resolution over the declaration: no tenant identity is ever branched on
|
|
20
|
+
* here, so this function is identical for every tenant and every mount count.
|
|
21
|
+
*/
|
|
22
|
+
export function publicationSpacesFromTopology(
|
|
23
|
+
tenant: string,
|
|
24
|
+
declaration: PublicationTopologyDeclarationV1,
|
|
25
|
+
): PublicationSpaceManifestV1[] {
|
|
26
|
+
const spaces: PublicationSpaceManifestV1[] = [
|
|
27
|
+
rootPublicationSpaceConfig(tenant, declaration.root.source, declaration.root.aliases ?? []),
|
|
28
|
+
...(declaration.mounted ?? []).map((mounted) =>
|
|
29
|
+
mountedPublicationSpaceConfig(
|
|
30
|
+
tenant,
|
|
31
|
+
mounted.id,
|
|
32
|
+
mounted.source,
|
|
33
|
+
mounted.mount,
|
|
34
|
+
mounted.visibility ?? "public",
|
|
35
|
+
mounted.aliases ?? [],
|
|
36
|
+
)),
|
|
37
|
+
];
|
|
38
|
+
const foreign = spaces.find((space) => !sourceBelongsToTenant(tenant, space.source));
|
|
39
|
+
if (foreign) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`publication_foreign_source: space '${foreign.id}' for tenant '${tenant}' declares a source ` +
|
|
42
|
+
`('${foreign.source.forge}:${foreign.source.repo}') that is not namespaced under its own tenant.`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
return spaces;
|
|
46
|
+
}
|
|
19
47
|
|
|
20
48
|
/** Node-safe publication topology authority shared by runtime config and CI composition. */
|
|
21
49
|
export function publicationSpacesForTenant(tenant: string): PublicationSpaceManifestV1[] {
|
|
22
|
-
const
|
|
23
|
-
if (!
|
|
24
|
-
return
|
|
50
|
+
const declaration = PUBLICATION_SPACE_TOPOLOGY[tenant];
|
|
51
|
+
if (!declaration) return [];
|
|
52
|
+
return publicationSpacesFromTopology(tenant, declaration);
|
|
25
53
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { PublicationSpaceAliasV1, PublicationSpaceSourceV1, PublicationSpaceVisibilityV1 } from "@tot/public-runtime";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One declared non-root publication space: a mount, its source, and its
|
|
5
|
+
* visibility. Defaults to "public" — an "internal" declaration is the only
|
|
6
|
+
* way a tenant gets a capability-gated space.
|
|
7
|
+
*/
|
|
8
|
+
export interface MountedPublicationSpaceDeclarationV1 {
|
|
9
|
+
id: string;
|
|
10
|
+
source: PublicationSpaceSourceV1;
|
|
11
|
+
mount: string;
|
|
12
|
+
visibility?: PublicationSpaceVisibilityV1;
|
|
13
|
+
aliases?: readonly PublicationSpaceAliasV1[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A tenant's complete declared publication topology: exactly one root, plus any mounted spaces. */
|
|
17
|
+
export interface PublicationTopologyDeclarationV1 {
|
|
18
|
+
root: {
|
|
19
|
+
source: PublicationSpaceSourceV1;
|
|
20
|
+
aliases?: readonly PublicationSpaceAliasV1[];
|
|
21
|
+
};
|
|
22
|
+
mounted?: readonly MountedPublicationSpaceDeclarationV1[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The declared publication topology for every provisioned tenant — the ONLY place a tenant's
|
|
27
|
+
* publication sources are named. `publicationSpaceRegistry.ts` resolves this generically, with
|
|
28
|
+
* no branching on tenant id; runtime config, CI composition, and both CDN publish paths all
|
|
29
|
+
* consume what it resolves, never this table directly.
|
|
30
|
+
*/
|
|
31
|
+
export const PUBLICATION_SPACE_TOPOLOGY: Readonly<Record<string, PublicationTopologyDeclarationV1>> = {
|
|
32
|
+
home: { root: { source: { forge: "fixture", repo: "home", defaultBranch: "main" } } },
|
|
33
|
+
"tokenoftrust.com": {
|
|
34
|
+
root: { source: { forge: "gitea", repo: "tokenoftrust.com-main", defaultBranch: "main" } },
|
|
35
|
+
},
|
|
36
|
+
"storefront.tokenoftrust.store": {
|
|
37
|
+
root: { source: { forge: "gitea", repo: "storefront.tokenoftrust.store", defaultBranch: "main" } },
|
|
38
|
+
},
|
|
39
|
+
"sample-store.com": {
|
|
40
|
+
root: { source: { forge: "gitea", repo: "sample-store.com", defaultBranch: "main" } },
|
|
41
|
+
},
|
|
42
|
+
"sample-store.e2e.test": {
|
|
43
|
+
root: { source: { forge: "gitea", repo: "sample-store.e2e.test", defaultBranch: "main" } },
|
|
44
|
+
},
|
|
45
|
+
"verify.e2e.test": {
|
|
46
|
+
root: { source: { forge: "fixture", repo: "verify.e2e.test", defaultBranch: "main" } },
|
|
47
|
+
},
|
|
48
|
+
// Carries the mounted public + mounted internal coverage every tenant topology can declare —
|
|
49
|
+
// see publicationSpaces.test.ts for the 3-space resolution this exercises.
|
|
50
|
+
"fixture.e2e.test": {
|
|
51
|
+
root: { source: { forge: "fixture", repo: "fixture.e2e.test", defaultBranch: "main" } },
|
|
52
|
+
mounted: [
|
|
53
|
+
{
|
|
54
|
+
id: "fixture-docs",
|
|
55
|
+
source: { forge: "fixture", repo: "fixture.e2e.test-docs", defaultBranch: "main" },
|
|
56
|
+
mount: "/docs",
|
|
57
|
+
visibility: "public",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: "fixture-internal",
|
|
61
|
+
source: { forge: "fixture", repo: "fixture.e2e.test-internal", defaultBranch: "main" },
|
|
62
|
+
mount: "/internal-docs",
|
|
63
|
+
visibility: "internal",
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
"chrome.e2e.test": {
|
|
68
|
+
root: { source: { forge: "fixture", repo: "chrome.e2e.test", defaultBranch: "main" } },
|
|
69
|
+
},
|
|
70
|
+
"catalog.e2e.test": {
|
|
71
|
+
root: { source: { forge: "fixture", repo: "catalog.e2e.test", defaultBranch: "main" } },
|
|
72
|
+
},
|
|
73
|
+
};
|
|
@@ -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
|
+
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.16",
|
|
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",
|