@blamejs/blamejs-shop 0.4.23 → 0.4.25

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/lib/admin.js CHANGED
@@ -59,6 +59,19 @@ var AUDIT_NAMESPACE = "shop_admin";
59
59
  var NOTES_PANEL_LIMIT = 50;
60
60
  var ACTIVITY_PANEL_LIMIT = 50;
61
61
 
62
+ // Newest-N bound on the order-detail timeline panel — a chronological
63
+ // read of the most recent slice of the order's story, not a paginated
64
+ // list (the order page is a single screen, not a feed).
65
+ var TIMELINE_PANEL_LIMIT = 60;
66
+
67
+ // Role the operator inbox broadcasts to (the audience for the navbar
68
+ // unread badge + the /admin/inbox screen). A single-credential console
69
+ // has no per-operator owner for a new-order ping, so the audience is a
70
+ // role — every operator carrying it sees the same feed. Must match the
71
+ // role the order FSM's paid-edge observer enqueues against (server.js).
72
+ var INBOX_ROLE = "fulfillment";
73
+ var INBOX_PANEL_LIMIT = 50;
74
+
62
75
  // Operator-readable error-log sink (lib/error-log.js), set by mount()
63
76
  // when the deployment wires `deps.errorLog`. `_safeNotice` records the
64
77
  // scrubbed message of a genuine 5xx here so the failure is reachable
@@ -85,30 +98,52 @@ var _errorLogSink = null;
85
98
  // the verb itself, not merely hidden in the nav. Read routes (`R`, no
86
99
  // audit action) demand no permission beyond a valid credential.
87
100
 
88
- // The three built-in roles and their permission grants. `owner` holds the
89
- // full set including operator management; `manager` covers catalog /
90
- // orders / customers / marketing writes; `viewer` is read-only it holds
91
- // NO write permission, so every `W`-wrapped route refuses it. The role set
92
- // is the v1-defensible surface; operators wanting finer-grained custom
93
- // roles compose lib/operator-roles.js on top.
94
- var OPERATOR_PERMISSIONS = Object.freeze([
95
- "catalog.write", // products, variants, prices, media, inventory, collections, merchandising, marketing content
96
- "orders.write", // orders, returns, exchanges, refunds, fulfilment, exports, quotes, gift cards
97
- "customers.write", // customer records, segments, notes, store credit
98
- "settings.write", // shop configuration
99
- "operators.manage", // create / disable / re-role other operators
100
- ]);
101
-
102
- var ROLE_GRANTS = Object.freeze({
103
- owner: Object.freeze(OPERATOR_PERMISSIONS.slice()),
104
- manager: Object.freeze(["catalog.write", "orders.write", "customers.write"]),
105
- viewer: Object.freeze([]),
101
+ // The three built-in roles, declared as a `b.permissions` registry. The
102
+ // registry gives role inheritance (owner manager viewer), wildcard
103
+ // scope coverage, and boot-time validation a typo in a scope string, an
104
+ // unknown `extends` target, or a cycle throws at module-eval rather than
105
+ // silently mis-granting on the first request. `owner` holds the full set
106
+ // including operator management plus the `*` root scope (so it grants the
107
+ // owner-only fallback below); `manager` covers catalog / orders / customers
108
+ // / marketing writes; `viewer` is read-only — it holds NO write scope, so
109
+ // every `W`-wrapped route refuses it. The role set is the v1-defensible
110
+ // surface; operators wanting finer-grained custom roles compose
111
+ // lib/operator-roles.js on top.
112
+ //
113
+ // Scope strings use `:` segments (the matcher's wildcard syntax —
114
+ // `b.permissions` rejects `.`-separated scopes). The action→permission map
115
+ // below still names the operator-facing permission in `domain.write` dot
116
+ // form (what an auditor reads in the denial row); `_scopeFor` translates
117
+ // the dotted name to its `:` scope at the single check boundary.
118
+ var _perms = b.permissions.create({
119
+ roles: {
120
+ viewer: [],
121
+ manager: { extends: ["viewer"], permissions: ["catalog:write", "orders:write", "customers:write"] },
122
+ // `*` is the root-greedy scope — it covers every required scope,
123
+ // INCLUDING the owner-only fallback (`owner:only`) that an unmapped
124
+ // mutating action resolves to. A manager never holds `*`, so a new,
125
+ // un-mapped write route is owner-reachable only — fail-closed.
126
+ owner: { extends: ["manager"], permissions: ["settings:write", "operators:manage", "*"] },
127
+ },
106
128
  });
107
129
 
130
+ // Role-existence check (replaces the old ROLE_GRANTS-keyed lookup). The
131
+ // sealed-cookie path falls back to viewer for any role this registry
132
+ // doesn't know.
133
+ function _knownRole(role) { return typeof role === "string" && _perms.has(role); }
134
+
135
+ // The owner-only fallback permission. An action whose prefix is NOT in
136
+ // `_ACTION_PERMISSION` resolves here, so a newly-added W()-route nobody
137
+ // maps requires the owner role rather than the broad (manager-grantable)
138
+ // merchandising write. The dotted name is what the denial audit records;
139
+ // it maps to the `owner:only` scope only `owner` (via `*`) can match.
140
+ var OWNER_ONLY_PERMISSION = "owner.only";
141
+
108
142
  // Map a `W(...)` audit-action's first segment to the permission it
109
143
  // requires. Every mutating admin route is covered; an action whose prefix
110
- // is not listed falls back to `catalog.write` (the broad merchandising
111
- // grant) so a newly-added write route is gated rather than silently open.
144
+ // is not listed FAILS CLOSED to the owner-only fallback (not the broad
145
+ // merchandising write) so a newly-added write route is owner-reachable
146
+ // only until it's mapped to the role that should hold it.
112
147
  var _ACTION_PERMISSION = Object.freeze({
113
148
  // catalog / merchandising / marketing content
114
149
  product: "catalog.write", variant: "catalog.write", price: "catalog.write",
@@ -118,6 +153,7 @@ var _ACTION_PERMISSION = Object.freeze({
118
153
  gift: "catalog.write", preorder: "catalog.write", quantity_discount: "catalog.write",
119
154
  auto_discount: "catalog.write", coupon_policy: "catalog.write",
120
155
  promo_banner: "catalog.write", announcement: "catalog.write", blog: "catalog.write",
156
+ email_campaign: "catalog.write", suggestion: "catalog.write", sidebar_widget: "catalog.write",
121
157
  page: "catalog.write", help: "catalog.write", survey: "catalog.write",
122
158
  hours: "catalog.write", delivery_holiday: "catalog.write",
123
159
  delivery_transit: "catalog.write", tax_rate: "catalog.write",
@@ -130,6 +166,7 @@ var _ACTION_PERMISSION = Object.freeze({
130
166
  export: "orders.write", tax_filing: "orders.write", quote: "orders.write",
131
167
  gift_card: "orders.write", subscription: "orders.write",
132
168
  cart_recovery_code: "orders.write", support: "orders.write",
169
+ inbox: "orders.write",
133
170
  // customers
134
171
  customer: "customers.write", customer_segment: "customers.write",
135
172
  // shop configuration
@@ -138,21 +175,32 @@ var _ACTION_PERMISSION = Object.freeze({
138
175
  operator: "operators.manage",
139
176
  });
140
177
 
178
+ // Translate an operator-facing `domain.write` permission name to the
179
+ // `b.permissions` `:` scope the registry matches on. The owner-only
180
+ // fallback maps to `owner:only` — a scope only the `*`-holding owner role
181
+ // covers.
182
+ function _scopeFor(permission) {
183
+ if (permission === OWNER_ONLY_PERMISSION) return "owner:only";
184
+ return permission.replace(/\./g, ":");
185
+ }
186
+
141
187
  // Permission a `W(auditAction, ...)` route requires, derived from the
142
- // action's first dotted segment. Unmapped prefixes default to the broad
143
- // merchandising write so an un-mapped new route fails closed for viewers.
188
+ // action's first dotted segment. Unmapped prefixes FAIL CLOSED to the
189
+ // owner-only fallback so an un-mapped new route can't be reached by a
190
+ // manager — it requires the owner role (or an explicit map entry).
144
191
  function _permissionForAction(auditAction) {
145
- if (typeof auditAction !== "string" || !auditAction.length) return "catalog.write";
192
+ if (typeof auditAction !== "string" || !auditAction.length) return OWNER_ONLY_PERMISSION;
146
193
  var seg = auditAction.split(".")[0];
147
- return _ACTION_PERMISSION[seg] || "catalog.write";
194
+ return _ACTION_PERMISSION[seg] || OWNER_ONLY_PERMISSION;
148
195
  }
149
196
 
150
- // True when `role` grants `permission`. The owner role always grants
151
- // everything; an unknown role grants nothing (fails closed).
197
+ // True when `role` grants `permission`. Delegates to the `b.permissions`
198
+ // registry: the owner role holds `*` (grants everything including the
199
+ // owner-only fallback), manager inherits viewer + its three writes, viewer
200
+ // holds nothing, and an unknown role grants nothing (fails closed).
152
201
  function _roleGrants(role, permission) {
153
- var grants = ROLE_GRANTS[role];
154
- if (!grants) return false;
155
- return grants.indexOf(permission) !== -1;
202
+ if (!_knownRole(role)) return false;
203
+ return _perms.check({ roles: [role] }, _scopeFor(permission));
156
204
  }
157
205
 
158
206
  // Per-request store for the double-submit CSRF token. The admin console is
@@ -595,7 +643,7 @@ async function _resolveActor(req, authCtx) {
595
643
  // No accounts handle wired but the cookie names an operator — treat the
596
644
  // sealed claim's role as authoritative (the cookie is vault-sealed, so it
597
645
  // can't be forged); fail closed to viewer if the role is unexpected.
598
- var role = ROLE_GRANTS[claims.role] ? claims.role : "viewer";
646
+ var role = _knownRole(claims.role) ? claims.role : "viewer";
599
647
  return { kind: "operator", operator_id: String(claims.operator_id), role: role, via: "operator_cookie" };
600
648
  }
601
649
 
@@ -846,13 +894,31 @@ function mount(router, deps) {
846
894
  var operatorAccounts = deps.operatorAccounts || null;
847
895
  var operatorAuditLog = deps.operatorAuditLog || null;
848
896
 
897
+ // Unified order-event feed for the order-detail screen. When wired, the
898
+ // /admin/orders/:id page renders a chronological timeline aggregated
899
+ // across the FSM transitions, shipment events, customer-service notes,
900
+ // returns, shipping labels, and dispatched notifications — one read of
901
+ // the whole order story rather than scanning each panel. Read-only: the
902
+ // primitive only memoizes a summary cache, never mutating an event row.
903
+ var orderTimeline = deps.orderTimeline || null;
904
+
905
+ // Operator inbox + navbar unread badge. When wired, the /admin/inbox
906
+ // screen lists notifications broadcast to the fulfillment role (newest
907
+ // order pings, etc.) with mark-read / archive, and every authed page's
908
+ // nav carries the unread count. The new-order ping is enqueued by the
909
+ // order FSM's paid-edge observer (wired in server.js); this console is
910
+ // the read + clear side. The audience is a role broadcast so a single-
911
+ // credential deployment surfaces the badge without modelling one owning
912
+ // operator — `INBOX_ROLE` is that role.
913
+ var operatorInbox = deps.operatorInbox || null;
914
+
849
915
  // Which optional console sections are wired — gates their nav links so a
850
916
  // signed-in admin is never sent to a route that wasn't mounted. Passed
851
917
  // into every authed render call as `nav_available`.
852
918
  // `reports` is always present in the nav (read-only sales summary needs no
853
919
  // extra dep); its route mounts unconditionally and renders an unconfigured
854
920
  // notice when the salesReports primitive isn't wired.
855
- var navAvailable = { analytics: !!deps.analytics, returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, promoBanners: !!deps.promoBanners, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, deliveryEstimate: !!deps.deliveryEstimate, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, complianceExport: !!complianceExport, orderExchanges: !!orderExchanges, orderRatings: !!orderRatings, clickAndCollect: !!clickAndCollect, giftOptions: !!giftOptions, searchRanking: !!searchRanking, searchSuggestions: !!searchSuggestions, trustBadges: !!trustBadges, orderExport: !!orderExport, auditLog: auditLog, errorLog: !!errorLog, carts: !!cart, inventoryLocations: !!inventoryLocations, inventoryReceive: !!inventoryReceive, stockTransfers: !!stockTransfers, inventoryWriteoffs: !!inventoryWriteoffs, quotes: !!deps.quotes, emailCampaigns: !!emailCampaigns, operators: !!operatorAccounts };
921
+ var navAvailable = { analytics: !!deps.analytics, returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, promoBanners: !!deps.promoBanners, suggestionBox: !!deps.suggestionBox, sidebarWidgets: !!deps.sidebarWidgets, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, deliveryEstimate: !!deps.deliveryEstimate, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, complianceExport: !!complianceExport, orderExchanges: !!orderExchanges, orderRatings: !!orderRatings, clickAndCollect: !!clickAndCollect, giftOptions: !!giftOptions, searchRanking: !!searchRanking, searchSuggestions: !!searchSuggestions, trustBadges: !!trustBadges, orderExport: !!orderExport, auditLog: auditLog, errorLog: !!errorLog, carts: !!cart, inventoryLocations: !!inventoryLocations, inventoryReceive: !!inventoryReceive, stockTransfers: !!stockTransfers, inventoryWriteoffs: !!inventoryWriteoffs, quotes: !!deps.quotes, emailCampaigns: !!emailCampaigns, operators: !!operatorAccounts, inbox: !!operatorInbox };
856
922
 
857
923
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
858
924
 
@@ -868,12 +934,35 @@ function mount(router, deps) {
868
934
  if (typeof router.use === "function") {
869
935
  router.use(function adminCsrfTokenMiddleware(req, _res, next) {
870
936
  try {
871
- _csrfAls.enterWith({ csrf_token: req.csrfToken || "" });
937
+ // One mutable store object per request — the CSRF token is seeded
938
+ // here synchronously; the inbox-badge middleware below fills in the
939
+ // unread count on the SAME object once it has awaited the read.
940
+ _csrfAls.enterWith({ csrf_token: req.csrfToken || "", inbox_unread: 0 });
872
941
  } catch (_e) { /* drop-silent — form renders token-less */ }
873
942
  next();
874
943
  });
875
944
  }
876
945
 
946
+ // Seed the navbar unread-inbox badge count onto the per-request ALS
947
+ // store so `_renderAdminShell` can stamp it into the nav on EVERY authed
948
+ // page — the operator sees a new sale without polling or opening the
949
+ // inbox. The audience is a role broadcast (INBOX_ROLE), so the count is
950
+ // role-scoped (`unreadCountForRole`), independent of whether the actor is
951
+ // the bootstrap owner or a per-operator session. Runs after the CSRF
952
+ // middleware (same store object). Drop-silent + best-effort: a read
953
+ // failure (or an unmigrated table) leaves the badge at zero rather than
954
+ // 500-ing the console. Skipped entirely when no inbox is wired.
955
+ if (operatorInbox && typeof router.use === "function") {
956
+ router.use(async function adminInboxBadgeMiddleware(_req, _res, next) {
957
+ try {
958
+ var n = await operatorInbox.unreadCountForRole({ role: INBOX_ROLE });
959
+ var store = _csrfAls.getStore();
960
+ if (store) store.inbox_unread = Number(n) || 0;
961
+ } catch (_e) { /* drop-silent — badge renders at zero */ }
962
+ next();
963
+ });
964
+ }
965
+
877
966
  // The auth context every wrapped route resolves through — the bootstrap
878
967
  // token plus the per-operator credential store + the chained audit peer.
879
968
  // Threaded into `_wrap` so the single chokepoint owns BOTH the credential
@@ -2119,6 +2208,30 @@ function mount(router, deps) {
2119
2208
  notes = (await orderNotes.listForOrder({ order_id: o.id, limit: NOTES_PANEL_LIMIT })).rows || [];
2120
2209
  } catch (_ne) { notes = []; }
2121
2210
  }
2211
+ // Chronological order story — one feed across FSM transitions,
2212
+ // shipment events, notes, returns, labels, and dispatched
2213
+ // notifications. Operator view (no customer_visible_only), bounded
2214
+ // to the most recent slice. Best-effort: an unwired timeline
2215
+ // primitive (or an unmigrated source table) degrades to no panel
2216
+ // rather than 500-ing the detail page.
2217
+ var timeline = null;
2218
+ if (orderTimeline) {
2219
+ try {
2220
+ var feed = await orderTimeline.forOrder({ order_id: o.id });
2221
+ timeline = (feed || []).slice(0, TIMELINE_PANEL_LIMIT);
2222
+ } catch (_te) { timeline = null; }
2223
+ }
2224
+ // Running refunded total (minor units) so the partial-refund panel
2225
+ // can state how much is already refunded and cap the next slice at
2226
+ // the remaining balance. Sums every `refund` order-transition row's
2227
+ // amount — integer minor-unit arithmetic, never a float. Best-effort:
2228
+ // a read failure leaves the panel to treat the order as un-refunded
2229
+ // (the route re-validates the cap server-side before moving money).
2230
+ var refundedMinor = 0;
2231
+ if (payment && o.payment_intent_id) {
2232
+ try { refundedMinor = await order.refundedTotalMinor(o.id); }
2233
+ catch (_re) { refundedMinor = 0; }
2234
+ }
2122
2235
  _sendHtml(res, 200, renderAdminOrder({
2123
2236
  shop_name: deps.shop_name,
2124
2237
  nav_available: navAvailable,
@@ -2127,6 +2240,15 @@ function mount(router, deps) {
2127
2240
  // Refund moves money, so the console only offers it when a payment
2128
2241
  // provider is wired AND the order has a captured intent to refund.
2129
2242
  can_refund: !!(payment && o.payment_intent_id),
2243
+ // Partial-refund panel inputs. `refunded_minor` is the running
2244
+ // total already refunded; `refundable_minor` is what's left of the
2245
+ // order's grand total. The panel renders a decimal-amount form
2246
+ // capped at the remaining balance; the route re-validates the cap
2247
+ // server-side (the form is display only — the backend is the gate).
2248
+ refunded_minor: refundedMinor,
2249
+ refundable_minor: Math.max(0, (Number(o.grand_total_minor) || 0) - refundedMinor),
2250
+ refund_done: url && url.searchParams.get("refunded"),
2251
+ refund_err: url && url.searchParams.get("refund_err") ? url.searchParams.get("refund_err") : null,
2130
2252
  // Shipment/tracking panel only renders when the tracking primitive
2131
2253
  // is wired; the carrier + status enums drive its form selects.
2132
2254
  can_track: !!orderTracking,
@@ -2168,6 +2290,11 @@ function mount(router, deps) {
2168
2290
  note_err: url && url.searchParams.get("note_err") ? url.searchParams.get("note_err") : null,
2169
2291
  note_authors: orderNotes ? orderNotes.ALLOWED_AUTHORS : null,
2170
2292
  note_visibility: orderNotes ? orderNotes.ALLOWED_VISIBILITY : null,
2293
+ // Order timeline panel — renders only when the timeline primitive
2294
+ // is wired. The feed is already operator-facing event shape
2295
+ // (`{ kind, occurred_at, title, body?, actor?, link? }`).
2296
+ can_timeline: !!orderTimeline,
2297
+ timeline: timeline,
2171
2298
  notice: url && url.searchParams.get("err") ? "That action couldn't be completed for this order." : null,
2172
2299
  }));
2173
2300
  },
@@ -3954,6 +4081,214 @@ function mount(router, deps) {
3954
4081
  _redirect(res, "/admin/orders/" + encodeURIComponent(id) + "?moved=1");
3955
4082
  },
3956
4083
  ));
4084
+
4085
+ // ---- partial refund -------------------------------------------------
4086
+ //
4087
+ // Refund a specific amount that may NOT clear the order's balance. The
4088
+ // operator types a decimal amount (e.g. "12.50"); the backend parses it
4089
+ // through `b.money.of(<decimal>, <currency>)` so the minor-unit count is
4090
+ // exact and currency-exponent-correct (USD→2, JPY→0) — never a float,
4091
+ // and extra fractional digits are refused at the parse boundary. The
4092
+ // remaining-balance cap is enforced SERVER-SIDE before any money moves:
4093
+ // refunded-so-far is summed from the ledger, and a slice that would push
4094
+ // the total past the order's grand total is refused (422) with NOTHING
4095
+ // sent to the provider. The provider refund is issued FIRST under an
4096
+ // idempotency key derived from the order + amount + the refunded-total
4097
+ // seen, so a double-submit (or a retry) collapses to one provider charge
4098
+ // and one ledger row rather than refunding twice — the race guards the
4099
+ // payment-integrity work established. Only after the provider succeeds is
4100
+ // the ledger touched: a slice that exactly clears the remaining balance
4101
+ // drives the terminal FSM `refund` edge (order → refunded, running the
4102
+ // gift-card / loyalty reversals); a slice that leaves a balance records a
4103
+ // same-state partial-refund row (the order keeps its lifecycle state).
4104
+ async function _partialRefund(o, decimalAmount) {
4105
+ var alreadyMinor = await order.refundedTotalMinor(o.id);
4106
+ var remainingMinor = (Number(o.grand_total_minor) || 0) - alreadyMinor;
4107
+ if (remainingMinor <= 0) {
4108
+ var none = new TypeError("This order is already fully refunded — nothing remains to refund.");
4109
+ none._refundCode = "nothing-remaining";
4110
+ throw none;
4111
+ }
4112
+ // Parse the operator-typed decimal into exact minor units via the
4113
+ // money primitive (BigInt, currency-exponent-aware). A bad shape /
4114
+ // too many fractional digits throws — surfaced as a clean 4xx.
4115
+ var minor;
4116
+ try {
4117
+ minor = Number(b.money.of(String(decimalAmount == null ? "" : decimalAmount).trim(), o.currency).toMinorUnits());
4118
+ } catch (_pe) {
4119
+ var bad = new TypeError("Enter a valid amount in " + o.currency + " (e.g. 12.50) — no more decimal places than the currency allows.");
4120
+ bad._refundCode = "bad-amount";
4121
+ throw bad;
4122
+ }
4123
+ if (!Number.isInteger(minor) || minor <= 0) {
4124
+ var pos = new TypeError("The refund amount must be greater than zero.");
4125
+ pos._refundCode = "bad-amount";
4126
+ throw pos;
4127
+ }
4128
+ if (minor > remainingMinor) {
4129
+ // Over-refund — would push the refunded total past what the customer
4130
+ // paid. Refuse BEFORE the provider is called so no money moves.
4131
+ var over = new TypeError("That's more than the " + pricing.format(remainingMinor, o.currency) +
4132
+ " still refundable on this order.");
4133
+ over._refundCode = "over-refund";
4134
+ throw over;
4135
+ }
4136
+ var clearsBalance = (minor === remainingMinor);
4137
+ // Idempotency key folds in the refunded-total seen, so two submits of
4138
+ // the same slice (a double-click, a retry) reuse one provider refund.
4139
+ var idemKey = "refund:" + o.id + ":partial:" + alreadyMinor + ":" + minor;
4140
+ var refund = await payment.refund({
4141
+ payment_intent: o.payment_intent_id,
4142
+ amount_minor: minor,
4143
+ reason: "requested_by_customer",
4144
+ metadata: { order_id: o.id, partial: !clearsBalance },
4145
+ }, idemKey);
4146
+ var refundedMinor = Number(refund.amount) || minor;
4147
+ if (clearsBalance) {
4148
+ // The slice clears the remaining balance — drive the terminal FSM
4149
+ // edge so the order moves to `refunded` (and the gift-card / loyalty
4150
+ // reversals fire). A transition refusal (already terminal) is
4151
+ // swallowed: the provider refund has succeeded and is the source of
4152
+ // truth, surfaced via the refreshed ledger.
4153
+ try {
4154
+ await order.transition(o.id, "refund", {
4155
+ reason: "admin:refund:partial-final",
4156
+ metadata: { stripe_refund_id: refund.id, amount_minor: refundedMinor, partial: true },
4157
+ });
4158
+ } catch (_te) { /* provider refund persisted; FSM refusal surfaced via re-fetch */ }
4159
+ } else {
4160
+ // Leaves a balance — append the partial-refund ledger row WITHOUT
4161
+ // changing the order's lifecycle state.
4162
+ await order.recordPartialRefund(o.id, {
4163
+ amount_minor: refundedMinor,
4164
+ reason: "admin:refund:partial",
4165
+ metadata: { stripe_refund_id: refund.id },
4166
+ });
4167
+ }
4168
+ return { refund: refund, amount_minor: refundedMinor, cleared: clearsBalance };
4169
+ }
4170
+
4171
+ router.post("/admin/orders/:id/refund/partial", _pageOrApi(false,
4172
+ W("order.refund", async function (req, res) {
4173
+ var o = await order.get(req.params.id);
4174
+ if (!o) return _problem(res, 404, "order-not-found");
4175
+ if (!o.payment_intent_id) return _problem(res, 422, "no-payment-intent", "Order has no linked payment intent");
4176
+ var body = req.body || {};
4177
+ var result;
4178
+ try {
4179
+ result = await _partialRefund(o, body.amount);
4180
+ } catch (e) {
4181
+ if (e instanceof TypeError) {
4182
+ var status = e._refundCode === "over-refund" || e._refundCode === "nothing-remaining" ? 422 : 400;
4183
+ return _problem(res, status, e._refundCode || "bad-request", e.message);
4184
+ }
4185
+ // allow:admin-5xx-echoes-raw-error-message — 502 surfaces the PAYMENT PROVIDER's refund-failure reason, an operator-actionable upstream message, not a server/storage internal.
4186
+ return _problem(res, 502, "stripe-refund-failed", (e && e.message) || String(e));
4187
+ }
4188
+ _json(res, 200, result);
4189
+ return { id: o.id };
4190
+ }),
4191
+ async function (req, res) {
4192
+ var id = req.params.id;
4193
+ var o;
4194
+ try { o = await order.get(id); }
4195
+ catch (e) { if (!(e instanceof TypeError)) throw e; o = null; }
4196
+ if (!o || !o.payment_intent_id) {
4197
+ return _redirect(res, "/admin/orders/" + encodeURIComponent(id) + "?err=1");
4198
+ }
4199
+ var enc = encodeURIComponent(id);
4200
+ try {
4201
+ await _partialRefund(o, (req.body || {}).amount);
4202
+ } catch (e) {
4203
+ if (e instanceof TypeError) {
4204
+ // Operator-actionable validation message (over-refund, bad amount,
4205
+ // already-refunded) surfaces verbatim on the detail page.
4206
+ return _redirect(res, "/admin/orders/" + enc + "?refund_err=" + encodeURIComponent(e.message));
4207
+ }
4208
+ // Provider refund failed — the order is untouched (the ledger is
4209
+ // only written after a successful provider refund).
4210
+ return _redirect(res, "/admin/orders/" + enc + "?refund_err=" +
4211
+ encodeURIComponent("The refund couldn't be processed by the payment provider. Nothing was charged back — try again."));
4212
+ }
4213
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".order.refund", outcome: "success", metadata: { id: id, partial: true } });
4214
+ _redirect(res, "/admin/orders/" + enc + "?refunded=1");
4215
+ },
4216
+ ));
4217
+ }
4218
+
4219
+ // ---- operator inbox -------------------------------------------------
4220
+ //
4221
+ // The in-console notification feed. Mounted only when an operator-inbox
4222
+ // primitive is wired. The audience is a role broadcast (INBOX_ROLE) — a
4223
+ // new-order ping (enqueued by the order FSM's paid-edge observer) reaches
4224
+ // every operator carrying the role without modelling one owning operator,
4225
+ // so the navbar badge + this screen work the same on a single-credential
4226
+ // console and a multi-operator one. The screen lists the role's messages
4227
+ // newest-first with mark-read / archive; each write asserts the row
4228
+ // carries the role before mutating (a guessed id from another role can't
4229
+ // be cleared here).
4230
+ if (operatorInbox) {
4231
+ router.get("/admin/inbox", _pageOrApi(true,
4232
+ R(async function (req, res) {
4233
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
4234
+ var includeArchived = !!(url && url.searchParams.get("archived"));
4235
+ var page = await operatorInbox.inboxForRole({
4236
+ role: INBOX_ROLE, include_archived: includeArchived, limit: INBOX_PANEL_LIMIT,
4237
+ });
4238
+ _json(res, 200, page);
4239
+ }),
4240
+ async function (req, res) {
4241
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
4242
+ var includeArchived = !!(url && url.searchParams.get("archived"));
4243
+ var messages = [];
4244
+ try {
4245
+ messages = (await operatorInbox.inboxForRole({
4246
+ role: INBOX_ROLE, include_archived: includeArchived, limit: INBOX_PANEL_LIMIT,
4247
+ })).rows || [];
4248
+ } catch (_e) { messages = []; }
4249
+ _sendHtml(res, 200, _renderAdminInbox({
4250
+ shop_name: deps.shop_name, nav_available: navAvailable,
4251
+ messages: messages, include_archived: includeArchived,
4252
+ done: url && url.searchParams.get("done"),
4253
+ notice: url && url.searchParams.get("err") ? "That action couldn't be completed." : null,
4254
+ }));
4255
+ },
4256
+ ));
4257
+
4258
+ // Shared mark-read / archive write — role-scoped (the row must carry
4259
+ // INBOX_ROLE). A malformed id throws (TypeError → clean 4xx); an unknown
4260
+ // id or one addressed elsewhere is a clean 404 with nothing written.
4261
+ function _inboxWriteRoute(suffix, audit, op) {
4262
+ router.post("/admin/inbox/:id" + suffix, _pageOrApi(false,
4263
+ W(audit, async function (req, res) {
4264
+ var out;
4265
+ try { out = await op(req.params.id); }
4266
+ catch (e) {
4267
+ if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
4268
+ if (e && (e.code === "INBOX_MESSAGE_NOT_FOUND" || e.code === "INBOX_MESSAGE_NOT_ADDRESSABLE")) {
4269
+ return _problem(res, 404, "inbox-message-not-found");
4270
+ }
4271
+ throw e;
4272
+ }
4273
+ _json(res, 200, out);
4274
+ return { id: req.params.id };
4275
+ }),
4276
+ async function (req, res) {
4277
+ try { await op(req.params.id); }
4278
+ catch (_e) {
4279
+ // Malformed / unknown / cross-role id — a clean notice, never a 500.
4280
+ return _redirect(res, "/admin/inbox?err=1");
4281
+ }
4282
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + "." + audit, outcome: "success", metadata: { id: req.params.id } });
4283
+ _redirect(res, "/admin/inbox?done=1");
4284
+ },
4285
+ ));
4286
+ }
4287
+
4288
+ _inboxWriteRoute("/read", "inbox.read",
4289
+ function (id) { return operatorInbox.markReadForRole({ id: id, role: INBOX_ROLE }); });
4290
+ _inboxWriteRoute("/archive", "inbox.archive",
4291
+ function (id) { return operatorInbox.archiveForRole({ id: id, role: INBOX_ROLE }); });
3957
4292
  }
3958
4293
 
3959
4294
  // ---- reviews (moderation) -------------------------------------------
@@ -5157,9 +5492,19 @@ function mount(router, deps) {
5157
5492
  });
5158
5493
  }
5159
5494
 
5495
+ // The privacy/DSR actions administer a customer's personal data — a PII
5496
+ // export bundle (fulfill / dispatch) or an irreversible erasure (delete).
5497
+ // They gate on `customers.write`, the same grant the customer-record
5498
+ // routes use, so a read-only viewer is refused on the verb (and the
5499
+ // denial is chained through the operator audit log) rather than able to
5500
+ // export or erase. The `customer.` audit-action prefix maps to
5501
+ // `customers.write` in `_ACTION_PERMISSION`; wrapping the JSON handler in
5502
+ // `W(...)` gates BOTH the bearer JSON path (inside `_wrap`) and the
5503
+ // browser-form path (`_dsrAction` forwards the handler to `_pageOrApi`,
5504
+ // which reads its `_adminWriteAction` tag to gate the cookie branch).
5160
5505
  router.post("/admin/dsr/:id/fulfill", _dsrAction(
5161
- R(async function (req, res) {
5162
- try { var bundle = await dsr.fulfillRequest({ request_id: req.params.id }); _json(res, 200, bundle); }
5506
+ W("customer.dsr_fulfill", async function (req, res) {
5507
+ try { var bundle = await dsr.fulfillRequest({ request_id: req.params.id }); _json(res, 200, bundle); return { id: req.params.id }; }
5163
5508
  catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5164
5509
  }),
5165
5510
  "dsr.fulfill",
@@ -5167,9 +5512,9 @@ function mount(router, deps) {
5167
5512
  ));
5168
5513
 
5169
5514
  router.post("/admin/dsr/:id/dispatch", _dsrAction(
5170
- R(async function (req, res) {
5515
+ W("customer.dsr_dispatch", async function (req, res) {
5171
5516
  var body = req.body || {};
5172
- try { var row = await dsr.dispatchExport({ request_id: req.params.id, delivery_method: body.delivery_method, delivery_address: body.delivery_address }); _json(res, 200, row); }
5517
+ try { var row = await dsr.dispatchExport({ request_id: req.params.id, delivery_method: body.delivery_method, delivery_address: body.delivery_address }); _json(res, 200, row); return { id: req.params.id }; }
5173
5518
  catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5174
5519
  }),
5175
5520
  "dsr.dispatch",
@@ -5177,9 +5522,9 @@ function mount(router, deps) {
5177
5522
  ));
5178
5523
 
5179
5524
  router.post("/admin/dsr/:id/dismiss", _dsrAction(
5180
- R(async function (req, res) {
5525
+ W("customer.dsr_dismiss", async function (req, res) {
5181
5526
  var body = req.body || {};
5182
- try { var row = await dsr.dismissRequest({ request_id: req.params.id, dismiss_reason: body.dismiss_reason }); _json(res, 200, row); }
5527
+ try { var row = await dsr.dismissRequest({ request_id: req.params.id, dismiss_reason: body.dismiss_reason }); _json(res, 200, row); return { id: req.params.id }; }
5183
5528
  catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5184
5529
  }),
5185
5530
  "dsr.dismiss",
@@ -5193,8 +5538,8 @@ function mount(router, deps) {
5193
5538
  // which point processDeletion runs for real (dry_run: false). The bearer
5194
5539
  // JSON path executes directly (tooling has no interstitial).
5195
5540
  router.post("/admin/dsr/:id/delete/confirm", _pageOrApi(false,
5196
- R(async function (req, res) {
5197
- try { var result = await dsr.processDeletion({ request_id: req.params.id, dry_run: false }); _json(res, 200, result); }
5541
+ W("customer.dsr_delete", async function (req, res) {
5542
+ try { var result = await dsr.processDeletion({ request_id: req.params.id, dry_run: false }); _json(res, 200, result); return { id: req.params.id }; }
5198
5543
  catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5199
5544
  }),
5200
5545
  async function (req, res) {
@@ -7210,6 +7555,305 @@ function mount(router, deps) {
7210
7555
  ));
7211
7556
  }
7212
7557
 
7558
+ // ---- suggestion box -------------------------------------------------
7559
+ // The customer-submitted idea backlog. The triage list (optionally
7560
+ // filtered by status / category) + a per-suggestion detail screen where
7561
+ // the operator responds (status transition through the roadmap FSM),
7562
+ // archives, or flags as spam. Content-negotiated like the other screens:
7563
+ // bearer → JSON; signed-in browser → the HTML table + detail. The public
7564
+ // /suggestions storefront page is the intake; this is the operator side
7565
+ // of the same instance.
7566
+ if (deps.suggestionBox) {
7567
+ var suggestionBox = deps.suggestionBox;
7568
+
7569
+ // Read a triage page. The console lists open + responded rows (it never
7570
+ // surfaces spam-flagged / archived rows in the default view) sorted
7571
+ // newest-first; a `?status=` / `?category=` chip narrows it. Drop-silent
7572
+ // to an empty list on a read error so the screen renders rather than 500.
7573
+ var _loadSuggestionTriage = async function (status, category, cursor) {
7574
+ var listOpts = { sort: "newest", limit: 50 };
7575
+ if (status) listOpts.status = status;
7576
+ if (category) listOpts.category = category;
7577
+ if (cursor) listOpts.cursor = cursor;
7578
+ try {
7579
+ var page = await suggestionBox.listSuggestions(listOpts);
7580
+ return { rows: page.rows || [], next_cursor: page.next_cursor || null };
7581
+ } catch (_e) {
7582
+ return { rows: [], next_cursor: null };
7583
+ }
7584
+ };
7585
+
7586
+ router.get("/admin/suggestions", _pageOrApi(true,
7587
+ R(async function (req, res) {
7588
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
7589
+ var status = url && url.searchParams.get("status");
7590
+ var category = url && url.searchParams.get("category");
7591
+ var cursor = url && url.searchParams.get("cursor");
7592
+ var page = await _loadSuggestionTriage(status || null, category || null, cursor || null);
7593
+ _json(res, 200, { rows: page.rows, next_cursor: page.next_cursor });
7594
+ }),
7595
+ async function (req, res) {
7596
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
7597
+ var status = (url && url.searchParams.get("status")) || null;
7598
+ var category = (url && url.searchParams.get("category")) || null;
7599
+ var cursor = (url && url.searchParams.get("cursor")) || null;
7600
+ var page = await _loadSuggestionTriage(status, category, cursor);
7601
+ _sendHtml(res, 200, renderAdminSuggestions({
7602
+ shop_name: deps.shop_name, nav_available: navAvailable,
7603
+ suggestions: page.rows, next_cursor: page.next_cursor,
7604
+ status_filter: status, category_filter: category,
7605
+ responded: url && url.searchParams.get("responded"),
7606
+ archived: url && url.searchParams.get("archived"),
7607
+ flagged: url && url.searchParams.get("flagged"),
7608
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the suggestion." : null,
7609
+ }));
7610
+ },
7611
+ ));
7612
+
7613
+ router.get("/admin/suggestions/:id", _pageOrApi(true,
7614
+ R(async function (req, res) {
7615
+ var row = await suggestionBox.getSuggestion(req.params.id);
7616
+ if (!row) return _problem(res, 404, "suggestion-not-found");
7617
+ _json(res, 200, row);
7618
+ }),
7619
+ async function (req, res) {
7620
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
7621
+ var row = null;
7622
+ try { row = await suggestionBox.getSuggestion(req.params.id); }
7623
+ catch (_e) { row = null; }
7624
+ if (!row) return _sendHtml(res, 404, renderAdminSuggestions({
7625
+ shop_name: deps.shop_name, nav_available: navAvailable, suggestions: [], notice: "Suggestion not found.",
7626
+ }));
7627
+ _sendHtml(res, 200, renderAdminSuggestion({
7628
+ shop_name: deps.shop_name, nav_available: navAvailable, suggestion: row,
7629
+ responded: url && url.searchParams.get("responded"),
7630
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the suggestion." : null,
7631
+ }));
7632
+ },
7633
+ ));
7634
+
7635
+ // Respond — transition the suggestion through the roadmap FSM, optionally
7636
+ // leaving a public-visible reply. The form posts status + response +
7637
+ // responder; the responder is the signed-in operator's id when resolved,
7638
+ // else a console label.
7639
+ router.post("/admin/suggestions/:id/respond", _pageOrApi(false,
7640
+ W("suggestion.respond", async function (req, res) {
7641
+ var body = req.body || {};
7642
+ var row;
7643
+ try {
7644
+ row = await suggestionBox.respondToSuggestion({
7645
+ suggestion_id: req.params.id,
7646
+ status: body.status,
7647
+ response: typeof body.response === "string" ? body.response : "",
7648
+ responder: _suggestionResponder(req, body),
7649
+ });
7650
+ } catch (e) {
7651
+ if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
7652
+ if (e && e.code === "SUGGESTION_NOT_FOUND") return _problem(res, 404, "suggestion-not-found");
7653
+ if (e && e.code === "SUGGESTION_INVALID_TRANSITION") return _problem(res, 409, "conflict", e.message);
7654
+ if (e && e.code === "SUGGESTION_ARCHIVED") return _problem(res, 409, "conflict", e.message);
7655
+ throw e;
7656
+ }
7657
+ _json(res, 200, row);
7658
+ return { id: row.id };
7659
+ }),
7660
+ async function (req, res) {
7661
+ var id = req.params.id;
7662
+ var enc = encodeURIComponent(id);
7663
+ var body = req.body || {};
7664
+ try {
7665
+ await suggestionBox.respondToSuggestion({
7666
+ suggestion_id: id,
7667
+ status: body.status,
7668
+ response: typeof body.response === "string" ? body.response : "",
7669
+ responder: _suggestionResponder(req, body),
7670
+ });
7671
+ } catch (e) {
7672
+ if (e instanceof TypeError || (e && e.code)) return _redirect(res, "/admin/suggestions/" + enc + "?err=1");
7673
+ throw e;
7674
+ }
7675
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".suggestion.respond", outcome: "success", metadata: { id: id } });
7676
+ _redirect(res, "/admin/suggestions/" + enc + "?responded=1");
7677
+ },
7678
+ ));
7679
+
7680
+ router.post("/admin/suggestions/:id/archive", _pageOrApi(false,
7681
+ W("suggestion.archive", async function (req, res) {
7682
+ var row;
7683
+ try { row = await suggestionBox.archiveSuggestion(req.params.id); }
7684
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
7685
+ if (!row) return _problem(res, 404, "suggestion-not-found");
7686
+ _json(res, 200, row);
7687
+ return { id: row.id };
7688
+ }),
7689
+ async function (req, res) {
7690
+ var id = req.params.id;
7691
+ var row = null;
7692
+ try { row = await suggestionBox.archiveSuggestion(id); }
7693
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
7694
+ if (!row) return _redirect(res, "/admin/suggestions?err=1");
7695
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".suggestion.archive", outcome: "success", metadata: { id: id } });
7696
+ _redirect(res, "/admin/suggestions?archived=1");
7697
+ },
7698
+ ));
7699
+
7700
+ router.post("/admin/suggestions/:id/flag", _pageOrApi(false,
7701
+ W("suggestion.flag", async function (req, res) {
7702
+ var body = req.body || {};
7703
+ var flagged = !(body.flagged === "0" || body.flagged === false || body.flagged === "false");
7704
+ var row;
7705
+ try { row = await suggestionBox.flagAsSpam({ suggestion_id: req.params.id, flagged: flagged }); }
7706
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
7707
+ if (!row) return _problem(res, 404, "suggestion-not-found");
7708
+ _json(res, 200, row);
7709
+ return { id: row.id };
7710
+ }),
7711
+ async function (req, res) {
7712
+ var id = req.params.id;
7713
+ var body = req.body || {};
7714
+ // The console toggle posts the desired next state via a hidden field
7715
+ // (flag → 1, un-flag → 0); a missing field defaults to flag.
7716
+ var flagged = !(body.flagged === "0");
7717
+ var row = null;
7718
+ try { row = await suggestionBox.flagAsSpam({ suggestion_id: id, flagged: flagged }); }
7719
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
7720
+ if (!row) return _redirect(res, "/admin/suggestions?err=1");
7721
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".suggestion.flag", outcome: "success", metadata: { id: id, flagged: flagged } });
7722
+ _redirect(res, "/admin/suggestions?flagged=1");
7723
+ },
7724
+ ));
7725
+ }
7726
+
7727
+ // ---- sidebar widgets ------------------------------------------------
7728
+ // Operator-curated storefront right-rail content. The console defines
7729
+ // widgets (kind-specific payload) + sets each page's ordered placement;
7730
+ // the storefront resolves + renders the placed widgets per page. Content-
7731
+ // negotiated like the other screens: bearer → JSON; signed-in browser →
7732
+ // the HTML table + create form + the per-page placement editor. The
7733
+ // console exposes the all / guest / logged_in audiences; the primitive's
7734
+ // segment audience needs an isMember handle that isn't wired (see
7735
+ // server.js), so it isn't offered here.
7736
+ if (deps.sidebarWidgets) {
7737
+ var sidebarWidgets = deps.sidebarWidgets;
7738
+
7739
+ // The pages a sidebar can be placed on. These page_keys are the same
7740
+ // values the storefront resolver derives from the request path, so an
7741
+ // operator placing widgets on "home" reaches the home page's right rail.
7742
+ var _SIDEBAR_PAGE_KEYS = ["home", "collection", "search", "cart", "product"];
7743
+
7744
+ var _loadSidebarConsole = async function () {
7745
+ var widgets = [];
7746
+ try { widgets = await sidebarWidgets.listWidgets({ include_archived: true, limit: sidebarWidgets.MAX_LIMIT || 500 }); }
7747
+ catch (_e) { widgets = []; }
7748
+ // Resolve each page's current ordered placement so the editor can
7749
+ // pre-check the boxes. widgetsForPage filters by audience/schedule, so
7750
+ // for the editor we read the raw placement via a far-future "now" +
7751
+ // a guest viewer to surface every placed slug regardless of window;
7752
+ // archived widgets drop out (they can't be placed). Drop-silent.
7753
+ var placements = Object.create(null);
7754
+ for (var i = 0; i < _SIDEBAR_PAGE_KEYS.length; i += 1) {
7755
+ var key = _SIDEBAR_PAGE_KEYS[i];
7756
+ placements[key] = [];
7757
+ try {
7758
+ var rows = await sidebarWidgets.widgetsForPage({
7759
+ page_key: key, viewer_kind: "guest", now: 1,
7760
+ });
7761
+ placements[key] = rows.map(function (r) { return r.slug; });
7762
+ } catch (_e2) { placements[key] = []; }
7763
+ }
7764
+ return { widgets: widgets, placements: placements, page_keys: _SIDEBAR_PAGE_KEYS.slice() };
7765
+ };
7766
+
7767
+ router.get("/admin/sidebar-widgets", _pageOrApi(true,
7768
+ R(async function (_req, res) {
7769
+ var console_ = await _loadSidebarConsole();
7770
+ _json(res, 200, { widgets: console_.widgets, placements: console_.placements });
7771
+ }),
7772
+ async function (req, res) {
7773
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
7774
+ var console_ = await _loadSidebarConsole();
7775
+ _sendHtml(res, 200, renderAdminSidebarWidgets({
7776
+ shop_name: deps.shop_name, nav_available: navAvailable,
7777
+ widgets: console_.widgets, placements: console_.placements, page_keys: console_.page_keys,
7778
+ created: url && url.searchParams.get("created"),
7779
+ updated: url && url.searchParams.get("updated"),
7780
+ archived: url && url.searchParams.get("archived"),
7781
+ placed: url && url.searchParams.get("placed"),
7782
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the widget." : null,
7783
+ }));
7784
+ },
7785
+ ));
7786
+
7787
+ router.post("/admin/sidebar-widgets", _pageOrApi(false,
7788
+ W("sidebar_widget.define", async function (req, res) {
7789
+ var row;
7790
+ try { row = await sidebarWidgets.defineWidget(_sidebarWidgetFromForm(req.body || {})); }
7791
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
7792
+ _json(res, 201, row);
7793
+ return { id: row.slug };
7794
+ }),
7795
+ async function (req, res) {
7796
+ try {
7797
+ await sidebarWidgets.defineWidget(_sidebarWidgetFromForm(req.body || {}));
7798
+ } catch (e) {
7799
+ if (!(e instanceof TypeError)) throw e;
7800
+ var n = _safeNotice(e, "sidebar_widget.define");
7801
+ var console_ = await _loadSidebarConsole();
7802
+ return _sendHtml(res, n.status, renderAdminSidebarWidgets({
7803
+ shop_name: deps.shop_name, nav_available: navAvailable,
7804
+ widgets: console_.widgets, placements: console_.placements, page_keys: console_.page_keys,
7805
+ notice: n.message.replace(/^sidebarWidgets[.:]\s*/, ""),
7806
+ }));
7807
+ }
7808
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".sidebar_widget.define", outcome: "success" });
7809
+ _redirect(res, "/admin/sidebar-widgets?created=1");
7810
+ },
7811
+ ));
7812
+
7813
+ router.post("/admin/sidebar-widgets/:slug/archive", _pageOrApi(false,
7814
+ W("sidebar_widget.archive", async function (req, res) {
7815
+ var row;
7816
+ try { row = await sidebarWidgets.archiveWidget(req.params.slug); }
7817
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
7818
+ _json(res, 200, row);
7819
+ return { id: row.slug };
7820
+ }),
7821
+ async function (req, res) {
7822
+ var slug = req.params.slug;
7823
+ try { await sidebarWidgets.archiveWidget(slug); }
7824
+ catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/sidebar-widgets?err=1"); }
7825
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".sidebar_widget.archive", outcome: "success", metadata: { slug: slug } });
7826
+ _redirect(res, "/admin/sidebar-widgets?archived=1");
7827
+ },
7828
+ ));
7829
+
7830
+ // Set a page's ordered placement. The form posts a `page_key` + zero or
7831
+ // more `slug` checkbox values; the order is the form's submitted order
7832
+ // (checkbox values arrive in document order). An empty selection clears
7833
+ // the page's sidebar.
7834
+ router.post("/admin/sidebar-widgets/placement", _pageOrApi(false,
7835
+ W("sidebar_widget.place", async function (req, res) {
7836
+ var body = req.body || {};
7837
+ var pageKey = typeof body.page_key === "string" ? body.page_key : "";
7838
+ var slugs = _sidebarPlacementSlugs(body);
7839
+ var out;
7840
+ try { out = await sidebarWidgets.setPagePlacement(pageKey, slugs); }
7841
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
7842
+ _json(res, 200, out);
7843
+ return { id: pageKey };
7844
+ }),
7845
+ async function (req, res) {
7846
+ var body = req.body || {};
7847
+ var pageKey = typeof body.page_key === "string" ? body.page_key : "";
7848
+ var slugs = _sidebarPlacementSlugs(body);
7849
+ try { await sidebarWidgets.setPagePlacement(pageKey, slugs); }
7850
+ catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/sidebar-widgets?err=1"); }
7851
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".sidebar_widget.place", outcome: "success", metadata: { page_key: pageKey, count: slugs.length } });
7852
+ _redirect(res, "/admin/sidebar-widgets?placed=1");
7853
+ },
7854
+ ));
7855
+ }
7856
+
7213
7857
  // ---- search suggestions ---------------------------------------------
7214
7858
  // Operator curation for the header autocomplete dropdown: featured
7215
7859
  // suggestions (typing "free" surfaces "Free shipping over $50") plus a
@@ -13129,6 +13773,10 @@ var DASHBOARD_LAYOUT =
13129
13773
  " <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n" +
13130
13774
  " <meta name=\"robots\" content=\"noindex,nofollow\">\n" +
13131
13775
  " <title>{{page_title}} — {{shop_name}}</title>\n" +
13776
+ " <link rel=\"icon\" type=\"image/svg+xml\" href=\"/assets/brand/favicon.svg\">\n" +
13777
+ " <link rel=\"icon\" type=\"image/png\" href=\"/assets/brand/favicon.png\">\n" +
13778
+ " <link rel=\"apple-touch-icon\" href=\"/assets/brand/favicon.png\">\n" +
13779
+ " <meta name=\"theme-color\" content=\"#08080a\">\n" +
13132
13780
  " RAW_ADMIN_CSS\n" +
13133
13781
  "</head>\n" +
13134
13782
  "<body>\n" +
@@ -13431,6 +14079,7 @@ var ADMIN_NAV_ITEMS = [
13431
14079
  { key: "inventory-transfers", href: "/admin/inventory/transfers", label: "Transfers", requires: "stockTransfers" },
13432
14080
  { key: "inventory-writeoffs", href: "/admin/inventory/writeoffs", label: "Write-offs", requires: "inventoryWriteoffs" },
13433
14081
  { key: "orders", href: "/admin/orders", label: "Orders" },
14082
+ { key: "inbox", href: "/admin/inbox", label: "Inbox", requires: "inbox", badge: true },
13434
14083
  { key: "quotes", href: "/admin/quotes", label: "Quotes", requires: "quotes" },
13435
14084
  { key: "carts", href: "/admin/carts", label: "Abandoned carts", requires: "carts" },
13436
14085
  { key: "reports", href: "/admin/reports", label: "Reports" },
@@ -13467,6 +14116,8 @@ var ADMIN_NAV_ITEMS = [
13467
14116
  { key: "pick-lists", href: "/admin/pick-lists", label: "Pick lists", requires: "pickLists" },
13468
14117
  { key: "announcements", href: "/admin/announcements", label: "Announcements", requires: "announcementBar" },
13469
14118
  { key: "promo-banners", href: "/admin/promo-banners", label: "Promo banners", requires: "promoBanners" },
14119
+ { key: "suggestions", href: "/admin/suggestions", label: "Suggestion box", requires: "suggestionBox" },
14120
+ { key: "sidebar-widgets", href: "/admin/sidebar-widgets", label: "Sidebar widgets", requires: "sidebarWidgets" },
13470
14121
  { key: "campaigns", href: "/admin/campaigns", label: "Email campaigns", requires: "emailCampaigns" },
13471
14122
  { key: "blog", href: "/admin/blog", label: "Blog", requires: "blog" },
13472
14123
  { key: "help", href: "/admin/help", label: "Help center", requires: "knowledgeBase" },
@@ -13488,12 +14139,34 @@ function _adminNav(active, available) {
13488
14139
  var links = ADMIN_NAV_ITEMS.filter(function (it) {
13489
14140
  return !it.requires || !available || available[it.requires];
13490
14141
  }).map(function (it) {
14142
+ // A badge-bearing nav item (the inbox) carries an unread-count
14143
+ // placeholder spliced post-render from the per-request ALS store, so
14144
+ // the count is current on every page without each render call threading
14145
+ // it. A non-badge item renders unchanged.
14146
+ var badge = it.badge ? "{{INBOX_BADGE}}" : "";
13491
14147
  return "<a href=\"" + it.href + "\"" + (it.key === active ? " class=\"active\" aria-current=\"page\"" : "") + ">" +
13492
- _htmlEscape(it.label) + "</a>";
14148
+ _htmlEscape(it.label) + badge + "</a>";
13493
14149
  }).join("");
13494
14150
  return "<nav class=\"admin-nav\"><div class=\"admin-nav__inner\">" + links + "</div></nav>";
13495
14151
  }
13496
14152
 
14153
+ // Replace the inbox nav badge placeholder with the per-request unread
14154
+ // count read from the ALS store. A zero count renders no pill (an empty
14155
+ // badge is noise); a positive count renders a small count pill, capped at
14156
+ // "99+" so the nav layout stays stable. Reached outside a request (no
14157
+ // store) the placeholder collapses to empty.
14158
+ function _injectAdminInboxBadge(html) {
14159
+ if (typeof html !== "string" || html.indexOf("{{INBOX_BADGE}}") === -1) return html;
14160
+ var store = _csrfAls.getStore();
14161
+ var n = (store && Number(store.inbox_unread)) || 0;
14162
+ var pill = "";
14163
+ if (n > 0) {
14164
+ var label = n > 99 ? "99+" : String(n);
14165
+ pill = " <span class=\"nav-badge\" aria-label=\"" + _htmlEscape(label) + " unread\">" + _htmlEscape(label) + "</span>";
14166
+ }
14167
+ return html.split("{{INBOX_BADGE}}").join(pill);
14168
+ }
14169
+
13497
14170
  function _renderAdminShell(shopName, subtitle, bodyHtml, active, available) {
13498
14171
  var html = _renderTemplate(DASHBOARD_LAYOUT, {
13499
14172
  shop_name: shopName || "blamejs.shop",
@@ -13506,10 +14179,11 @@ function _renderAdminShell(shopName, subtitle, bodyHtml, active, available) {
13506
14179
  // Splice the admin body literally so a `$`-bearing fragment can't trip
13507
14180
  // `String.replace`'s dollar substitution. See `_spliceRaw`.
13508
14181
  html = _spliceRaw(html, "RAW_BODY", bodyHtml);
13509
- // Token every admin POST form with the per-request double-submit CSRF value
13510
- // (seeded on the ALS by mount()'s sync middleware). Single funnel every
13511
- // authenticated admin page assembles here so this is the one place the
13512
- // field needs injecting.
14182
+ // Stamp the per-request inbox unread count into the nav badge (seeded on
14183
+ // the ALS by mount()'s inbox-badge middleware), then token every admin
14184
+ // POST form with the per-request double-submit CSRF value (also ALS-
14185
+ // seeded). Single funnel — every authenticated admin page assembles here.
14186
+ html = _injectAdminInboxBadge(html);
13513
14187
  return _injectAdminCsrfFields(html);
13514
14188
  }
13515
14189
 
@@ -13545,16 +14219,21 @@ function renderAdminConfirm(opts) {
13545
14219
  // 403 page shown when a signed-in operator's role does not grant the
13546
14220
  // permission a browser-form mutation requires. The required permission is
13547
14221
  // stated plainly so the operator knows what to ask the owner for. `perm`
13548
- // is one of the closed OPERATOR_PERMISSIONS tokens (never untrusted), but
13549
- // it is escaped anyway to keep the render escape-by-default.
14222
+ // is one of the closed `_ACTION_PERMISSION` tokens or the owner-only
14223
+ // fallback (never untrusted), but it is escaped anyway to keep the render
14224
+ // escape-by-default.
13550
14225
  function _renderAdminForbidden(shopName, navAvailable, perm) {
13551
14226
  var name = shopName || "blamejs.shop";
14227
+ // The owner-only fallback has no operator-friendly grant name — an
14228
+ // un-mapped action is owner-reachable only — so render its requirement
14229
+ // as "owner" rather than the internal `owner.only` token.
14230
+ var permLabel = perm === OWNER_ONLY_PERMISSION ? "owner" : (perm || "");
13552
14231
  var body =
13553
14232
  "<section class=\"mw-42\">" +
13554
14233
  "<h2>Not permitted</h2>" +
13555
14234
  "<div class=\"banner banner--err\">Your role does not permit this action.</div>" +
13556
14235
  "<div class=\"panel\">" +
13557
- "<p>This action requires the <code>" + _htmlEscape(perm || "") + "</code> permission. " +
14236
+ "<p>This action requires the <code>" + _htmlEscape(permLabel) + "</code> permission. " +
13558
14237
  "Ask an owner to grant your account a role that includes it.</p>" +
13559
14238
  "<a class=\"btn btn--ghost\" href=\"/admin\">Back to dashboard</a>" +
13560
14239
  "</div>" +
@@ -14771,6 +15450,28 @@ function renderAdminOrder(opts) {
14771
15450
  opts.note_authors || [], opts.note_visibility || []);
14772
15451
  }
14773
15452
 
15453
+ // Partial-refund panel — only when a payment provider is wired AND the
15454
+ // order has a captured intent (`can_refund`) AND a balance remains. The
15455
+ // operator types a decimal amount capped at the remaining balance; the
15456
+ // backend re-validates the cap before any money moves. Refund banners
15457
+ // (`refund_done` / `refund_err`) ride the same PRG pattern as the others.
15458
+ var refundPanel = "";
15459
+ if (opts.can_refund) {
15460
+ refundPanel = _orderRefundPanel(o,
15461
+ Number(opts.refunded_minor) || 0,
15462
+ Number(opts.refundable_minor) || 0,
15463
+ opts.refund_done ? "<div class=\"banner banner--ok\">Refund issued.</div>" : "",
15464
+ opts.refund_err ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.refund_err) + "</div>" : "");
15465
+ }
15466
+
15467
+ // Order timeline panel — only when the timeline primitive is wired. A
15468
+ // single chronological feed across every post-checkout source, escaped
15469
+ // at render (titles / bodies are operator + system + carrier free text).
15470
+ var timelinePanel = "";
15471
+ if (opts.can_timeline) {
15472
+ timelinePanel = _orderTimelinePanel(opts.timeline || []);
15473
+ }
15474
+
14774
15475
  var body =
14775
15476
  "<section class=\"mw-48\">" +
14776
15477
  "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/orders\">&larr; Orders</a></div>" +
@@ -14789,6 +15490,8 @@ function renderAdminOrder(opts) {
14789
15490
  "<div class=\"panel mt\"><h3 class=\"subhead\">Actions</h3>" +
14790
15491
  "<div class=\"order-actions\">" + actions + "</div>" +
14791
15492
  "</div>" +
15493
+ refundPanel +
15494
+ timelinePanel +
14792
15495
  documentsPanel +
14793
15496
  resendPanel +
14794
15497
  notesPanel +
@@ -14870,6 +15573,147 @@ function _orderNotesPanel(orderId, notes, doneBanner, errBanner, authors, visibi
14870
15573
  "</div>";
14871
15574
  }
14872
15575
 
15576
+ // Refund panel for the order detail. States the order total, how much is
15577
+ // already refunded, and how much remains, then offers a partial-refund
15578
+ // form (a decimal amount capped at the remaining balance) that POSTs to
15579
+ // the `/refund/partial` route. The amount input's max + step are derived
15580
+ // from the currency exponent (display hints only — the backend re-parses
15581
+ // via b.money and enforces the cap), and the form is omitted once the
15582
+ // order is fully refunded. The full-refund button lives in the Actions
15583
+ // panel (a legal FSM transition); this panel owns the partial path.
15584
+ function _orderRefundPanel(o, refundedMinor, refundableMinor, doneBanner, errBanner) {
15585
+ var currency = o.currency;
15586
+ var totalFmt = pricing.format(Number(o.grand_total_minor) || 0, currency);
15587
+ var refundedFmt = pricing.format(refundedMinor, currency);
15588
+ var refundableFmt = pricing.format(refundableMinor, currency);
15589
+ var summary =
15590
+ "<p class=\"refund-summary\">" +
15591
+ "Order total " + _htmlEscape(totalFmt) +
15592
+ " · refunded " + _htmlEscape(refundedFmt) +
15593
+ " · refundable " + _htmlEscape(refundableFmt) +
15594
+ "</p>";
15595
+ var form;
15596
+ if (refundableMinor > 0) {
15597
+ // The currency exponent sets the input's decimal granularity: a 2-dp
15598
+ // currency steps in 0.01, a 0-dp currency (JPY) steps in 1. The max is
15599
+ // the remaining balance as a decimal string built from the same money
15600
+ // primitive so the form hint and the server cap agree.
15601
+ var exp = 2;
15602
+ try { exp = b.money.CURRENCIES[currency]; if (typeof exp !== "number") exp = 2; } catch (_e) { exp = 2; }
15603
+ var step = exp <= 0 ? "1" : "0." + new Array(exp).join("0") + "1";
15604
+ var maxDecimal;
15605
+ try { maxDecimal = b.money.fromMinorUnits(BigInt(refundableMinor), currency).toString().replace(/\s+[A-Z]{3}$/, ""); }
15606
+ catch (_me) { maxDecimal = String(refundableMinor); }
15607
+ form =
15608
+ "<form method=\"post\" action=\"/admin/orders/" + _htmlEscape(o.id) + "/refund/partial\" class=\"return-action\">" +
15609
+ "<label class=\"form-field\"><span>Refund amount (" + _htmlEscape(currency) + ")</span>" +
15610
+ "<input type=\"number\" name=\"amount\" inputmode=\"decimal\" min=\"" + _htmlEscape(step) + "\" max=\"" + _htmlEscape(maxDecimal) + "\" step=\"" + _htmlEscape(step) + "\" placeholder=\"" + _htmlEscape(maxDecimal) + "\" required>" +
15611
+ "<small>Up to " + _htmlEscape(refundableFmt) + " remaining. The amount is charged back through the payment provider.</small>" +
15612
+ "</label>" +
15613
+ "<button class=\"btn btn--danger\" type=\"submit\">Issue refund</button>" +
15614
+ "</form>";
15615
+ } else {
15616
+ form = "<p class=\"empty\">This order is fully refunded — nothing remains to refund.</p>";
15617
+ }
15618
+ return "<div class=\"panel mt\"><h3 class=\"subhead\">Refunds</h3>" +
15619
+ doneBanner + errBanner + summary + form +
15620
+ "</div>";
15621
+ }
15622
+
15623
+ // Order timeline panel for the order detail. Renders the aggregated
15624
+ // operator-facing event feed (newest-first) as a vertical timeline. Every
15625
+ // interpolated value (title / body / actor) is operator-, system-, or
15626
+ // carrier-supplied free text, so each is escaped at render. A link, when
15627
+ // present, is a carrier tracking URL — rendered with rel="noopener
15628
+ // nofollow" + target="_blank" like the tracking panel's links.
15629
+ function _orderTimelinePanel(events) {
15630
+ var items = (events || []).map(function (e) {
15631
+ var when = "<span class=\"tl-when\">" + _htmlEscape(_fmtDate(e.occurred_at)) + "</span>";
15632
+ var title = "<span class=\"tl-title\">" + _htmlEscape(String(e.title == null ? "" : e.title)) + "</span>";
15633
+ var bodyLine = e.body
15634
+ ? "<div class=\"tl-body\">" + _htmlEscape(String(e.body)) + "</div>"
15635
+ : "";
15636
+ var actorLine = e.actor
15637
+ ? "<div class=\"tl-actor\">" + _htmlEscape(String(e.actor)) + "</div>"
15638
+ : "";
15639
+ var linkLine = e.link
15640
+ ? "<div><a href=\"" + _htmlEscape(String(e.link)) + "\" rel=\"noopener nofollow\" target=\"_blank\">Track ↗</a></div>"
15641
+ : "";
15642
+ return "<li>" +
15643
+ "<div>" + when + " · " + title + "</div>" +
15644
+ bodyLine + actorLine + linkLine +
15645
+ "</li>";
15646
+ }).join("");
15647
+ return "<div class=\"panel mt\"><h3 class=\"subhead\">Timeline</h3>" +
15648
+ (items
15649
+ ? "<ul class=\"timeline\">" + items + "</ul>"
15650
+ : "<p class=\"empty\">No recorded events for this order yet.</p>") +
15651
+ "</div>";
15652
+ }
15653
+
15654
+ // Operator inbox screen. Lists the role's notifications newest-first with
15655
+ // each message's severity pill, subject, body, and timestamp, plus the
15656
+ // per-message actions (mark read on an unread one, archive on any active
15657
+ // one). Subject + body are application-supplied (a new-order ping today,
15658
+ // other system events tomorrow), so both are escaped at render. A toggle
15659
+ // link switches between the active feed and the archive. Every form
15660
+ // renders through _renderAdminShell, which tokens each POST.
15661
+ function _renderAdminInbox(opts) {
15662
+ opts = opts || {};
15663
+ var messages = opts.messages || [];
15664
+ var includeArchived = !!opts.include_archived;
15665
+ var done = opts.done ? "<div class=\"banner banner--ok\">Inbox updated.</div>" : "";
15666
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
15667
+
15668
+ var items = messages.map(function (m) {
15669
+ var enc = _htmlEscape(encodeURIComponent(m.id));
15670
+ var isUnread = m.read_at == null;
15671
+ var isArchived = m.archived_at != null;
15672
+ var sev = String(m.severity || "info");
15673
+ // Map severity to the existing status-pill colour classes so the feed
15674
+ // reads at a glance without new colour tokens.
15675
+ var sevClass = sev === "critical" || sev === "urgent" ? "cancelled"
15676
+ : (sev === "warning" ? "pending" : "paid");
15677
+ var pills =
15678
+ "<span class=\"status-pill " + _htmlEscape(sevClass) + "\">" + _htmlEscape(sev) + "</span>" +
15679
+ (isUnread ? "" : "<span class=\"status-pill\">Read</span>") +
15680
+ (isArchived ? "<span class=\"status-pill refunded\">Archived</span>" : "");
15681
+ var actions = "";
15682
+ if (!isArchived) {
15683
+ if (isUnread) {
15684
+ actions += "<form method=\"post\" action=\"/admin/inbox/" + enc + "/read\" class=\"form-inline\">" +
15685
+ "<button class=\"btn btn--ghost\" type=\"submit\">Mark read</button></form>";
15686
+ }
15687
+ actions += "<form method=\"post\" action=\"/admin/inbox/" + enc + "/archive\" class=\"form-inline\">" +
15688
+ "<button class=\"btn btn--ghost\" type=\"submit\">Archive</button></form>";
15689
+ }
15690
+ return "<li class=\"" + (isUnread ? "unread" : "") + "\">" +
15691
+ "<div class=\"inbox-meta\">" + _htmlEscape(_fmtDate(m.created_at)) + " " + pills + "</div>" +
15692
+ "<div class=\"inbox-subject\">" + _htmlEscape(String(m.subject == null ? "" : m.subject)) + "</div>" +
15693
+ "<div class=\"tl-body\">" + _htmlEscape(String(m.body == null ? "" : m.body)) + "</div>" +
15694
+ (actions ? "<div class=\"actions-row\">" + actions + "</div>" : "") +
15695
+ "</li>";
15696
+ }).join("");
15697
+
15698
+ var toggle = includeArchived
15699
+ ? "<a class=\"btn btn--ghost\" href=\"/admin/inbox\">Show active only</a>"
15700
+ : "<a class=\"btn btn--ghost\" href=\"/admin/inbox?archived=1\">Include archived</a>";
15701
+
15702
+ var body =
15703
+ "<section class=\"mw-48\">" +
15704
+ "<h2>Inbox</h2>" +
15705
+ "<p class=\"meta\">System notifications for your team — new orders, alerts, and other events. Mark a message read once you've actioned it, or archive it to clear it from the feed.</p>" +
15706
+ done + notice +
15707
+ "<div class=\"actions-row\">" + toggle + "</div>" +
15708
+ "<div class=\"panel mt\">" +
15709
+ (messages.length
15710
+ ? "<ul class=\"inbox-list\">" + items + "</ul>"
15711
+ : "<p class=\"empty\">" + (includeArchived ? "No messages." : "No new messages.") + "</p>") +
15712
+ "</div>" +
15713
+ "</section>";
15714
+ return _renderAdminShell(opts.shop_name, "Inbox", body, "inbox", opts.nav_available);
15715
+ }
15716
+
14873
15717
  // Per-shipment carrier-label sub-panel for the order detail. Lists the
14874
15718
  // recorded labels (tracking number, broker, cost, status pill) with a
14875
15719
  // "Mark used" action on purchased ones, then a form to record a freshly-
@@ -15197,6 +16041,8 @@ function renderPickListPrint(opts) {
15197
16041
  "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">" +
15198
16042
  "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">" +
15199
16043
  "<meta name=\"robots\" content=\"noindex, nofollow\">" +
16044
+ "<link rel=\"icon\" type=\"image/svg+xml\" href=\"/assets/brand/favicon.svg\">" +
16045
+ "<link rel=\"icon\" type=\"image/png\" href=\"/assets/brand/favicon.png\">" +
15200
16046
  "<title>Pick list " + _htmlEscape(l.id.slice(0, 8)) + "</title>" + style + "</head><body>" +
15201
16047
  "<h1>" + _htmlEscape(shopName) + " — pick list</h1>" +
15202
16048
  "<p class=\"meta\">List " + _htmlEscape(l.id.slice(0, 8)) + " · location " + _htmlEscape(String(l.location_code)) +
@@ -15955,6 +16801,23 @@ function _dsrPillClass(status) {
15955
16801
  // Operator queue. Status chips filter the board; each row links to the
15956
16802
  // request detail. Renders the customer (short id), kind, jurisdiction,
15957
16803
  // scope, status pill, and when it was requested.
16804
+ // Render the statutory-deadline cell for a DSR row. The deadline is the
16805
+ // derived `statutory_deadline` the primitive computes from jurisdiction +
16806
+ // requested_at (GDPR one month, CCPA 45 days, LGPD 15 days; null for a
16807
+ // jurisdiction with no statutory clock). For an OPEN request (received /
16808
+ // processing) an elapsed wall is flagged "overdue"; a closed request shows
16809
+ // the date plainly (the clock no longer runs against the controller).
16810
+ function _dsrDeadlineCell(r) {
16811
+ var d = r && r.statutory_deadline;
16812
+ if (!d || typeof d.due_by !== "number") return "<span class=\"meta\">—</span>";
16813
+ var dateStr = _htmlEscape(_fmtDate(d.due_by));
16814
+ var open = r.status === "received" || r.status === "processing";
16815
+ if (open && Date.now() > d.due_by) {
16816
+ return "<span class=\"status-pill cancelled\" title=\"" + _htmlEscape(d.statute) + "\">overdue · " + dateStr + "</span>";
16817
+ }
16818
+ return "<span title=\"" + _htmlEscape(d.statute) + "\">" + dateStr + "</span>";
16819
+ }
16820
+
15958
16821
  function renderAdminDsr(opts) {
15959
16822
  opts = opts || {};
15960
16823
  var requests = opts.requests || [];
@@ -15975,11 +16838,12 @@ function renderAdminDsr(opts) {
15975
16838
  "<td>" + _htmlEscape(r.scope || "—") + "</td>" +
15976
16839
  "<td><span class=\"status-pill " + _dsrPillClass(r.status) + "\">" + _htmlEscape(r.status) + "</span></td>" +
15977
16840
  "<td>" + _htmlEscape(_fmtDate(r.requested_at)) + "</td>" +
16841
+ "<td>" + _dsrDeadlineCell(r) + "</td>" +
15978
16842
  "</tr>";
15979
16843
  }).join("");
15980
16844
 
15981
16845
  var table = requests.length
15982
- ? "<div class=\"panel\">" + _tableWrap("<table><thead><tr><th scope=\"col\">Customer</th><th scope=\"col\">Kind</th><th scope=\"col\">Jurisdiction</th><th scope=\"col\">Scope</th><th scope=\"col\">Status</th><th scope=\"col\">Requested</th></tr></thead><tbody>" + rows + "</tbody></table>") + "</div>"
16846
+ ? "<div class=\"panel\">" + _tableWrap("<table><thead><tr><th scope=\"col\">Customer</th><th scope=\"col\">Kind</th><th scope=\"col\">Jurisdiction</th><th scope=\"col\">Scope</th><th scope=\"col\">Status</th><th scope=\"col\">Requested</th><th scope=\"col\">Statutory deadline</th></tr></thead><tbody>" + rows + "</tbody></table>") + "</div>"
15983
16847
  : "<p class=\"empty\">No “" + _htmlEscape(active) + "” privacy requests.</p>";
15984
16848
 
15985
16849
  var body = "<section><h2>Privacy requests</h2>" +
@@ -16093,6 +16957,9 @@ function renderAdminDsrDetail(opts) {
16093
16957
  "<div class=\"two-col\">" +
16094
16958
  "<div class=\"panel\"><h3 class=\"subhead\">Details</h3>" +
16095
16959
  _field("Jurisdiction", r.jurisdiction) +
16960
+ _field("Statutory deadline", r.statutory_deadline
16961
+ ? _fmtDate(r.statutory_deadline.due_by) + " (" + r.statutory_deadline.statute + ")"
16962
+ : null) +
16096
16963
  _field("Scope", r.scope) +
16097
16964
  _field("Reason", r.reason) +
16098
16965
  _field("Dismiss reason", r.dismiss_reason) +
@@ -18805,6 +19672,120 @@ function _announcementPatchFromForm(body) {
18805
19672
  return patch;
18806
19673
  }
18807
19674
 
19675
+ // ---- suggestion-box form coercion -----------------------------------
19676
+
19677
+ // The responder string for a respondToSuggestion call. The signed-in
19678
+ // operator's id (stamped onto req by the role gate on the browser path, or
19679
+ // resolvable from the bearer) is the audit attribution; a console label is
19680
+ // the fallback so the responder field (which the primitive requires non-
19681
+ // empty) always lands a value. A hidden form `responder` is ignored — the
19682
+ // attribution comes from the authenticated actor, not the request body.
19683
+ function _suggestionResponder(req, _body) {
19684
+ if (req && req.operatorActor && req.operatorActor.operator_id) {
19685
+ return String(req.operatorActor.operator_id).slice(0, 200);
19686
+ }
19687
+ return "operator";
19688
+ }
19689
+
19690
+ // ---- sidebar-widget form coercion -----------------------------------
19691
+
19692
+ // Coerce the create form into the shape sidebarWidgets.defineWidget expects.
19693
+ // The kind determines the payload shape, so the coercion reads only the
19694
+ // fields that kind owns (the primitive refuses unknown payload keys). Every
19695
+ // numeric payload field is parsed to an integer; blank optional fields drop
19696
+ // out so the primitive applies its own validation. The schedule window
19697
+ // (starts_at / expires_at) is required by the primitive — a blank bound
19698
+ // defaults to "now" / "now + ~10 years" so an operator who just wants an
19699
+ // always-on widget doesn't have to fill the dates.
19700
+ function _sidebarWidgetPayloadFromForm(kind, body) {
19701
+ function _int(v) {
19702
+ if (v == null || String(v).trim() === "") return null;
19703
+ var n = parseInt(String(v), 10);
19704
+ return Number.isFinite(n) ? n : null;
19705
+ }
19706
+ if (kind === "newsletter_signup") {
19707
+ return { list_id: body.list_id, headline: body.headline, cta_label: body.cta_label };
19708
+ }
19709
+ if (kind === "recently_viewed") {
19710
+ var rl = _int(body.limit);
19711
+ return rl == null ? {} : { limit: rl };
19712
+ }
19713
+ if (kind === "trust_badges") {
19714
+ var badges = typeof body.badges === "string"
19715
+ ? body.badges.split(",").map(function (s) { return s.trim(); }).filter(function (s) { return s.length; })
19716
+ : [];
19717
+ return { badges: badges };
19718
+ }
19719
+ if (kind === "featured_collection") {
19720
+ var out = { collection_slug: body.collection_slug };
19721
+ var fl = _int(body.limit); if (fl != null) out.limit = fl;
19722
+ return out;
19723
+ }
19724
+ if (kind === "social_proof") {
19725
+ return { headline: body.headline, message_template: body.message_template };
19726
+ }
19727
+ if (kind === "size_chart") {
19728
+ return { chart_slug: body.chart_slug };
19729
+ }
19730
+ if (kind === "live_visitors") {
19731
+ var lv = {};
19732
+ var win = _int(body.window_minutes); if (win != null) lv.window_minutes = win;
19733
+ var thr = _int(body.min_threshold); if (thr != null) lv.min_threshold = thr;
19734
+ return lv;
19735
+ }
19736
+ if (kind === "countdown_timer") {
19737
+ var ct = {};
19738
+ var target = _epochFromForm(body.target_at);
19739
+ if (target != null) ct.target_at = target;
19740
+ ct.completed_label = body.completed_label;
19741
+ return ct;
19742
+ }
19743
+ // sticky_addtocart
19744
+ return { variant_slug: body.variant_slug };
19745
+ }
19746
+
19747
+ function _sidebarWidgetFromForm(body) {
19748
+ body = body || {};
19749
+ var kind = typeof body.kind === "string" ? body.kind : "";
19750
+ var out = {
19751
+ slug: typeof body.slug === "string" ? body.slug.trim() : body.slug,
19752
+ title: body.title,
19753
+ kind: kind,
19754
+ payload: _sidebarWidgetPayloadFromForm(kind, body),
19755
+ audience: typeof body.audience === "string" && body.audience ? body.audience : "all",
19756
+ };
19757
+ var pr = body.priority;
19758
+ if (pr != null && String(pr).trim() !== "") {
19759
+ var pn = parseInt(String(pr), 10);
19760
+ if (Number.isFinite(pn)) out.priority = pn;
19761
+ }
19762
+ // The schedule window is mandatory at the primitive layer. A blank start
19763
+ // means "live now"; a blank expiry means "no end" — represented as a
19764
+ // far-future bound so the always-on case needs no operator dates.
19765
+ var sa = _epochFromForm(body.starts_at);
19766
+ out.starts_at = sa != null ? sa : Date.now();
19767
+ var ea = _epochFromForm(body.expires_at);
19768
+ out.expires_at = ea != null ? ea : (out.starts_at + 315360000000); // +10y
19769
+ return out;
19770
+ }
19771
+
19772
+ // Read the ordered placement slugs from the placement form. A multi-value
19773
+ // `slug` field arrives as an array (multiple checked boxes) or a single
19774
+ // string (one box) or undefined (none) depending on the body parser; all
19775
+ // three normalise to an array. Each value is trimmed; blanks drop out. The
19776
+ // document order is the placement order.
19777
+ function _sidebarPlacementSlugs(body) {
19778
+ body = body || {};
19779
+ var raw = body.slug;
19780
+ var arr = Array.isArray(raw) ? raw : (raw == null ? [] : [raw]);
19781
+ var out = [];
19782
+ for (var i = 0; i < arr.length; i += 1) {
19783
+ var s = typeof arr[i] === "string" ? arr[i].trim() : "";
19784
+ if (s.length) out.push(s);
19785
+ }
19786
+ return out;
19787
+ }
19788
+
18808
19789
  // ---- search-suggestion form coercion --------------------------------
18809
19790
 
18810
19791
  // Read every featured suggestion for the curation table — through the
@@ -19118,6 +20099,297 @@ function renderAdminAnnouncements(opts) {
19118
20099
  return _renderAdminShell(opts.shop_name, "Announcements", bodyHtml, "announcements", opts.nav_available);
19119
20100
  }
19120
20101
 
20102
+ // ---- suggestion-box console -----------------------------------------
20103
+
20104
+ var _ADMIN_SUGGESTION_STATUS_LABEL = {
20105
+ open: "Open",
20106
+ under_consideration: "Under review",
20107
+ planned: "Planned",
20108
+ shipped: "Shipped",
20109
+ declined: "Not planned",
20110
+ duplicate: "Merged",
20111
+ };
20112
+ var _ADMIN_SUGGESTION_CATEGORY_LABEL = {
20113
+ product_idea: "Product idea",
20114
+ feature_request: "Feature request",
20115
+ improvement: "Improvement",
20116
+ complaint: "Issue",
20117
+ general: "General",
20118
+ };
20119
+
20120
+ // The suggestion triage list — a filterable table of customer-submitted
20121
+ // ideas with their net vote score + status, each linking to the detail
20122
+ // screen where the operator responds / archives / flags. Every echoed
20123
+ // customer free-text value (title) is _htmlEscape'd at the sink.
20124
+ function renderAdminSuggestions(opts) {
20125
+ opts = opts || {};
20126
+ var rows = opts.suggestions || [];
20127
+ var responded = opts.responded ? "<div class=\"banner banner--ok\">Response saved.</div>" : "";
20128
+ var archived = opts.archived ? "<div class=\"banner banner--ok\">Suggestion archived.</div>" : "";
20129
+ var flagged = opts.flagged ? "<div class=\"banner banner--ok\">Spam flag updated.</div>" : "";
20130
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
20131
+
20132
+ var sf = opts.status_filter;
20133
+ var cf = opts.category_filter;
20134
+ var statusChips = ["open", "under_consideration", "planned", "shipped", "declined"].map(function (s) {
20135
+ var on = (sf === s) ? " chip--on" : "";
20136
+ return "<a class=\"chip" + on + "\" href=\"/admin/suggestions?status=" + s + "\">" + _htmlEscape(_ADMIN_SUGGESTION_STATUS_LABEL[s]) + "</a>";
20137
+ }).join("");
20138
+ var chips = "<div class=\"order-filters\">" +
20139
+ "<a class=\"chip" + (sf == null && cf == null ? " chip--on" : "") + "\" href=\"/admin/suggestions\">All</a>" +
20140
+ statusChips +
20141
+ "</div>";
20142
+
20143
+ var bodyRows = rows.map(function (r) {
20144
+ var enc = _htmlEscape(encodeURIComponent(r.id));
20145
+ var statusKey = _ADMIN_SUGGESTION_STATUS_LABEL[r.status] ? r.status : "open";
20146
+ var catLbl = _ADMIN_SUGGESTION_CATEGORY_LABEL[r.category] || "General";
20147
+ return "<tr>" +
20148
+ "<td>" + _htmlEscape(r.title) + "</td>" +
20149
+ "<td>" + _htmlEscape(catLbl) + "</td>" +
20150
+ "<td><span class=\"status-pill\">" + _htmlEscape(_ADMIN_SUGGESTION_STATUS_LABEL[statusKey]) + "</span></td>" +
20151
+ "<td class=\"num\">" + _htmlEscape(String(Number(r.vote_count) || 0)) + "</td>" +
20152
+ "<td><div class=\"actions-row\">" +
20153
+ "<a class=\"btn btn--ghost\" href=\"/admin/suggestions/" + enc + "\">Review</a>" +
20154
+ "</div></td>" +
20155
+ "</tr>";
20156
+ }).join("");
20157
+
20158
+ var table = rows.length
20159
+ ? "<div class=\"panel\">" + _tableWrap("<table><thead><tr><th scope=\"col\">Title</th><th scope=\"col\">Category</th><th scope=\"col\">Status</th><th scope=\"col\" class=\"num\">Votes</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + bodyRows + "</tbody></table>") + "</div>"
20160
+ : "<p class=\"empty\">No suggestions" + (sf ? " in this status" : " yet") + ".</p>";
20161
+
20162
+ var more = "";
20163
+ if (opts.next_cursor) {
20164
+ var moreHref = "/admin/suggestions?" +
20165
+ (sf ? "status=" + _htmlEscape(encodeURIComponent(sf)) + "&" : "") +
20166
+ (cf ? "category=" + _htmlEscape(encodeURIComponent(cf)) + "&" : "") +
20167
+ "cursor=" + _htmlEscape(encodeURIComponent(opts.next_cursor));
20168
+ more = "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"" + moreHref + "\">Load more</a></div>";
20169
+ }
20170
+
20171
+ var bodyHtml = "<section><h2>Suggestion box</h2>" +
20172
+ "<p class=\"meta\">Customer-submitted ideas. Respond to move an idea through the roadmap, archive a stale one, or flag spam. The public board lives at <code class=\"order-id\">/suggestions</code>.</p>" +
20173
+ responded + archived + flagged + notice + chips + table + more + "</section>";
20174
+ return _renderAdminShell(opts.shop_name, "Suggestion box", bodyHtml, "suggestions", opts.nav_available);
20175
+ }
20176
+
20177
+ // One suggestion's detail + the operator respond / archive / flag controls.
20178
+ // The respond form drives the roadmap FSM; the response textarea is the
20179
+ // public-visible reply (optional — a blank reply is a status-only move).
20180
+ function renderAdminSuggestion(opts) {
20181
+ opts = opts || {};
20182
+ var s = opts.suggestion;
20183
+ if (!s) {
20184
+ var nf = "<section><h2>Suggestion</h2><p class=\"empty\">Suggestion not found.</p>" +
20185
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/suggestions\">Back to suggestions</a></div></section>";
20186
+ return _renderAdminShell(opts.shop_name, "Suggestion", nf, "suggestions", opts.nav_available);
20187
+ }
20188
+ var responded = opts.responded ? "<div class=\"banner banner--ok\">Response saved.</div>" : "";
20189
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
20190
+ var enc = _htmlEscape(encodeURIComponent(s.id));
20191
+ var isArchived = s.archived_at != null;
20192
+ var isTerminal = ["shipped", "declined", "duplicate"].indexOf(s.status) !== -1;
20193
+ var statusKey = _ADMIN_SUGGESTION_STATUS_LABEL[s.status] ? s.status : "open";
20194
+ var catLbl = _ADMIN_SUGGESTION_CATEGORY_LABEL[s.category] || "General";
20195
+
20196
+ // The destination statuses an operator can move to from the current
20197
+ // status. duplicate is excluded (it requires a canonical id — operators
20198
+ // merge via the API). open is the entry state and not a response target.
20199
+ var allowed = {
20200
+ open: ["under_consideration", "planned", "shipped", "declined"],
20201
+ under_consideration: ["planned", "shipped", "declined"],
20202
+ planned: ["shipped", "declined"],
20203
+ };
20204
+ var nextStatuses = allowed[s.status] || [];
20205
+ var statusOpts = nextStatuses.map(function (st) {
20206
+ return "<option value=\"" + st + "\">" + _htmlEscape(_ADMIN_SUGGESTION_STATUS_LABEL[st]) + "</option>";
20207
+ }).join("");
20208
+
20209
+ var existingResponse = (s.response_text && String(s.response_text).trim().length)
20210
+ ? "<div class=\"panel\"><h3 class=\"subhead\">Current response</h3><p>" + _htmlEscape(s.response_text) + "</p>" +
20211
+ (s.response_by ? "<p class=\"meta\">By " + _htmlEscape(s.response_by) + "</p>" : "") + "</div>"
20212
+ : "";
20213
+
20214
+ var respondForm = (isArchived || isTerminal || !nextStatuses.length)
20215
+ ? "<p class=\"empty\">" + (isArchived ? "This suggestion is archived." : "This suggestion is in a terminal status and can't be moved further.") + "</p>"
20216
+ : "<div class=\"panel mw-40\"><h3 class=\"subhead\">Respond</h3>" +
20217
+ "<form method=\"post\" action=\"/admin/suggestions/" + enc + "/respond\">" +
20218
+ "<label class=\"form-field\"><span>Move to</span><select name=\"status\">" + statusOpts + "</select></label>" +
20219
+ "<label class=\"form-field\"><span>Public reply (optional)</span><textarea name=\"response\" maxlength=\"5000\" rows=\"4\" placeholder=\"Shown on the public board next to this idea.\"></textarea></label>" +
20220
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Save response</button></div>" +
20221
+ "</form>" +
20222
+ "</div>";
20223
+
20224
+ // Archive + spam-flag controls. The flag toggle posts the desired next
20225
+ // state (flag → 1, un-flag → 0).
20226
+ var spamFlagged = !!s.spam_flagged;
20227
+ var manageRow = isArchived
20228
+ ? ""
20229
+ : "<div class=\"actions-row\">" +
20230
+ "<form method=\"post\" action=\"/admin/suggestions/" + enc + "/flag\" class=\"form-inline\">" +
20231
+ "<input type=\"hidden\" name=\"flagged\" value=\"" + (spamFlagged ? "0" : "1") + "\">" +
20232
+ "<button class=\"btn btn--ghost\" type=\"submit\">" + (spamFlagged ? "Unflag spam" : "Flag as spam") + "</button>" +
20233
+ "</form>" +
20234
+ "<form method=\"post\" action=\"/admin/suggestions/" + enc + "/archive\" class=\"form-inline\">" +
20235
+ "<button class=\"btn btn--danger\" type=\"submit\">Archive</button>" +
20236
+ "</form>" +
20237
+ "</div>";
20238
+
20239
+ var bodyHtml = "<section><h2>Suggestion</h2>" + responded + notice +
20240
+ "<div class=\"panel\">" +
20241
+ "<h3 class=\"subhead\">" + _htmlEscape(s.title) + "</h3>" +
20242
+ "<dl class=\"detail-grid\">" +
20243
+ "<div><dt>Category</dt><dd>" + _htmlEscape(catLbl) + "</dd></div>" +
20244
+ "<div><dt>Status</dt><dd><span class=\"status-pill\">" + _htmlEscape(_ADMIN_SUGGESTION_STATUS_LABEL[statusKey]) + "</span></dd></div>" +
20245
+ "<div><dt>Votes</dt><dd>" + _htmlEscape(String(Number(s.vote_count) || 0)) + "</dd></div>" +
20246
+ "<div><dt>Spam</dt><dd>" + (spamFlagged ? "Flagged" : "No") + "</dd></div>" +
20247
+ "</dl>" +
20248
+ "<p>" + _htmlEscape(s.body) + "</p>" +
20249
+ "</div>" +
20250
+ existingResponse +
20251
+ respondForm +
20252
+ manageRow +
20253
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/suggestions\">Back to suggestions</a></div>" +
20254
+ "</section>";
20255
+ return _renderAdminShell(opts.shop_name, "Suggestion", bodyHtml, "suggestions", opts.nav_available);
20256
+ }
20257
+
20258
+ // ---- sidebar-widget console -----------------------------------------
20259
+
20260
+ var _SIDEBAR_KIND_LABEL = {
20261
+ newsletter_signup: "Newsletter signup",
20262
+ recently_viewed: "Recently viewed",
20263
+ trust_badges: "Trust badges",
20264
+ featured_collection: "Featured collection",
20265
+ social_proof: "Social proof",
20266
+ size_chart: "Size chart",
20267
+ live_visitors: "Live visitors",
20268
+ countdown_timer: "Countdown timer",
20269
+ sticky_addtocart: "Sticky add-to-cart",
20270
+ };
20271
+
20272
+ // The per-kind payload field set for the create form. Each entry is the HTML
20273
+ // for the kind's payload inputs, shown/hidden by the kind <select> via a
20274
+ // small island; with no JS every field is visible and the operator fills the
20275
+ // ones their chosen kind needs (the primitive refuses payload keys that don't
20276
+ // belong to the kind, so a wrong-kind field is a clean validation error).
20277
+ function _sidebarPayloadFields() {
20278
+ return "<fieldset class=\"panel\"><legend>Content</legend>" +
20279
+ "<p class=\"meta\">Fill the fields for the widget kind you chose above.</p>" +
20280
+ // newsletter_signup
20281
+ _setupField("List id (newsletter)", "list_id", "", "text", "newsletter_signup", " maxlength=\"120\"") +
20282
+ _setupField("Headline", "headline", "", "text", "newsletter_signup / social_proof", " maxlength=\"200\"") +
20283
+ _setupField("CTA label", "cta_label", "", "text", "newsletter_signup", " maxlength=\"80\"") +
20284
+ // social_proof
20285
+ "<label class=\"form-field\"><span>Message</span><textarea name=\"message_template\" maxlength=\"500\" rows=\"2\"></textarea><small>social_proof</small></label>" +
20286
+ // trust_badges
20287
+ _setupField("Badge slugs (comma-separated)", "badges", "", "text", "trust_badges", " maxlength=\"500\"") +
20288
+ // featured_collection
20289
+ _setupField("Collection slug", "collection_slug", "", "text", "featured_collection", " maxlength=\"120\"") +
20290
+ // size_chart
20291
+ _setupField("Chart slug", "chart_slug", "", "text", "size_chart", " maxlength=\"120\"") +
20292
+ // sticky_addtocart
20293
+ _setupField("Variant slug", "variant_slug", "", "text", "sticky_addtocart", " maxlength=\"120\"") +
20294
+ // recently_viewed / featured_collection limit + live_visitors + countdown
20295
+ _setupField("Limit", "limit", "", "number", "recently_viewed / featured_collection", " min=\"1\" max=\"24\"") +
20296
+ _setupField("Window minutes", "window_minutes", "", "number", "live_visitors", " min=\"1\" max=\"240\"") +
20297
+ _setupField("Min threshold", "min_threshold", "", "number", "live_visitors", " min=\"0\"") +
20298
+ "<label class=\"form-field\"><span>Countdown target</span><input type=\"datetime-local\" name=\"target_at\"><small>countdown_timer</small></label>" +
20299
+ _setupField("Completed label", "completed_label", "", "text", "countdown_timer", " maxlength=\"200\"") +
20300
+ "</fieldset>";
20301
+ }
20302
+
20303
+ // The widget definitions table + the create form + the per-page placement
20304
+ // editor. Every echoed operator free-text value (title, slug) is
20305
+ // _htmlEscape'd at the sink.
20306
+ function renderAdminSidebarWidgets(opts) {
20307
+ opts = opts || {};
20308
+ var widgets = opts.widgets || [];
20309
+ var placements = opts.placements || {};
20310
+ var pageKeys = opts.page_keys || [];
20311
+ var created = opts.created ? "<div class=\"banner banner--ok\">Widget saved.</div>" : "";
20312
+ var updated = opts.updated ? "<div class=\"banner banner--ok\">Widget updated.</div>" : "";
20313
+ var archived = opts.archived ? "<div class=\"banner banner--ok\">Widget archived.</div>" : "";
20314
+ var placed = opts.placed ? "<div class=\"banner banner--ok\">Page placement saved.</div>" : "";
20315
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
20316
+
20317
+ var widgetRows = widgets.map(function (w) {
20318
+ var enc = _htmlEscape(encodeURIComponent(w.slug));
20319
+ var isArchived = w.archived_at != null;
20320
+ return "<tr>" +
20321
+ "<td><code class=\"order-id\">" + _htmlEscape(w.slug) + "</code></td>" +
20322
+ "<td>" + _htmlEscape(w.title) + "</td>" +
20323
+ "<td>" + _htmlEscape(_SIDEBAR_KIND_LABEL[w.kind] || w.kind) + "</td>" +
20324
+ "<td>" + _htmlEscape(w.audience) + "</td>" +
20325
+ "<td><span class=\"status-pill " + (isArchived ? "cancelled" : "paid") + "\">" + (isArchived ? "archived" : "active") + "</span></td>" +
20326
+ "<td><div class=\"actions-row\">" +
20327
+ (isArchived ? "" :
20328
+ "<form method=\"post\" action=\"/admin/sidebar-widgets/" + enc + "/archive\" class=\"form-inline\">" +
20329
+ "<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form>") +
20330
+ "</div></td>" +
20331
+ "</tr>";
20332
+ }).join("");
20333
+
20334
+ var table = widgets.length
20335
+ ? "<div class=\"panel\">" + _tableWrap("<table><thead><tr><th scope=\"col\">Slug</th><th scope=\"col\">Title</th><th scope=\"col\">Kind</th><th scope=\"col\">Audience</th><th scope=\"col\">Status</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + widgetRows + "</tbody></table>") + "</div>"
20336
+ : "<p class=\"empty\">No sidebar widgets yet.</p>";
20337
+
20338
+ var kindOpts = Object.keys(_SIDEBAR_KIND_LABEL).map(function (k) {
20339
+ return "<option value=\"" + k + "\">" + _htmlEscape(_SIDEBAR_KIND_LABEL[k]) + "</option>";
20340
+ }).join("");
20341
+ var audienceOpts = ["all", "guest", "logged_in"].map(function (au) {
20342
+ return "<option value=\"" + au + "\">" + au + "</option>";
20343
+ }).join("");
20344
+
20345
+ var createForm =
20346
+ "<div class=\"panel mt mw-40\">" +
20347
+ "<h3 class=\"subhead\">Define a widget</h3>" +
20348
+ "<p class=\"meta\">Each widget has a stable slug + a kind that decides its content shape. Define it here, then place it on a page below. Leave the schedule blank for an always-on widget.</p>" +
20349
+ "<form method=\"post\" action=\"/admin/sidebar-widgets\">" +
20350
+ _setupField("Slug", "slug", "", "text", "Lowercase, hyphenated — a stable id.", " maxlength=\"80\" required") +
20351
+ _setupField("Title", "title", "", "text", "Shown as the widget heading.", " maxlength=\"200\" required") +
20352
+ "<label class=\"form-field\"><span>Kind</span><select name=\"kind\">" + kindOpts + "</select></label>" +
20353
+ "<label class=\"form-field\"><span>Audience</span><select name=\"audience\">" + audienceOpts + "</select></label>" +
20354
+ _setupField("Priority", "priority", "", "number", "Higher shows first in a list.", " min=\"0\"") +
20355
+ "<label class=\"form-field\"><span>Starts at (optional)</span><input type=\"datetime-local\" name=\"starts_at\"></label>" +
20356
+ "<label class=\"form-field\"><span>Expires at (optional)</span><input type=\"datetime-local\" name=\"expires_at\"></label>" +
20357
+ _sidebarPayloadFields() +
20358
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Define widget</button></div>" +
20359
+ "</form>" +
20360
+ "</div>";
20361
+
20362
+ // Per-page placement editor. For each page, a form of checkboxes (one per
20363
+ // active widget, pre-checked when already placed) posts the ordered slug
20364
+ // set. Archived widgets are omitted (they can't be placed).
20365
+ var activeWidgets = widgets.filter(function (w) { return w.archived_at == null; });
20366
+ var placementForms = pageKeys.map(function (key) {
20367
+ var placedSlugs = placements[key] || [];
20368
+ var boxes = activeWidgets.map(function (w) {
20369
+ var checked = placedSlugs.indexOf(w.slug) !== -1 ? " checked" : "";
20370
+ return "<label class=\"kv\"><input type=\"checkbox\" name=\"slug\" value=\"" + _htmlEscape(w.slug) + "\"" + checked + "> " +
20371
+ _htmlEscape(w.title) + " <span class=\"meta\">(" + _htmlEscape(_SIDEBAR_KIND_LABEL[w.kind] || w.kind) + ")</span></label>";
20372
+ }).join("");
20373
+ var inner = activeWidgets.length
20374
+ ? boxes + "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Save " + _htmlEscape(key) + " sidebar</button></div>"
20375
+ : "<p class=\"empty\">Define a widget first.</p>";
20376
+ return "<div class=\"panel\"><h3 class=\"subhead\">" + _htmlEscape(key) + " page</h3>" +
20377
+ "<form method=\"post\" action=\"/admin/sidebar-widgets/placement\">" +
20378
+ "<input type=\"hidden\" name=\"page_key\" value=\"" + _htmlEscape(key) + "\">" +
20379
+ inner +
20380
+ "</form>" +
20381
+ "</div>";
20382
+ }).join("");
20383
+ var placementSection = "<section class=\"mt\"><h3 class=\"subhead\">Page placement</h3>" +
20384
+ "<p class=\"meta\">Choose which widgets render in each page's right rail, in checkbox order.</p>" +
20385
+ placementForms + "</section>";
20386
+
20387
+ var bodyHtml = "<section><h2>Sidebar widgets</h2>" +
20388
+ created + updated + archived + placed + notice +
20389
+ table + createForm + placementSection + "</section>";
20390
+ return _renderAdminShell(opts.shop_name, "Sidebar widgets", bodyHtml, "sidebar-widgets", opts.nav_available);
20391
+ }
20392
+
19121
20393
  // Search-suggestions curation screen: the featured-suggestion table (with
19122
20394
  // a per-row inline priority/status edit + a delete), a create form, and a
19123
20395
  // read-only "Popular searches" view over the recent query log. Every
@@ -21643,6 +22915,13 @@ function renderAdminProduct(opts) {
21643
22915
  module.exports = {
21644
22916
  mount: mount,
21645
22917
  AUDIT_NAMESPACE: AUDIT_NAMESPACE,
22918
+ // RBAC introspection — the action→permission resolver + the role-grant
22919
+ // predicate the `_wrap` chokepoint uses, surfaced so a test can pin the
22920
+ // fail-closed boundary (an unmapped mutating action is owner-only) without
22921
+ // standing up a live route for every possible un-mapped prefix.
22922
+ _permissionForAction: _permissionForAction,
22923
+ _roleGrants: _roleGrants,
22924
+ _OWNER_ONLY_PERMISSION: OWNER_ONLY_PERMISSION,
21646
22925
  renderDashboard: renderDashboard,
21647
22926
  renderAdminAnalytics: renderAdminAnalytics,
21648
22927
  renderAdminLogin: renderAdminLogin,