@tokenoftrust/storefront-runner 3.12.3 → 4.0.0

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.
@@ -454,10 +454,16 @@ a.article-card__tag:hover { text-decoration: underline; }
454
454
  .site-footer[data-variant="light-collapsible"] .footer-disclaimer p { color: var(--color-text); }
455
455
 
456
456
  /* --- Reveal-on-scroll (raw-chrome markup uses .reveal/.in) ---------------- */
457
- .reveal { opacity: 0; transform: translateY(18px); transition: opacity .5s ease, transform .5s ease; }
457
+ /* Unmarked .reveal stays visible. Opacity 0 applies only with .is-below,
458
+ the same below-the-fold mark as [data-reveal], so an in-viewport block
459
+ is not hidden before a script adds .in. */
460
+ .reveal { opacity: 1; }
461
+ .reveal.is-below { transition: opacity .5s ease, transform .5s ease; }
462
+ .reveal.is-below:not(.in) { opacity: 0; transform: translateY(18px); }
458
463
  .reveal.in { opacity: 1; transform: none; }
459
464
  @media (prefers-reduced-motion: reduce) {
460
- .reveal { opacity: 1; transform: none; transition: none; }
465
+ .reveal,
466
+ .reveal.is-below:not(.in) { opacity: 1; transform: none; transition: none; }
461
467
  }
462
468
 
463
469
  /* --- Responsive ----------------------------------------------------------- */
@@ -582,14 +582,26 @@ const footerWidgets = await renderWidgetSlot(
582
582
  mountWidgetPostMessageBridge();
583
583
  </script>
584
584
 
585
- {/* Scroll-reveal + analytics pageview hook. ~0.5KB, no framework. */}
585
+ {/* Scroll-reveal. Initial-viewport blocks stay visible (no .is-below).
586
+ Only blocks below the fold start hidden, then .is-in shows them. */}
586
587
  <script>
587
- // Reveal-on-scroll (respects reduced motion via CSS).
588
+ // Reveal-on-scroll. Reduced motion stays visible via CSS and .is-in.
588
589
  const els = document.querySelectorAll("[data-reveal]");
589
- if (
590
- els.length &&
591
- !window.matchMedia("(prefers-reduced-motion: reduce)").matches
592
- ) {
590
+ const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
591
+ const fold = window.innerHeight || document.documentElement.clientHeight || 0;
592
+ const below: Element[] = [];
593
+ for (const el of els) {
594
+ const rect = el.getBoundingClientRect();
595
+ const laidOut = rect.width > 0 && rect.height > 0;
596
+ const inView = !laidOut || fold <= 0 || (rect.bottom > 0 && rect.top < fold);
597
+ if (reduce || inView || typeof IntersectionObserver !== "function") {
598
+ el.classList.add("is-in");
599
+ } else {
600
+ el.classList.add("is-below");
601
+ below.push(el);
602
+ }
603
+ }
604
+ if (below.length) {
593
605
  const io = new IntersectionObserver(
594
606
  (entries) => {
595
607
  for (const e of entries) {
@@ -601,9 +613,7 @@ const footerWidgets = await renderWidgetSlot(
601
613
  },
602
614
  { rootMargin: "0px 0px -8% 0px", threshold: 0.05 },
603
615
  );
604
- els.forEach((el) => io.observe(el));
605
- } else {
606
- els.forEach((el) => el.classList.add("is-in"));
616
+ for (const el of below) io.observe(el);
607
617
  }
608
618
  </script>
609
619
 
@@ -51,13 +51,10 @@ function computeSettingsViewModel(envelope: AdminTenantEnvelope): SettingsViewMo
51
51
  aiPolicyMode: "approval_required",
52
52
  internalTenantPickerEnabled: true,
53
53
  auditTrail,
54
- // The admin console renders from mock data; the checkout-styling editor
55
- // starts from the neutral baseline (an un-styled tenant). A real published
56
- // override is resolved server-side via resolveCheckoutStyle at render time
57
- // once the read path is wired in — until then the editor shows the default,
58
- // and SAVE is already live (POST /api/admin/checkout-style publishes to the
59
- // versioned spine). Cloning keeps the shared DEFAULT_CHECKOUT_STYLE constant
60
- // immutable if a caller mutates the view model.
54
+ // The neutral baseline (an un-styled tenant). The Settings tab renders the
55
+ // store's draft (resolveCheckoutStyle on `test`) in its place, and falls
56
+ // back to this only when the store cannot be read. Cloning keeps the shared
57
+ // DEFAULT_CHECKOUT_STYLE constant immutable if a caller mutates the view model.
61
58
  checkoutStyle: {
62
59
  ...DEFAULT_CHECKOUT_STYLE,
63
60
  colors: { ...DEFAULT_CHECKOUT_STYLE.colors },
@@ -1,16 +1,17 @@
1
1
  /** Browser wiring for the /admin "Checkout requirements" form: collect the switches, POST, report. */
2
2
 
3
+ import { SETTING_SAVED_EVENT, savedMessage } from "./settingDraftActions";
4
+
3
5
  type StatusState = "ok" | "err";
4
6
 
5
7
  interface SaveResponse {
6
8
  readBack?: string;
9
+ inEffect?: string[];
7
10
  notice?: string;
8
11
  error?: string;
9
12
  errors?: unknown;
10
13
  }
11
14
 
12
- export const SAVED_IN_EFFECT = "Saved. In effect on your preview and live store.";
13
-
14
15
  export function saveFailureMessage(status: number, data: SaveResponse): string {
15
16
  if (Array.isArray(data.errors) && data.errors.length) return data.errors.join(" ");
16
17
  return data.error || `Save failed (${status})`;
@@ -18,12 +19,13 @@ export function saveFailureMessage(status: number, data: SaveResponse): string {
18
19
 
19
20
  /**
20
21
  * What the form reports for a save response. "Saved" only when the server read the save back
21
- * through the readers' own resolver (`readBack: "in-effect"`); an accepted save the edge has not
22
- * picked up yet reports its notice without the ok state.
22
+ * through the readers' own resolver (`readBack: "in-effect"`), naming where it is in effect: the
23
+ * draft the preview reads, or also live on a store with no live site. An accepted save the edge has
24
+ * not picked up yet reports its notice without the ok state.
23
25
  */
24
26
  export function saveResultStatus(httpOk: boolean, status: number, data: SaveResponse): { message: string; state?: StatusState } {
25
27
  if (!httpOk) return { message: saveFailureMessage(status, data), state: "err" };
26
- if (data.readBack === "in-effect") return { message: SAVED_IN_EFFECT, state: "ok" };
28
+ if (data.readBack === "in-effect") return { message: savedMessage(data.inEffect), state: "ok" };
27
29
  if (data.readBack === "pending") return { message: data.notice || "Accepted. Reload to confirm it is in effect." };
28
30
  return { message: "The save could not be confirmed in effect. Reload to check.", state: "err" };
29
31
  }
@@ -60,6 +62,7 @@ export function mountCheckoutRequirementsForm(form: HTMLElement): void {
60
62
  }
61
63
  const result = saveResultStatus(res.ok, res.status, data);
62
64
  setStatus(result.message, result.state);
65
+ if (res.ok) document.dispatchEvent(new CustomEvent(SETTING_SAVED_EVENT, { detail: { kind: "checkout-requirements" } }));
63
66
  } catch {
64
67
  setStatus("Save failed — network error", "err");
65
68
  } finally {
@@ -0,0 +1,110 @@
1
+ /** Browser wiring for the /admin "Checkout styling" editor: live preview, collect the fields, POST, report. */
2
+
3
+ import { saveResultStatus } from "./checkoutRequirementsForm";
4
+ import { SETTING_SAVED_EVENT } from "./settingDraftActions";
5
+
6
+ type StatusState = "ok" | "err";
7
+
8
+ /** data-cs-field → the preview CSS custom property it drives. */
9
+ const CSS_VAR: Readonly<Record<string, string>> = {
10
+ "colors.background": "--cs-bg",
11
+ "colors.surface": "--cs-surface",
12
+ "colors.primary": "--cs-primary",
13
+ "colors.primaryContrast": "--cs-primary-contrast",
14
+ "colors.accent": "--cs-accent",
15
+ "colors.text": "--cs-text",
16
+ "typography.fontFamily": "--cs-font",
17
+ "typography.baseSize": "--cs-size",
18
+ "button.radius": "--cs-radius",
19
+ };
20
+
21
+ export interface CheckoutStyleFields {
22
+ colors: Record<string, string>;
23
+ typography: Record<string, string>;
24
+ button: Record<string, string>;
25
+ logoUrl?: string;
26
+ }
27
+
28
+ /** The CheckoutStyle partial the save endpoint validates, from `[field path, value]` pairs. */
29
+ export function collectCheckoutStyle(entries: Iterable<readonly [string, string]>): CheckoutStyleFields {
30
+ const style: CheckoutStyleFields = { colors: {}, typography: {}, button: {} };
31
+ for (const [path, raw] of entries) {
32
+ const value = raw.trim();
33
+ if (path === "logoUrl") {
34
+ if (value) style.logoUrl = value;
35
+ continue;
36
+ }
37
+ const dot = path.indexOf(".");
38
+ const group = path.slice(0, dot);
39
+ if (group === "colors" || group === "typography" || group === "button") style[group][path.slice(dot + 1)] = value;
40
+ }
41
+ return style;
42
+ }
43
+
44
+ export function mountCheckoutStyleForm(form: HTMLElement): void {
45
+ const endpoint = form.dataset.endpoint || "/api/preview/checkout-style";
46
+ // The tenant this save targets: the routed tenant for a merchant, or the staff picker's
47
+ // "operating as" tenant. The endpoint re-authorizes it.
48
+ const targetTenant = form.dataset.targetTenant || "";
49
+ const preview = form.querySelector<HTMLElement>("[data-cs-preview]");
50
+ const logoImg = form.querySelector<HTMLImageElement>("[data-cs-logo]");
51
+ const status = form.querySelector<HTMLElement>("[data-checkout-style-status]");
52
+ const saveBtn = form.querySelector<HTMLButtonElement>("[data-checkout-style-save]");
53
+ const fields = Array.from(form.querySelectorAll<HTMLInputElement | HTMLSelectElement>("[data-cs-field]"));
54
+
55
+ const setStatus = (message: string, state?: StatusState) => {
56
+ if (!status) return;
57
+ status.textContent = message;
58
+ if (state) status.dataset.state = state;
59
+ else delete status.dataset.state;
60
+ };
61
+
62
+ // Reflect edits into the preview live. Inline style ATTRIBUTES are the one CSP-tolerated
63
+ // relaxation (style-src-attr 'unsafe-inline'); no <style> tag is injected.
64
+ const applyPreview = () => {
65
+ for (const el of fields) {
66
+ const path = el.dataset.csField ?? "";
67
+ const value = el.value.trim();
68
+ const cssVar = CSS_VAR[path];
69
+ if (cssVar && preview) preview.style.setProperty(cssVar, value);
70
+ const swatch = form.querySelector<HTMLElement>(`[data-cs-swatch="${path}"]`);
71
+ if (swatch) swatch.style.background = value;
72
+ if (path === "logoUrl" && logoImg) {
73
+ if (value) logoImg.src = value;
74
+ else logoImg.removeAttribute("src");
75
+ logoImg.hidden = !value;
76
+ }
77
+ }
78
+ };
79
+ for (const el of fields) el.addEventListener("input", applyPreview);
80
+
81
+ saveBtn?.addEventListener("click", async () => {
82
+ setStatus("Saving…");
83
+ saveBtn.disabled = true;
84
+ try {
85
+ const style = collectCheckoutStyle(fields.map((el) => [el.dataset.csField ?? "", el.value] as const));
86
+ const res = await fetch(endpoint, {
87
+ method: "POST",
88
+ headers: { "content-type": "application/json" },
89
+ body: JSON.stringify(targetTenant ? { ...style, targetTenant } : style),
90
+ });
91
+ let data: Parameters<typeof saveResultStatus>[2] & { versionId?: unknown } = {};
92
+ try {
93
+ data = (await res.json()) as typeof data;
94
+ } catch {
95
+ // non-JSON error body
96
+ }
97
+ const result = saveResultStatus(res.ok, res.status, data);
98
+ const version = result.state === "ok" && typeof data.versionId === "string" ? ` (version ${data.versionId.slice(0, 8)})` : "";
99
+ setStatus(result.message + version, result.state);
100
+ if (res.ok) document.dispatchEvent(new CustomEvent(SETTING_SAVED_EVENT, { detail: { kind: "checkout-style" } }));
101
+ } catch {
102
+ setStatus("Save failed — network error", "err");
103
+ } finally {
104
+ saveBtn.disabled = false;
105
+ }
106
+ });
107
+
108
+ // The preview coupon link is illustrative only; don't navigate.
109
+ form.querySelector("[data-checkout-styling-noop]")?.addEventListener("click", (e) => e.preventDefault());
110
+ }
@@ -0,0 +1,367 @@
1
+ /**
2
+ * Browser wiring for operational settings' draft/live state in /admin.
3
+ *
4
+ * - Under each Settings panel, one setting's block: "Live" when the draft equals live, otherwise
5
+ * the pending changes in plain words with Publish to live and Discard draft.
6
+ * - On the Publish tab, "Settings waiting to go live": every pending setting with its changes,
7
+ * Publish settings, and Roll back to a prior live value. Each pending setting carries
8
+ * `[data-settings-pin]` with the versionIds it shows, which the publish-live dialog pins.
9
+ *
10
+ * Each action echoes back the versionIds shown, so the server refuses it when the draft or live
11
+ * value moved since. A save announces itself with `SETTING_SAVED_EVENT`, and the blocks re-read.
12
+ */
13
+
14
+ export const SETTING_SAVED_EVENT = "operational-setting:saved";
15
+
16
+ export interface SettingChangeView {
17
+ label: string;
18
+ live: string;
19
+ draft: string;
20
+ }
21
+
22
+ export interface SettingHistoryView {
23
+ versionId: string;
24
+ at: string;
25
+ }
26
+
27
+ export interface SettingStatusView {
28
+ kind: string;
29
+ title?: string;
30
+ path?: string;
31
+ draftVersionId: string | null;
32
+ liveVersionId: string | null;
33
+ pending: boolean;
34
+ changes: SettingChangeView[];
35
+ history?: SettingHistoryView[];
36
+ }
37
+
38
+ interface StatusResponse {
39
+ hasLiveSite?: boolean;
40
+ settings?: SettingStatusView[];
41
+ }
42
+
43
+ interface ActionResponse {
44
+ readBack?: string;
45
+ notice?: string;
46
+ error?: string;
47
+ settings?: StatusResponse;
48
+ }
49
+
50
+ type StatusState = "ok" | "err";
51
+
52
+ /** "Enforce compliance rules: live off → draft on". */
53
+ export function changeSentence(change: SettingChangeView): string {
54
+ return `${change.label}: live ${change.live} → draft ${change.draft}`;
55
+ }
56
+
57
+ /** Tell the other settings blocks on the page that a setting's draft or live value changed. */
58
+ export function announceSettingChanged(kind: string): void {
59
+ document.dispatchEvent(new CustomEvent(SETTING_SAVED_EVENT, { detail: { kind } }));
60
+ }
61
+
62
+ /** A prior live value, by when it went live: "Live from 2026-10-24 09:12 UTC". */
63
+ export function historyLabel(entry: SettingHistoryView): string {
64
+ const at = new Date(entry.at);
65
+ const when = Number.isNaN(at.getTime()) ? entry.at : `${at.toISOString().slice(0, 16).replace("T", " ")} UTC`;
66
+ return `Live from ${when}`;
67
+ }
68
+
69
+ /** What a save reports, by the envs it is in effect on. */
70
+ export function savedMessage(inEffect: readonly string[] | undefined): string {
71
+ return inEffect?.includes("live")
72
+ ? "Saved. In effect on your preview and live store."
73
+ : "Saved to your draft. Your preview shows it; publish it to put it on your live store.";
74
+ }
75
+
76
+ export function publishBody(setting: SettingStatusView, targetTenant: string): Record<string, unknown> {
77
+ return {
78
+ kind: setting.kind,
79
+ action: "publish",
80
+ versionId: setting.draftVersionId,
81
+ expectedLiveVersionId: setting.liveVersionId,
82
+ ...(targetTenant ? { targetTenant } : {}),
83
+ };
84
+ }
85
+
86
+ export function rollbackBody(setting: SettingStatusView, versionId: string, targetTenant: string): Record<string, unknown> {
87
+ return {
88
+ kind: setting.kind,
89
+ action: "rollback",
90
+ versionId,
91
+ expectedLiveVersionId: setting.liveVersionId,
92
+ ...(targetTenant ? { targetTenant } : {}),
93
+ };
94
+ }
95
+
96
+ export function discardBody(setting: SettingStatusView, targetTenant: string): Record<string, unknown> {
97
+ return {
98
+ kind: setting.kind,
99
+ expectedDraftVersionId: setting.draftVersionId,
100
+ ...(targetTenant ? { targetTenant } : {}),
101
+ };
102
+ }
103
+
104
+ /** What a publish, rollback or discard reports. "Done" only when the server read it back in effect. */
105
+ export function actionResultStatus(
106
+ action: "publish" | "rollback" | "discard",
107
+ httpOk: boolean,
108
+ status: number,
109
+ data: ActionResponse,
110
+ ): { message: string; state?: StatusState } {
111
+ if (!httpOk) return { message: data.error || `That didn't work (${status}). Reload and try again.`, state: "err" };
112
+ if (data.readBack === "pending") return { message: data.notice || "Accepted. Reload to confirm it is in effect." };
113
+ if (data.readBack !== "in-effect") return { message: "It could not be confirmed in effect. Reload to check.", state: "err" };
114
+ if (action === "discard") return { message: "Draft discarded. Your preview shows the live value again.", state: "ok" };
115
+ if (action === "rollback") return { message: "Rolled back. Your live store reads the version you picked.", state: "ok" };
116
+ return { message: "Published. Your live store reads it now.", state: "ok" };
117
+ }
118
+
119
+ async function readJson<T>(res: Response): Promise<T> {
120
+ try {
121
+ return (await res.json()) as T;
122
+ } catch {
123
+ return {} as T;
124
+ }
125
+ }
126
+
127
+ function readSetting(el: HTMLElement): SettingStatusView {
128
+ return {
129
+ kind: el.dataset.kind ?? "",
130
+ draftVersionId: el.dataset.draftVersionId || null,
131
+ liveVersionId: el.dataset.liveVersionId || null,
132
+ pending: el.dataset.pending === "true",
133
+ changes: [],
134
+ };
135
+ }
136
+
137
+ /** Render a status into the block's existing markup: text only, never HTML. */
138
+ export function renderSettingStatus(el: HTMLElement, setting: SettingStatusView): void {
139
+ el.dataset.draftVersionId = setting.draftVersionId ?? "";
140
+ el.dataset.liveVersionId = setting.liveVersionId ?? "";
141
+ el.dataset.pending = setting.pending ? "true" : "false";
142
+ const live = el.querySelector<HTMLElement>("[data-setting-draft-live]");
143
+ const pending = el.querySelector<HTMLElement>("[data-setting-draft-pending]");
144
+ const list = el.querySelector<HTMLElement>("[data-setting-draft-changes]");
145
+ if (live) live.hidden = setting.pending;
146
+ if (pending) pending.hidden = !setting.pending;
147
+ if (list) {
148
+ list.replaceChildren(
149
+ ...setting.changes.map((change) => {
150
+ const li = document.createElement("li");
151
+ li.textContent = changeSentence(change);
152
+ return li;
153
+ }),
154
+ );
155
+ }
156
+ }
157
+
158
+ export function mountSettingDraftStatus(el: HTMLElement): void {
159
+ const endpoint = el.dataset.endpoint || "/api/preview/settings-live";
160
+ const targetTenant = el.dataset.targetTenant || "";
161
+ const status = el.querySelector<HTMLElement>("[data-setting-draft-status]");
162
+ const buttons = [...el.querySelectorAll<HTMLButtonElement>("[data-setting-draft-publish], [data-setting-draft-discard]")];
163
+ const setStatus = (message: string, state?: StatusState) => {
164
+ if (!status) return;
165
+ status.textContent = message;
166
+ if (state) status.dataset.state = state;
167
+ else delete status.dataset.state;
168
+ };
169
+ const apply = (data: StatusResponse | undefined) => {
170
+ const next = data?.settings?.find((s) => s.kind === el.dataset.kind);
171
+ if (next) renderSettingStatus(el, next);
172
+ };
173
+ const refresh = async (): Promise<string | null> => {
174
+ try {
175
+ apply(await readStatus(endpoint, targetTenant));
176
+ return null;
177
+ } catch (e) {
178
+ return reasonOf(e);
179
+ }
180
+ };
181
+ const act = async (action: "publish" | "discard") => {
182
+ const setting = readSetting(el);
183
+ setStatus(action === "publish" ? "Publishing…" : "Discarding…");
184
+ for (const b of buttons) b.disabled = true;
185
+ try {
186
+ const res = await fetch(endpoint, {
187
+ method: action === "publish" ? "POST" : "DELETE",
188
+ headers: { "content-type": "application/json" },
189
+ body: JSON.stringify(action === "publish" ? publishBody(setting, targetTenant) : discardBody(setting, targetTenant)),
190
+ });
191
+ const data = await readJson<ActionResponse>(res);
192
+ let result = actionResultStatus(action, res.ok, res.status, data);
193
+ if (res.ok) apply(data.settings);
194
+ else result = withStale(result, await refresh());
195
+ setStatus(result.message, result.state);
196
+ if (res.ok) announceSettingChanged(el.dataset.kind ?? "");
197
+ // The form above still shows the discarded draft; reload it onto the value the preview reads now.
198
+ if (res.ok && action === "discard") window.location.reload();
199
+ } catch {
200
+ setStatus("That didn't work — network error", "err");
201
+ } finally {
202
+ for (const b of buttons) b.disabled = false;
203
+ }
204
+ };
205
+ el.querySelector("[data-setting-draft-publish]")?.addEventListener("click", () => void act("publish"));
206
+ el.querySelector("[data-setting-draft-discard]")?.addEventListener("click", () => void act("discard"));
207
+ document.addEventListener(SETTING_SAVED_EVENT, (event) => {
208
+ if ((event as CustomEvent<{ kind?: string }>).detail?.kind !== el.dataset.kind) return;
209
+ void refresh().then((stale) => stale && setStatus(staleMessage(stale), "err"));
210
+ });
211
+ }
212
+
213
+ function statusSetter(el: Element | null) {
214
+ return (message: string, state?: StatusState) => {
215
+ if (!(el instanceof HTMLElement)) return;
216
+ el.textContent = message;
217
+ if (state) el.dataset.state = state;
218
+ else delete el.dataset.state;
219
+ };
220
+ }
221
+
222
+ const reasonOf = (e: unknown): string => (e instanceof Error ? e.message : String(e));
223
+
224
+ const staleMessage = (reason: string): string => `Could not refresh the settings status (${reason}). Reload the page before acting on it.`;
225
+
226
+ /** An action's result, told that the status shown beside it could not be refreshed. */
227
+ function withStale(result: { message: string; state?: StatusState }, stale: string | null): { message: string; state?: StatusState } {
228
+ return stale ? { message: `${result.message} ${staleMessage(stale)}`, state: "err" } : result;
229
+ }
230
+
231
+ /** The settings status; throws with the reason when it cannot be read. */
232
+ export async function readStatus(endpoint: string, targetTenant: string): Promise<StatusResponse> {
233
+ const query = targetTenant ? `?targetTenant=${encodeURIComponent(targetTenant)}` : "";
234
+ const res = await fetch(`${endpoint}${query}`, { headers: { accept: "application/json" } });
235
+ const data = await readJson<StatusResponse & { error?: string }>(res);
236
+ if (!res.ok || !Array.isArray(data.settings)) throw new Error(data.error ?? `HTTP ${res.status}`);
237
+ return data;
238
+ }
239
+
240
+ /** Render the Publish tab's pending list and rollback pickers: text only, never HTML. */
241
+ export function renderSettingsGoLive(el: HTMLElement, status: StatusResponse): void {
242
+ const settings = status.settings ?? [];
243
+ const pending = settings.filter((s) => s.pending);
244
+ const list = el.querySelector<HTMLElement>("[data-settings-go-live-list]");
245
+ const none = el.querySelector<HTMLElement>("[data-settings-go-live-none]");
246
+ const publish = el.querySelector<HTMLButtonElement>("[data-settings-go-live-publish]");
247
+ if (list) {
248
+ list.replaceChildren(
249
+ ...pending.map((setting) => {
250
+ const li = document.createElement("li");
251
+ li.dataset.settingsPin = "";
252
+ li.dataset.kind = setting.kind;
253
+ li.dataset.settingsPath = setting.path ?? "";
254
+ li.dataset.draftVersionId = setting.draftVersionId ?? "";
255
+ li.dataset.liveVersionId = setting.liveVersionId ?? "";
256
+ const title = document.createElement("b");
257
+ title.textContent = setting.title ?? setting.kind;
258
+ const changes = document.createElement("ul");
259
+ changes.replaceChildren(
260
+ ...setting.changes.map((change) => {
261
+ const item = document.createElement("li");
262
+ item.textContent = changeSentence(change);
263
+ return item;
264
+ }),
265
+ );
266
+ li.append(title, changes);
267
+ return li;
268
+ }),
269
+ );
270
+ }
271
+ if (none) none.hidden = pending.length > 0;
272
+ if (publish) publish.hidden = pending.length === 0;
273
+ for (const row of el.querySelectorAll<HTMLElement>("[data-settings-rollback]")) {
274
+ const setting = settings.find((s) => s.kind === row.dataset.kind);
275
+ const select = row.querySelector<HTMLSelectElement>("select");
276
+ row.dataset.liveVersionId = setting?.liveVersionId ?? "";
277
+ const history = setting?.history ?? [];
278
+ row.hidden = history.length === 0;
279
+ if (select) {
280
+ select.replaceChildren(
281
+ ...history.map((entry) => {
282
+ const option = document.createElement("option");
283
+ option.value = entry.versionId;
284
+ option.textContent = historyLabel(entry);
285
+ return option;
286
+ }),
287
+ );
288
+ }
289
+ }
290
+ }
291
+
292
+ /** The Publish tab's "Settings waiting to go live" block. */
293
+ export function mountSettingsGoLive(el: HTMLElement): void {
294
+ const endpoint = el.dataset.endpoint || "/api/preview/settings-live";
295
+ const targetTenant = el.dataset.targetTenant || "";
296
+ const setStatus = statusSetter(el.querySelector("[data-settings-go-live-status]"));
297
+ const buttons = () => [...el.querySelectorAll<HTMLButtonElement>("button")];
298
+ const refresh = async (): Promise<string | null> => {
299
+ try {
300
+ renderSettingsGoLive(el, await readStatus(endpoint, targetTenant));
301
+ return null;
302
+ } catch (e) {
303
+ return reasonOf(e);
304
+ }
305
+ };
306
+ const post = async (body: Record<string, unknown>) => {
307
+ const res = await fetch(endpoint, {
308
+ method: "POST",
309
+ headers: { "content-type": "application/json" },
310
+ body: JSON.stringify(body),
311
+ });
312
+ return { res, data: await readJson<ActionResponse>(res) };
313
+ };
314
+ const run = async (work: () => Promise<{ message: string; state?: StatusState }>) => {
315
+ for (const b of buttons()) b.disabled = true;
316
+ let result: { message: string; state?: StatusState };
317
+ try {
318
+ result = await work();
319
+ } catch {
320
+ result = { message: "That didn't work — network error", state: "err" };
321
+ }
322
+ const shown = withStale(result, await refresh());
323
+ setStatus(shown.message, shown.state);
324
+ for (const b of buttons()) b.disabled = false;
325
+ for (const row of el.querySelectorAll<HTMLElement>("[data-settings-rollback]")) announceSettingChanged(row.dataset.kind ?? "");
326
+ };
327
+ el.querySelector("[data-settings-go-live-publish]")?.addEventListener("click", () =>
328
+ void run(async () => {
329
+ // Exactly the drafts this block shows; each is CAS against the live value shown beside it.
330
+ const pins = [...el.querySelectorAll<HTMLElement>("[data-settings-pin]")];
331
+ setStatus("Publishing settings…");
332
+ let last: { message: string; state?: StatusState } = { message: "Nothing to publish." };
333
+ for (const pin of pins) {
334
+ const setting: SettingStatusView = {
335
+ kind: pin.dataset.kind ?? "",
336
+ draftVersionId: pin.dataset.draftVersionId || null,
337
+ liveVersionId: pin.dataset.liveVersionId || null,
338
+ pending: true,
339
+ changes: [],
340
+ };
341
+ const { res, data } = await post(publishBody(setting, targetTenant));
342
+ last = actionResultStatus("publish", res.ok, res.status, data);
343
+ if (last.state !== "ok") return last;
344
+ }
345
+ return pins.length > 1 ? { message: "Published. Your live store reads these settings now.", state: "ok" } : last;
346
+ }),
347
+ );
348
+ for (const row of el.querySelectorAll<HTMLElement>("[data-settings-rollback]")) {
349
+ row.querySelector("[data-settings-rollback-apply]")?.addEventListener("click", () =>
350
+ void run(async () => {
351
+ const versionId = row.querySelector<HTMLSelectElement>("select")?.value;
352
+ if (!versionId) return { message: "Pick a version to roll back to.", state: "err" };
353
+ setStatus("Rolling back…");
354
+ const setting: SettingStatusView = {
355
+ kind: row.dataset.kind ?? "",
356
+ draftVersionId: null,
357
+ liveVersionId: row.dataset.liveVersionId || null,
358
+ pending: false,
359
+ changes: [],
360
+ };
361
+ const { res, data } = await post(rollbackBody(setting, versionId, targetTenant));
362
+ return actionResultStatus("rollback", res.ok, res.status, data);
363
+ }),
364
+ );
365
+ }
366
+ document.addEventListener(SETTING_SAVED_EVENT, () => void refresh().then((stale) => stale && setStatus(staleMessage(stale), "err")));
367
+ }
@@ -4,8 +4,10 @@
4
4
  *
5
5
  * They are merchant-operated commerce settings (ADR 0035 "operational"), saved as the
6
6
  * tenant-owned artifact `tenants/<id>/checkout-requirements.json` to the store's operational
7
- * selection. A save is in effect at once on the store's preview AND its live store — every
8
- * reader resolves it through `resolveArtifactSelection`, with no promote. Every switch defaults
7
+ * selection. A save writes the draft (`test`), which the store's preview reads; the live store
8
+ * reads the published value (`live`) until the owner publishes the draft. A store with no live
9
+ * site has nothing to protect, so its save writes both. Every reader resolves the setting through
10
+ * `resolveArtifactSelection` for its own env. Every switch defaults
9
11
  * off and can only make checkout stricter, so no write can weaken what the platform enforces.
10
12
  * They apply only while the store checks out on the shared test store; a store with its own
11
13
  * checkout store, and production, keep the full fail-closed contract regardless of what is saved here.
@@ -335,18 +335,30 @@
335
335
  h2:is(.display-xl, .display-lg, .display-md) { font-size: var(--heading-h2, var(--display-size)); }
336
336
  h3:is(.display-xl, .display-lg, .display-md) { font-size: var(--heading-h3, var(--display-size)); }
337
337
 
338
- /* Tasteful scroll-reveal; opt-in via [data-reveal], JS toggles .is-in. */
338
+ /* Scroll-reveal only after the script marks a block below the fold.
339
+ An unmarked [data-reveal] stays at opacity 1, so a block already in the
340
+ initial viewport is visible with no IntersectionObserver and no tenant flag. */
339
341
  [data-reveal] {
342
+ opacity: 1;
343
+ }
344
+ [data-reveal].is-below {
345
+ transition: opacity 0.6s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
346
+ }
347
+ [data-reveal].is-below:not(.is-in) {
340
348
  opacity: 0;
341
349
  transform: translateY(14px);
342
- transition: opacity 0.6s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
343
350
  }
344
351
  [data-reveal].is-in {
345
352
  opacity: 1;
346
353
  transform: none;
347
354
  }
348
355
  @media (prefers-reduced-motion: reduce) {
349
- [data-reveal] { opacity: 1; transform: none; }
356
+ [data-reveal],
357
+ [data-reveal].is-below:not(.is-in) {
358
+ opacity: 1;
359
+ transform: none;
360
+ transition: none;
361
+ }
350
362
  }
351
363
 
352
364
  /* Island animations (MobileNav drawer slide-in). Globally neutralized by the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "3.12.3",
3
+ "version": "4.0.0",
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",
@@ -338,6 +338,25 @@ export const ACTION_CATALOG = {
338
338
  argsAllow: ["versionId", "scope", ...OPERATIONAL_READ_BACK_ARGS], renderedAllow: [],
339
339
  desc: "A store's checkout requirements were saved to a version.",
340
340
  },
341
+ "content.operational_setting.published": {
342
+ // An operational setting's saved draft (checkout requirements or styling) was made the live
343
+ // store's value (/api/preview/settings-live). `scope` names the setting.
344
+ actors: ["merchant", "admin"], source: "server", sinks: LIFECYCLE,
345
+ argsAllow: ["versionId", "scope", ...OPERATIONAL_READ_BACK_ARGS], renderedAllow: [],
346
+ desc: "A store's operational setting was published to its live store.",
347
+ },
348
+ "content.operational_setting.rolled_back": {
349
+ // An operational setting's live value was put back to one it held before.
350
+ actors: ["merchant", "admin"], source: "server", sinks: LIFECYCLE,
351
+ argsAllow: ["versionId", "scope", ...OPERATIONAL_READ_BACK_ARGS], renderedAllow: [],
352
+ desc: "A store's operational setting was rolled back on its live store.",
353
+ },
354
+ "content.operational_setting.draft_discarded": {
355
+ // An operational setting's pending draft was dropped; the preview reads the live value again.
356
+ actors: ["merchant", "admin"], source: "server", sinks: LIFECYCLE,
357
+ argsAllow: ["versionId", "scope", ...OPERATIONAL_READ_BACK_ARGS], renderedAllow: [],
358
+ desc: "A store's pending operational-setting draft was discarded.",
359
+ },
341
360
  "change.go_live": {
342
361
  // Renamed from the orphaned, un-namespaced `go_live` (the ONE key that broke the
343
362
  // domain-prefix rule). The snake_case suffix is kept (the `deploy.version_observed`
@@ -80,11 +80,12 @@ export function derivedArtifactPath(tenantId: string, name: string): string {
80
80
  export const CATALOG_LISTING_ARTIFACT = "catalog-listing.json";
81
81
 
82
82
  /**
83
- * Operational settings (ADR 0035): records a merchant edits from /admin, in effect with no
84
- * deploy and no promote. Each is saved to the store's operational selection (the pointer's
85
- * per-path `test` selection) and read from there for EVERY env and channel, so a save is what the
86
- * preview, the live store and the final-cart authority all read. A path not listed here is
87
- * repo-authored content: a channel pointer, when engaged, owns it.
83
+ * Operational settings (ADR 0035): records a merchant edits from /admin, in effect with no deploy.
84
+ * Each has a draft and a live value, both per-path selections on the pointer: a save writes the
85
+ * draft (`test`), which the preview, staff preview and preview-side checkout read at once; the live
86
+ * store and live checkout read `live`, which moves only when the setting is published, rolled back,
87
+ * or carried by a preview→live promotion. A store with no live site yet saves to both. A path not
88
+ * listed here is repo-authored content: a channel pointer, when engaged, owns it.
88
89
  */
89
90
  export const OPERATIONAL_SETTING_FILES = Object.freeze(["checkout-requirements.json", "checkout-style.json"] as const);
90
91
  export type OperationalSettingFile = (typeof OPERATIONAL_SETTING_FILES)[number];
@@ -100,7 +101,7 @@ export function isOperationalSettingPath(path: string): boolean {
100
101
 
101
102
  /**
102
103
  * Which selection a reader of (env, path) consults on `pointer`:
103
- * - `operational`: an operational setting the store has saved — the same selection for every env;
104
+ * - `operational`: an operational setting's per-env selection (`test` = draft, `live` = published);
104
105
  * - `channel`: the channel's immutable site version (authoritative once engaged);
105
106
  * - `legacy`: the per-path selection of a tenant not cut over to channels.
106
107
  * `null` when nothing is selected. Readers ({@link resolvePublishedArtifactKey}) and the writer
@@ -117,8 +118,8 @@ export function resolveArtifactSelection(
117
118
  path: string,
118
119
  ): ArtifactSelection | null {
119
120
  if (isOperationalSettingPath(path)) {
120
- const versionId = pointer.test?.[path];
121
- if (versionId) return { source: "operational", versionId };
121
+ const versionId = pointer[env]?.[path];
122
+ return versionId ? { source: "operational", versionId } : null;
122
123
  }
123
124
  const channel = channelFromEnv(env);
124
125
  if (pointer.channels?.[channel]) return { source: "channel", channel };
@@ -331,8 +332,8 @@ export async function readVersionedArtifact(
331
332
  * back to the build-time artifact.
332
333
  *
333
334
  * Resolution follows {@link resolveArtifactSelection}:
334
- * - An operational setting the store has saved resolves to that saved version
335
- * for every env and channel.
335
+ * - An operational setting resolves to `pointer[env][path]` alone: the draft for
336
+ * `test`, the published value for `live`. No channel and no other env stands in.
336
337
  * - If the tenant has a channel pointer for the resolved channel
337
338
  * (`channelFromEnv(env)`), resolve via the immutable site version. A channel
338
339
  * pointer is AUTHORITATIVE — if the snapshot omits this path we return null
@@ -39,7 +39,6 @@ export * from "./capabilities.js";
39
39
  export * from "./compliance-obligations.js";
40
40
  export * from "./evidence.js";
41
41
  export * from "./checkout-style.js";
42
- export * from "./checkout-style-publish.js";
43
42
  export * from "./catalog-api.js";
44
43
  export * from "./search.js";
45
44
  export * from "./catalog-d1.js";
@@ -1,200 +0,0 @@
1
- /**
2
- * Signed storefront → checkout theme-publish client (U11b, the PUSH half of U11).
3
- *
4
- * Points a store's hosted checkout template at the storefront-hosted stylesheet
5
- * (`GET /api/checkout-style.css`, U11) by driving the commerce service's theme
6
- * endpoint: PUT the `content_url`, then POST `/refresh` (PUT alone only sets the
7
- * pointer — checkout caches the template, so nothing goes live until refresh).
8
- *
9
- * AUTH: each request carries an `x-tot-signature` header = lowercase-hex
10
- * HMAC-SHA256 of `JSON.stringify(body)` keyed by the STORE'S ToT SECRET KEY —
11
- * byte-identical to the commerce service's webhook-signature verifier. That
12
- * secret is the storefront's `TOT_SECRET_KEY`; this is independent of the
13
- * provider API credentials.
14
- *
15
- * Edge-safe + node-free (WebCrypto HMAC); the network call is injected so the
16
- * flow is fully unit-testable offline. The caller (the checkout-styling promote
17
- * hook — U13) supplies the resolved secret + base URL from server context; the
18
- * secret is never shipped to the client.
19
- */
20
- import type { CustomizationEnv } from "./customization-preview.js";
21
-
22
- /** Header the commerce service reads the HMAC from. */
23
- export const TOT_SIGNATURE_HEADER = "x-tot-signature";
24
-
25
- /** The provider caps content_url at 300 chars; '' detaches the template. */
26
- export const MAX_CONTENT_URL = 300;
27
-
28
- /** Fetch shape we depend on — the platform `fetch`, injectable for tests. */
29
- export type FetchLike = (
30
- input: string,
31
- init?: {
32
- method?: string;
33
- headers?: Record<string, string>;
34
- body?: string;
35
- },
36
- ) => Promise<{ ok: boolean; status: number; text: () => Promise<string> }>;
37
-
38
- export interface ThemePublishTarget {
39
- /** Commerce service base URL for the checkout proxy. */
40
- checkoutServiceBaseUrl: string;
41
- /** The store's app domain / subdomain (the service resolves the store from it). */
42
- appDomain: string;
43
- /** The hosted stylesheet URL checkout will fetch (U11's `/api/checkout-style.css`). */
44
- contentUrl: string;
45
- /** The store's ToT secret key (storefront `TOT_SECRET_KEY`) — signs the request. */
46
- secret: string;
47
- /** Checkout template to target; checkout styling uses `checkout` (the default). */
48
- templateType?: string;
49
- }
50
-
51
- export interface ThemePublishResult {
52
- ok: boolean;
53
- /** Parsed PUT response (pointer set), when it succeeded. */
54
- set?: unknown;
55
- /** Parsed refresh response (cache re-pulled), when it succeeded. */
56
- refreshed?: boolean;
57
- /** Failure detail: which step + the endpoint's status/message. */
58
- error?: string;
59
- }
60
-
61
- /** Lowercase-hex HMAC-SHA256(message) under `secret` (WebCrypto). */
62
- async function hmacHex(secret: string, message: string): Promise<string> {
63
- const enc = new TextEncoder();
64
- const key = await crypto.subtle.importKey(
65
- "raw",
66
- enc.encode(secret),
67
- { name: "HMAC", hash: "SHA-256" },
68
- false,
69
- ["sign"],
70
- );
71
- const sig = await crypto.subtle.sign("HMAC", key, enc.encode(message));
72
- return [...new Uint8Array(sig)]
73
- .map((b) => b.toString(16).padStart(2, "0"))
74
- .join("");
75
- }
76
-
77
- /**
78
- * The exact commerce-service signature: hex HMAC-SHA256 of the JSON body string
79
- * under the store secret. Exported so the promote hook and tests can
80
- * compute/assert it identically.
81
- */
82
- export function signThemeRequestBody(
83
- secret: string,
84
- body: unknown,
85
- ): Promise<string> {
86
- return hmacHex(secret, JSON.stringify(body));
87
- }
88
-
89
- /**
90
- * The canonical, stable content_url a store's checkout template points at — the
91
- * U11 route on the store's own host, so the server-side fetch self-identifies
92
- * the tenant + env. Returns null if it would exceed the provider's 300-char cap
93
- * so the caller surfaces a config error before pushing a URL checkout will reject.
94
- */
95
- export function checkoutStyleContentUrl(
96
- host: string,
97
- basePath = "",
98
- ): string | null {
99
- const url = `https://${host}${basePath}/api/checkout-style.css`;
100
- return url.length <= MAX_CONTENT_URL ? url : null;
101
- }
102
-
103
- async function readErr(
104
- res: { status: number; text: () => Promise<string> },
105
- step: string,
106
- ): Promise<string> {
107
- let detail = "";
108
- try {
109
- detail = (await res.text()).slice(0, 300);
110
- } catch {
111
- /* ignore */
112
- }
113
- return `${step} failed (HTTP ${res.status})${detail ? `: ${detail}` : ""}`;
114
- }
115
-
116
- /**
117
- * Publish a store's checkout styling: signed PUT `/theme/foxy` then
118
- * signed POST `/theme/foxy/refresh`. Returns a discriminated result — we do NOT
119
- * fail open: if the PUT fails we skip the refresh and report the error, so the
120
- * caller never reports "published" for something that didn't take.
121
- *
122
- * `fetchImpl` defaults to the global `fetch`; tests inject a stub.
123
- */
124
- export async function publishCheckoutTheme(
125
- target: ThemePublishTarget,
126
- fetchImpl?: FetchLike,
127
- ): Promise<ThemePublishResult> {
128
- const doFetch = fetchImpl ?? (globalThis.fetch as unknown as FetchLike);
129
- const templateType = target.templateType ?? "checkout";
130
- const base = target.checkoutServiceBaseUrl.replace(/\/+$/, "");
131
-
132
- if (
133
- target.contentUrl !== "" &&
134
- (!target.contentUrl.startsWith("https://") ||
135
- target.contentUrl.length > MAX_CONTENT_URL)
136
- ) {
137
- return {
138
- ok: false,
139
- error: `invalid contentUrl (must be https and <= ${MAX_CONTENT_URL} chars)`,
140
- };
141
- }
142
-
143
- // 1. PUT the pointer.
144
- const setBody = {
145
- appDomain: target.appDomain,
146
- templateType,
147
- contentUrl: target.contentUrl,
148
- };
149
- const setSig = await signThemeRequestBody(target.secret, setBody);
150
- const setRes = await doFetch(`${base}/theme/foxy`, {
151
- method: "PUT",
152
- headers: {
153
- "content-type": "application/json",
154
- [TOT_SIGNATURE_HEADER]: setSig,
155
- },
156
- body: JSON.stringify(setBody),
157
- });
158
- if (!setRes.ok) {
159
- return { ok: false, error: await readErr(setRes, "PUT /theme/foxy") };
160
- }
161
- let set: unknown = undefined;
162
- try {
163
- set = JSON.parse(await setRes.text());
164
- } catch {
165
- /* non-JSON ok-body — leave undefined */
166
- }
167
-
168
- // 2. POST refresh to make it live.
169
- const refreshBody = { appDomain: target.appDomain, templateType };
170
- const refreshSig = await signThemeRequestBody(target.secret, refreshBody);
171
- const refreshRes = await doFetch(`${base}/theme/foxy/refresh`, {
172
- method: "POST",
173
- headers: {
174
- "content-type": "application/json",
175
- [TOT_SIGNATURE_HEADER]: refreshSig,
176
- },
177
- body: JSON.stringify(refreshBody),
178
- });
179
- if (!refreshRes.ok) {
180
- return {
181
- ok: false,
182
- set,
183
- error: await readErr(refreshRes, "POST /theme/foxy/refresh"),
184
- };
185
- }
186
-
187
- return { ok: true, set, refreshed: true };
188
- }
189
-
190
- /**
191
- * Convenience: the env → checkout service base URL for this workstream's targets.
192
- * qa/test use the non-prod proxy; live uses the prod proxy. Kept here so the
193
- * promote hook resolves one place.
194
- */
195
- export function checkoutServiceBaseUrl(env: CustomizationEnv): string {
196
- // "test" is the qa target's env; "live" is prod.
197
- return env === "live"
198
- ? "https://foxycart.tokenoftrust.com"
199
- : "https://foxycart-test.tokenoftrust.com";
200
- }