@blamejs/blamejs-shop 0.4.23 → 0.4.24

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
@@ -118,6 +131,7 @@ var _ACTION_PERMISSION = Object.freeze({
118
131
  gift: "catalog.write", preorder: "catalog.write", quantity_discount: "catalog.write",
119
132
  auto_discount: "catalog.write", coupon_policy: "catalog.write",
120
133
  promo_banner: "catalog.write", announcement: "catalog.write", blog: "catalog.write",
134
+ suggestion: "catalog.write", sidebar_widget: "catalog.write",
121
135
  page: "catalog.write", help: "catalog.write", survey: "catalog.write",
122
136
  hours: "catalog.write", delivery_holiday: "catalog.write",
123
137
  delivery_transit: "catalog.write", tax_rate: "catalog.write",
@@ -130,6 +144,7 @@ var _ACTION_PERMISSION = Object.freeze({
130
144
  export: "orders.write", tax_filing: "orders.write", quote: "orders.write",
131
145
  gift_card: "orders.write", subscription: "orders.write",
132
146
  cart_recovery_code: "orders.write", support: "orders.write",
147
+ inbox: "orders.write",
133
148
  // customers
134
149
  customer: "customers.write", customer_segment: "customers.write",
135
150
  // shop configuration
@@ -846,13 +861,31 @@ function mount(router, deps) {
846
861
  var operatorAccounts = deps.operatorAccounts || null;
847
862
  var operatorAuditLog = deps.operatorAuditLog || null;
848
863
 
864
+ // Unified order-event feed for the order-detail screen. When wired, the
865
+ // /admin/orders/:id page renders a chronological timeline aggregated
866
+ // across the FSM transitions, shipment events, customer-service notes,
867
+ // returns, shipping labels, and dispatched notifications — one read of
868
+ // the whole order story rather than scanning each panel. Read-only: the
869
+ // primitive only memoizes a summary cache, never mutating an event row.
870
+ var orderTimeline = deps.orderTimeline || null;
871
+
872
+ // Operator inbox + navbar unread badge. When wired, the /admin/inbox
873
+ // screen lists notifications broadcast to the fulfillment role (newest
874
+ // order pings, etc.) with mark-read / archive, and every authed page's
875
+ // nav carries the unread count. The new-order ping is enqueued by the
876
+ // order FSM's paid-edge observer (wired in server.js); this console is
877
+ // the read + clear side. The audience is a role broadcast so a single-
878
+ // credential deployment surfaces the badge without modelling one owning
879
+ // operator — `INBOX_ROLE` is that role.
880
+ var operatorInbox = deps.operatorInbox || null;
881
+
849
882
  // Which optional console sections are wired — gates their nav links so a
850
883
  // signed-in admin is never sent to a route that wasn't mounted. Passed
851
884
  // into every authed render call as `nav_available`.
852
885
  // `reports` is always present in the nav (read-only sales summary needs no
853
886
  // extra dep); its route mounts unconditionally and renders an unconfigured
854
887
  // 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 };
888
+ 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
889
 
857
890
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
858
891
 
@@ -868,12 +901,35 @@ function mount(router, deps) {
868
901
  if (typeof router.use === "function") {
869
902
  router.use(function adminCsrfTokenMiddleware(req, _res, next) {
870
903
  try {
871
- _csrfAls.enterWith({ csrf_token: req.csrfToken || "" });
904
+ // One mutable store object per request — the CSRF token is seeded
905
+ // here synchronously; the inbox-badge middleware below fills in the
906
+ // unread count on the SAME object once it has awaited the read.
907
+ _csrfAls.enterWith({ csrf_token: req.csrfToken || "", inbox_unread: 0 });
872
908
  } catch (_e) { /* drop-silent — form renders token-less */ }
873
909
  next();
874
910
  });
875
911
  }
876
912
 
913
+ // Seed the navbar unread-inbox badge count onto the per-request ALS
914
+ // store so `_renderAdminShell` can stamp it into the nav on EVERY authed
915
+ // page — the operator sees a new sale without polling or opening the
916
+ // inbox. The audience is a role broadcast (INBOX_ROLE), so the count is
917
+ // role-scoped (`unreadCountForRole`), independent of whether the actor is
918
+ // the bootstrap owner or a per-operator session. Runs after the CSRF
919
+ // middleware (same store object). Drop-silent + best-effort: a read
920
+ // failure (or an unmigrated table) leaves the badge at zero rather than
921
+ // 500-ing the console. Skipped entirely when no inbox is wired.
922
+ if (operatorInbox && typeof router.use === "function") {
923
+ router.use(async function adminInboxBadgeMiddleware(_req, _res, next) {
924
+ try {
925
+ var n = await operatorInbox.unreadCountForRole({ role: INBOX_ROLE });
926
+ var store = _csrfAls.getStore();
927
+ if (store) store.inbox_unread = Number(n) || 0;
928
+ } catch (_e) { /* drop-silent — badge renders at zero */ }
929
+ next();
930
+ });
931
+ }
932
+
877
933
  // The auth context every wrapped route resolves through — the bootstrap
878
934
  // token plus the per-operator credential store + the chained audit peer.
879
935
  // Threaded into `_wrap` so the single chokepoint owns BOTH the credential
@@ -2119,6 +2175,30 @@ function mount(router, deps) {
2119
2175
  notes = (await orderNotes.listForOrder({ order_id: o.id, limit: NOTES_PANEL_LIMIT })).rows || [];
2120
2176
  } catch (_ne) { notes = []; }
2121
2177
  }
2178
+ // Chronological order story — one feed across FSM transitions,
2179
+ // shipment events, notes, returns, labels, and dispatched
2180
+ // notifications. Operator view (no customer_visible_only), bounded
2181
+ // to the most recent slice. Best-effort: an unwired timeline
2182
+ // primitive (or an unmigrated source table) degrades to no panel
2183
+ // rather than 500-ing the detail page.
2184
+ var timeline = null;
2185
+ if (orderTimeline) {
2186
+ try {
2187
+ var feed = await orderTimeline.forOrder({ order_id: o.id });
2188
+ timeline = (feed || []).slice(0, TIMELINE_PANEL_LIMIT);
2189
+ } catch (_te) { timeline = null; }
2190
+ }
2191
+ // Running refunded total (minor units) so the partial-refund panel
2192
+ // can state how much is already refunded and cap the next slice at
2193
+ // the remaining balance. Sums every `refund` order-transition row's
2194
+ // amount — integer minor-unit arithmetic, never a float. Best-effort:
2195
+ // a read failure leaves the panel to treat the order as un-refunded
2196
+ // (the route re-validates the cap server-side before moving money).
2197
+ var refundedMinor = 0;
2198
+ if (payment && o.payment_intent_id) {
2199
+ try { refundedMinor = await order.refundedTotalMinor(o.id); }
2200
+ catch (_re) { refundedMinor = 0; }
2201
+ }
2122
2202
  _sendHtml(res, 200, renderAdminOrder({
2123
2203
  shop_name: deps.shop_name,
2124
2204
  nav_available: navAvailable,
@@ -2127,6 +2207,15 @@ function mount(router, deps) {
2127
2207
  // Refund moves money, so the console only offers it when a payment
2128
2208
  // provider is wired AND the order has a captured intent to refund.
2129
2209
  can_refund: !!(payment && o.payment_intent_id),
2210
+ // Partial-refund panel inputs. `refunded_minor` is the running
2211
+ // total already refunded; `refundable_minor` is what's left of the
2212
+ // order's grand total. The panel renders a decimal-amount form
2213
+ // capped at the remaining balance; the route re-validates the cap
2214
+ // server-side (the form is display only — the backend is the gate).
2215
+ refunded_minor: refundedMinor,
2216
+ refundable_minor: Math.max(0, (Number(o.grand_total_minor) || 0) - refundedMinor),
2217
+ refund_done: url && url.searchParams.get("refunded"),
2218
+ refund_err: url && url.searchParams.get("refund_err") ? url.searchParams.get("refund_err") : null,
2130
2219
  // Shipment/tracking panel only renders when the tracking primitive
2131
2220
  // is wired; the carrier + status enums drive its form selects.
2132
2221
  can_track: !!orderTracking,
@@ -2168,6 +2257,11 @@ function mount(router, deps) {
2168
2257
  note_err: url && url.searchParams.get("note_err") ? url.searchParams.get("note_err") : null,
2169
2258
  note_authors: orderNotes ? orderNotes.ALLOWED_AUTHORS : null,
2170
2259
  note_visibility: orderNotes ? orderNotes.ALLOWED_VISIBILITY : null,
2260
+ // Order timeline panel — renders only when the timeline primitive
2261
+ // is wired. The feed is already operator-facing event shape
2262
+ // (`{ kind, occurred_at, title, body?, actor?, link? }`).
2263
+ can_timeline: !!orderTimeline,
2264
+ timeline: timeline,
2171
2265
  notice: url && url.searchParams.get("err") ? "That action couldn't be completed for this order." : null,
2172
2266
  }));
2173
2267
  },
@@ -3954,6 +4048,214 @@ function mount(router, deps) {
3954
4048
  _redirect(res, "/admin/orders/" + encodeURIComponent(id) + "?moved=1");
3955
4049
  },
3956
4050
  ));
4051
+
4052
+ // ---- partial refund -------------------------------------------------
4053
+ //
4054
+ // Refund a specific amount that may NOT clear the order's balance. The
4055
+ // operator types a decimal amount (e.g. "12.50"); the backend parses it
4056
+ // through `b.money.of(<decimal>, <currency>)` so the minor-unit count is
4057
+ // exact and currency-exponent-correct (USD→2, JPY→0) — never a float,
4058
+ // and extra fractional digits are refused at the parse boundary. The
4059
+ // remaining-balance cap is enforced SERVER-SIDE before any money moves:
4060
+ // refunded-so-far is summed from the ledger, and a slice that would push
4061
+ // the total past the order's grand total is refused (422) with NOTHING
4062
+ // sent to the provider. The provider refund is issued FIRST under an
4063
+ // idempotency key derived from the order + amount + the refunded-total
4064
+ // seen, so a double-submit (or a retry) collapses to one provider charge
4065
+ // and one ledger row rather than refunding twice — the race guards the
4066
+ // payment-integrity work established. Only after the provider succeeds is
4067
+ // the ledger touched: a slice that exactly clears the remaining balance
4068
+ // drives the terminal FSM `refund` edge (order → refunded, running the
4069
+ // gift-card / loyalty reversals); a slice that leaves a balance records a
4070
+ // same-state partial-refund row (the order keeps its lifecycle state).
4071
+ async function _partialRefund(o, decimalAmount) {
4072
+ var alreadyMinor = await order.refundedTotalMinor(o.id);
4073
+ var remainingMinor = (Number(o.grand_total_minor) || 0) - alreadyMinor;
4074
+ if (remainingMinor <= 0) {
4075
+ var none = new TypeError("This order is already fully refunded — nothing remains to refund.");
4076
+ none._refundCode = "nothing-remaining";
4077
+ throw none;
4078
+ }
4079
+ // Parse the operator-typed decimal into exact minor units via the
4080
+ // money primitive (BigInt, currency-exponent-aware). A bad shape /
4081
+ // too many fractional digits throws — surfaced as a clean 4xx.
4082
+ var minor;
4083
+ try {
4084
+ minor = Number(b.money.of(String(decimalAmount == null ? "" : decimalAmount).trim(), o.currency).toMinorUnits());
4085
+ } catch (_pe) {
4086
+ var bad = new TypeError("Enter a valid amount in " + o.currency + " (e.g. 12.50) — no more decimal places than the currency allows.");
4087
+ bad._refundCode = "bad-amount";
4088
+ throw bad;
4089
+ }
4090
+ if (!Number.isInteger(minor) || minor <= 0) {
4091
+ var pos = new TypeError("The refund amount must be greater than zero.");
4092
+ pos._refundCode = "bad-amount";
4093
+ throw pos;
4094
+ }
4095
+ if (minor > remainingMinor) {
4096
+ // Over-refund — would push the refunded total past what the customer
4097
+ // paid. Refuse BEFORE the provider is called so no money moves.
4098
+ var over = new TypeError("That's more than the " + pricing.format(remainingMinor, o.currency) +
4099
+ " still refundable on this order.");
4100
+ over._refundCode = "over-refund";
4101
+ throw over;
4102
+ }
4103
+ var clearsBalance = (minor === remainingMinor);
4104
+ // Idempotency key folds in the refunded-total seen, so two submits of
4105
+ // the same slice (a double-click, a retry) reuse one provider refund.
4106
+ var idemKey = "refund:" + o.id + ":partial:" + alreadyMinor + ":" + minor;
4107
+ var refund = await payment.refund({
4108
+ payment_intent: o.payment_intent_id,
4109
+ amount_minor: minor,
4110
+ reason: "requested_by_customer",
4111
+ metadata: { order_id: o.id, partial: !clearsBalance },
4112
+ }, idemKey);
4113
+ var refundedMinor = Number(refund.amount) || minor;
4114
+ if (clearsBalance) {
4115
+ // The slice clears the remaining balance — drive the terminal FSM
4116
+ // edge so the order moves to `refunded` (and the gift-card / loyalty
4117
+ // reversals fire). A transition refusal (already terminal) is
4118
+ // swallowed: the provider refund has succeeded and is the source of
4119
+ // truth, surfaced via the refreshed ledger.
4120
+ try {
4121
+ await order.transition(o.id, "refund", {
4122
+ reason: "admin:refund:partial-final",
4123
+ metadata: { stripe_refund_id: refund.id, amount_minor: refundedMinor, partial: true },
4124
+ });
4125
+ } catch (_te) { /* provider refund persisted; FSM refusal surfaced via re-fetch */ }
4126
+ } else {
4127
+ // Leaves a balance — append the partial-refund ledger row WITHOUT
4128
+ // changing the order's lifecycle state.
4129
+ await order.recordPartialRefund(o.id, {
4130
+ amount_minor: refundedMinor,
4131
+ reason: "admin:refund:partial",
4132
+ metadata: { stripe_refund_id: refund.id },
4133
+ });
4134
+ }
4135
+ return { refund: refund, amount_minor: refundedMinor, cleared: clearsBalance };
4136
+ }
4137
+
4138
+ router.post("/admin/orders/:id/refund/partial", _pageOrApi(false,
4139
+ W("order.refund", async function (req, res) {
4140
+ var o = await order.get(req.params.id);
4141
+ if (!o) return _problem(res, 404, "order-not-found");
4142
+ if (!o.payment_intent_id) return _problem(res, 422, "no-payment-intent", "Order has no linked payment intent");
4143
+ var body = req.body || {};
4144
+ var result;
4145
+ try {
4146
+ result = await _partialRefund(o, body.amount);
4147
+ } catch (e) {
4148
+ if (e instanceof TypeError) {
4149
+ var status = e._refundCode === "over-refund" || e._refundCode === "nothing-remaining" ? 422 : 400;
4150
+ return _problem(res, status, e._refundCode || "bad-request", e.message);
4151
+ }
4152
+ // 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.
4153
+ return _problem(res, 502, "stripe-refund-failed", (e && e.message) || String(e));
4154
+ }
4155
+ _json(res, 200, result);
4156
+ return { id: o.id };
4157
+ }),
4158
+ async function (req, res) {
4159
+ var id = req.params.id;
4160
+ var o;
4161
+ try { o = await order.get(id); }
4162
+ catch (e) { if (!(e instanceof TypeError)) throw e; o = null; }
4163
+ if (!o || !o.payment_intent_id) {
4164
+ return _redirect(res, "/admin/orders/" + encodeURIComponent(id) + "?err=1");
4165
+ }
4166
+ var enc = encodeURIComponent(id);
4167
+ try {
4168
+ await _partialRefund(o, (req.body || {}).amount);
4169
+ } catch (e) {
4170
+ if (e instanceof TypeError) {
4171
+ // Operator-actionable validation message (over-refund, bad amount,
4172
+ // already-refunded) surfaces verbatim on the detail page.
4173
+ return _redirect(res, "/admin/orders/" + enc + "?refund_err=" + encodeURIComponent(e.message));
4174
+ }
4175
+ // Provider refund failed — the order is untouched (the ledger is
4176
+ // only written after a successful provider refund).
4177
+ return _redirect(res, "/admin/orders/" + enc + "?refund_err=" +
4178
+ encodeURIComponent("The refund couldn't be processed by the payment provider. Nothing was charged back — try again."));
4179
+ }
4180
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".order.refund", outcome: "success", metadata: { id: id, partial: true } });
4181
+ _redirect(res, "/admin/orders/" + enc + "?refunded=1");
4182
+ },
4183
+ ));
4184
+ }
4185
+
4186
+ // ---- operator inbox -------------------------------------------------
4187
+ //
4188
+ // The in-console notification feed. Mounted only when an operator-inbox
4189
+ // primitive is wired. The audience is a role broadcast (INBOX_ROLE) — a
4190
+ // new-order ping (enqueued by the order FSM's paid-edge observer) reaches
4191
+ // every operator carrying the role without modelling one owning operator,
4192
+ // so the navbar badge + this screen work the same on a single-credential
4193
+ // console and a multi-operator one. The screen lists the role's messages
4194
+ // newest-first with mark-read / archive; each write asserts the row
4195
+ // carries the role before mutating (a guessed id from another role can't
4196
+ // be cleared here).
4197
+ if (operatorInbox) {
4198
+ router.get("/admin/inbox", _pageOrApi(true,
4199
+ R(async function (req, res) {
4200
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
4201
+ var includeArchived = !!(url && url.searchParams.get("archived"));
4202
+ var page = await operatorInbox.inboxForRole({
4203
+ role: INBOX_ROLE, include_archived: includeArchived, limit: INBOX_PANEL_LIMIT,
4204
+ });
4205
+ _json(res, 200, page);
4206
+ }),
4207
+ async function (req, res) {
4208
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
4209
+ var includeArchived = !!(url && url.searchParams.get("archived"));
4210
+ var messages = [];
4211
+ try {
4212
+ messages = (await operatorInbox.inboxForRole({
4213
+ role: INBOX_ROLE, include_archived: includeArchived, limit: INBOX_PANEL_LIMIT,
4214
+ })).rows || [];
4215
+ } catch (_e) { messages = []; }
4216
+ _sendHtml(res, 200, _renderAdminInbox({
4217
+ shop_name: deps.shop_name, nav_available: navAvailable,
4218
+ messages: messages, include_archived: includeArchived,
4219
+ done: url && url.searchParams.get("done"),
4220
+ notice: url && url.searchParams.get("err") ? "That action couldn't be completed." : null,
4221
+ }));
4222
+ },
4223
+ ));
4224
+
4225
+ // Shared mark-read / archive write — role-scoped (the row must carry
4226
+ // INBOX_ROLE). A malformed id throws (TypeError → clean 4xx); an unknown
4227
+ // id or one addressed elsewhere is a clean 404 with nothing written.
4228
+ function _inboxWriteRoute(suffix, audit, op) {
4229
+ router.post("/admin/inbox/:id" + suffix, _pageOrApi(false,
4230
+ W(audit, async function (req, res) {
4231
+ var out;
4232
+ try { out = await op(req.params.id); }
4233
+ catch (e) {
4234
+ if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
4235
+ if (e && (e.code === "INBOX_MESSAGE_NOT_FOUND" || e.code === "INBOX_MESSAGE_NOT_ADDRESSABLE")) {
4236
+ return _problem(res, 404, "inbox-message-not-found");
4237
+ }
4238
+ throw e;
4239
+ }
4240
+ _json(res, 200, out);
4241
+ return { id: req.params.id };
4242
+ }),
4243
+ async function (req, res) {
4244
+ try { await op(req.params.id); }
4245
+ catch (_e) {
4246
+ // Malformed / unknown / cross-role id — a clean notice, never a 500.
4247
+ return _redirect(res, "/admin/inbox?err=1");
4248
+ }
4249
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + "." + audit, outcome: "success", metadata: { id: req.params.id } });
4250
+ _redirect(res, "/admin/inbox?done=1");
4251
+ },
4252
+ ));
4253
+ }
4254
+
4255
+ _inboxWriteRoute("/read", "inbox.read",
4256
+ function (id) { return operatorInbox.markReadForRole({ id: id, role: INBOX_ROLE }); });
4257
+ _inboxWriteRoute("/archive", "inbox.archive",
4258
+ function (id) { return operatorInbox.archiveForRole({ id: id, role: INBOX_ROLE }); });
3957
4259
  }
3958
4260
 
3959
4261
  // ---- reviews (moderation) -------------------------------------------
@@ -5157,9 +5459,19 @@ function mount(router, deps) {
5157
5459
  });
5158
5460
  }
5159
5461
 
5462
+ // The privacy/DSR actions administer a customer's personal data — a PII
5463
+ // export bundle (fulfill / dispatch) or an irreversible erasure (delete).
5464
+ // They gate on `customers.write`, the same grant the customer-record
5465
+ // routes use, so a read-only viewer is refused on the verb (and the
5466
+ // denial is chained through the operator audit log) rather than able to
5467
+ // export or erase. The `customer.` audit-action prefix maps to
5468
+ // `customers.write` in `_ACTION_PERMISSION`; wrapping the JSON handler in
5469
+ // `W(...)` gates BOTH the bearer JSON path (inside `_wrap`) and the
5470
+ // browser-form path (`_dsrAction` forwards the handler to `_pageOrApi`,
5471
+ // which reads its `_adminWriteAction` tag to gate the cookie branch).
5160
5472
  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); }
5473
+ W("customer.dsr_fulfill", async function (req, res) {
5474
+ try { var bundle = await dsr.fulfillRequest({ request_id: req.params.id }); _json(res, 200, bundle); return { id: req.params.id }; }
5163
5475
  catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5164
5476
  }),
5165
5477
  "dsr.fulfill",
@@ -5167,9 +5479,9 @@ function mount(router, deps) {
5167
5479
  ));
5168
5480
 
5169
5481
  router.post("/admin/dsr/:id/dispatch", _dsrAction(
5170
- R(async function (req, res) {
5482
+ W("customer.dsr_dispatch", async function (req, res) {
5171
5483
  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); }
5484
+ 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
5485
  catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5174
5486
  }),
5175
5487
  "dsr.dispatch",
@@ -5177,9 +5489,9 @@ function mount(router, deps) {
5177
5489
  ));
5178
5490
 
5179
5491
  router.post("/admin/dsr/:id/dismiss", _dsrAction(
5180
- R(async function (req, res) {
5492
+ W("customer.dsr_dismiss", async function (req, res) {
5181
5493
  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); }
5494
+ 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
5495
  catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5184
5496
  }),
5185
5497
  "dsr.dismiss",
@@ -5193,8 +5505,8 @@ function mount(router, deps) {
5193
5505
  // which point processDeletion runs for real (dry_run: false). The bearer
5194
5506
  // JSON path executes directly (tooling has no interstitial).
5195
5507
  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); }
5508
+ W("customer.dsr_delete", async function (req, res) {
5509
+ try { var result = await dsr.processDeletion({ request_id: req.params.id, dry_run: false }); _json(res, 200, result); return { id: req.params.id }; }
5198
5510
  catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5199
5511
  }),
5200
5512
  async function (req, res) {
@@ -7210,6 +7522,305 @@ function mount(router, deps) {
7210
7522
  ));
7211
7523
  }
7212
7524
 
7525
+ // ---- suggestion box -------------------------------------------------
7526
+ // The customer-submitted idea backlog. The triage list (optionally
7527
+ // filtered by status / category) + a per-suggestion detail screen where
7528
+ // the operator responds (status transition through the roadmap FSM),
7529
+ // archives, or flags as spam. Content-negotiated like the other screens:
7530
+ // bearer → JSON; signed-in browser → the HTML table + detail. The public
7531
+ // /suggestions storefront page is the intake; this is the operator side
7532
+ // of the same instance.
7533
+ if (deps.suggestionBox) {
7534
+ var suggestionBox = deps.suggestionBox;
7535
+
7536
+ // Read a triage page. The console lists open + responded rows (it never
7537
+ // surfaces spam-flagged / archived rows in the default view) sorted
7538
+ // newest-first; a `?status=` / `?category=` chip narrows it. Drop-silent
7539
+ // to an empty list on a read error so the screen renders rather than 500.
7540
+ var _loadSuggestionTriage = async function (status, category, cursor) {
7541
+ var listOpts = { sort: "newest", limit: 50 };
7542
+ if (status) listOpts.status = status;
7543
+ if (category) listOpts.category = category;
7544
+ if (cursor) listOpts.cursor = cursor;
7545
+ try {
7546
+ var page = await suggestionBox.listSuggestions(listOpts);
7547
+ return { rows: page.rows || [], next_cursor: page.next_cursor || null };
7548
+ } catch (_e) {
7549
+ return { rows: [], next_cursor: null };
7550
+ }
7551
+ };
7552
+
7553
+ router.get("/admin/suggestions", _pageOrApi(true,
7554
+ R(async function (req, res) {
7555
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
7556
+ var status = url && url.searchParams.get("status");
7557
+ var category = url && url.searchParams.get("category");
7558
+ var cursor = url && url.searchParams.get("cursor");
7559
+ var page = await _loadSuggestionTriage(status || null, category || null, cursor || null);
7560
+ _json(res, 200, { rows: page.rows, next_cursor: page.next_cursor });
7561
+ }),
7562
+ async function (req, res) {
7563
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
7564
+ var status = (url && url.searchParams.get("status")) || null;
7565
+ var category = (url && url.searchParams.get("category")) || null;
7566
+ var cursor = (url && url.searchParams.get("cursor")) || null;
7567
+ var page = await _loadSuggestionTriage(status, category, cursor);
7568
+ _sendHtml(res, 200, renderAdminSuggestions({
7569
+ shop_name: deps.shop_name, nav_available: navAvailable,
7570
+ suggestions: page.rows, next_cursor: page.next_cursor,
7571
+ status_filter: status, category_filter: category,
7572
+ responded: url && url.searchParams.get("responded"),
7573
+ archived: url && url.searchParams.get("archived"),
7574
+ flagged: url && url.searchParams.get("flagged"),
7575
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the suggestion." : null,
7576
+ }));
7577
+ },
7578
+ ));
7579
+
7580
+ router.get("/admin/suggestions/:id", _pageOrApi(true,
7581
+ R(async function (req, res) {
7582
+ var row = await suggestionBox.getSuggestion(req.params.id);
7583
+ if (!row) return _problem(res, 404, "suggestion-not-found");
7584
+ _json(res, 200, row);
7585
+ }),
7586
+ async function (req, res) {
7587
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
7588
+ var row = null;
7589
+ try { row = await suggestionBox.getSuggestion(req.params.id); }
7590
+ catch (_e) { row = null; }
7591
+ if (!row) return _sendHtml(res, 404, renderAdminSuggestions({
7592
+ shop_name: deps.shop_name, nav_available: navAvailable, suggestions: [], notice: "Suggestion not found.",
7593
+ }));
7594
+ _sendHtml(res, 200, renderAdminSuggestion({
7595
+ shop_name: deps.shop_name, nav_available: navAvailable, suggestion: row,
7596
+ responded: url && url.searchParams.get("responded"),
7597
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the suggestion." : null,
7598
+ }));
7599
+ },
7600
+ ));
7601
+
7602
+ // Respond — transition the suggestion through the roadmap FSM, optionally
7603
+ // leaving a public-visible reply. The form posts status + response +
7604
+ // responder; the responder is the signed-in operator's id when resolved,
7605
+ // else a console label.
7606
+ router.post("/admin/suggestions/:id/respond", _pageOrApi(false,
7607
+ W("suggestion.respond", async function (req, res) {
7608
+ var body = req.body || {};
7609
+ var row;
7610
+ try {
7611
+ row = await suggestionBox.respondToSuggestion({
7612
+ suggestion_id: req.params.id,
7613
+ status: body.status,
7614
+ response: typeof body.response === "string" ? body.response : "",
7615
+ responder: _suggestionResponder(req, body),
7616
+ });
7617
+ } catch (e) {
7618
+ if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
7619
+ if (e && e.code === "SUGGESTION_NOT_FOUND") return _problem(res, 404, "suggestion-not-found");
7620
+ if (e && e.code === "SUGGESTION_INVALID_TRANSITION") return _problem(res, 409, "conflict", e.message);
7621
+ if (e && e.code === "SUGGESTION_ARCHIVED") return _problem(res, 409, "conflict", e.message);
7622
+ throw e;
7623
+ }
7624
+ _json(res, 200, row);
7625
+ return { id: row.id };
7626
+ }),
7627
+ async function (req, res) {
7628
+ var id = req.params.id;
7629
+ var enc = encodeURIComponent(id);
7630
+ var body = req.body || {};
7631
+ try {
7632
+ await suggestionBox.respondToSuggestion({
7633
+ suggestion_id: id,
7634
+ status: body.status,
7635
+ response: typeof body.response === "string" ? body.response : "",
7636
+ responder: _suggestionResponder(req, body),
7637
+ });
7638
+ } catch (e) {
7639
+ if (e instanceof TypeError || (e && e.code)) return _redirect(res, "/admin/suggestions/" + enc + "?err=1");
7640
+ throw e;
7641
+ }
7642
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".suggestion.respond", outcome: "success", metadata: { id: id } });
7643
+ _redirect(res, "/admin/suggestions/" + enc + "?responded=1");
7644
+ },
7645
+ ));
7646
+
7647
+ router.post("/admin/suggestions/:id/archive", _pageOrApi(false,
7648
+ W("suggestion.archive", async function (req, res) {
7649
+ var row;
7650
+ try { row = await suggestionBox.archiveSuggestion(req.params.id); }
7651
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
7652
+ if (!row) return _problem(res, 404, "suggestion-not-found");
7653
+ _json(res, 200, row);
7654
+ return { id: row.id };
7655
+ }),
7656
+ async function (req, res) {
7657
+ var id = req.params.id;
7658
+ var row = null;
7659
+ try { row = await suggestionBox.archiveSuggestion(id); }
7660
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
7661
+ if (!row) return _redirect(res, "/admin/suggestions?err=1");
7662
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".suggestion.archive", outcome: "success", metadata: { id: id } });
7663
+ _redirect(res, "/admin/suggestions?archived=1");
7664
+ },
7665
+ ));
7666
+
7667
+ router.post("/admin/suggestions/:id/flag", _pageOrApi(false,
7668
+ W("suggestion.flag", async function (req, res) {
7669
+ var body = req.body || {};
7670
+ var flagged = !(body.flagged === "0" || body.flagged === false || body.flagged === "false");
7671
+ var row;
7672
+ try { row = await suggestionBox.flagAsSpam({ suggestion_id: req.params.id, flagged: flagged }); }
7673
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
7674
+ if (!row) return _problem(res, 404, "suggestion-not-found");
7675
+ _json(res, 200, row);
7676
+ return { id: row.id };
7677
+ }),
7678
+ async function (req, res) {
7679
+ var id = req.params.id;
7680
+ var body = req.body || {};
7681
+ // The console toggle posts the desired next state via a hidden field
7682
+ // (flag → 1, un-flag → 0); a missing field defaults to flag.
7683
+ var flagged = !(body.flagged === "0");
7684
+ var row = null;
7685
+ try { row = await suggestionBox.flagAsSpam({ suggestion_id: id, flagged: flagged }); }
7686
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
7687
+ if (!row) return _redirect(res, "/admin/suggestions?err=1");
7688
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".suggestion.flag", outcome: "success", metadata: { id: id, flagged: flagged } });
7689
+ _redirect(res, "/admin/suggestions?flagged=1");
7690
+ },
7691
+ ));
7692
+ }
7693
+
7694
+ // ---- sidebar widgets ------------------------------------------------
7695
+ // Operator-curated storefront right-rail content. The console defines
7696
+ // widgets (kind-specific payload) + sets each page's ordered placement;
7697
+ // the storefront resolves + renders the placed widgets per page. Content-
7698
+ // negotiated like the other screens: bearer → JSON; signed-in browser →
7699
+ // the HTML table + create form + the per-page placement editor. The
7700
+ // console exposes the all / guest / logged_in audiences; the primitive's
7701
+ // segment audience needs an isMember handle that isn't wired (see
7702
+ // server.js), so it isn't offered here.
7703
+ if (deps.sidebarWidgets) {
7704
+ var sidebarWidgets = deps.sidebarWidgets;
7705
+
7706
+ // The pages a sidebar can be placed on. These page_keys are the same
7707
+ // values the storefront resolver derives from the request path, so an
7708
+ // operator placing widgets on "home" reaches the home page's right rail.
7709
+ var _SIDEBAR_PAGE_KEYS = ["home", "collection", "search", "cart", "product"];
7710
+
7711
+ var _loadSidebarConsole = async function () {
7712
+ var widgets = [];
7713
+ try { widgets = await sidebarWidgets.listWidgets({ include_archived: true, limit: sidebarWidgets.MAX_LIMIT || 500 }); }
7714
+ catch (_e) { widgets = []; }
7715
+ // Resolve each page's current ordered placement so the editor can
7716
+ // pre-check the boxes. widgetsForPage filters by audience/schedule, so
7717
+ // for the editor we read the raw placement via a far-future "now" +
7718
+ // a guest viewer to surface every placed slug regardless of window;
7719
+ // archived widgets drop out (they can't be placed). Drop-silent.
7720
+ var placements = Object.create(null);
7721
+ for (var i = 0; i < _SIDEBAR_PAGE_KEYS.length; i += 1) {
7722
+ var key = _SIDEBAR_PAGE_KEYS[i];
7723
+ placements[key] = [];
7724
+ try {
7725
+ var rows = await sidebarWidgets.widgetsForPage({
7726
+ page_key: key, viewer_kind: "guest", now: 1,
7727
+ });
7728
+ placements[key] = rows.map(function (r) { return r.slug; });
7729
+ } catch (_e2) { placements[key] = []; }
7730
+ }
7731
+ return { widgets: widgets, placements: placements, page_keys: _SIDEBAR_PAGE_KEYS.slice() };
7732
+ };
7733
+
7734
+ router.get("/admin/sidebar-widgets", _pageOrApi(true,
7735
+ R(async function (_req, res) {
7736
+ var console_ = await _loadSidebarConsole();
7737
+ _json(res, 200, { widgets: console_.widgets, placements: console_.placements });
7738
+ }),
7739
+ async function (req, res) {
7740
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
7741
+ var console_ = await _loadSidebarConsole();
7742
+ _sendHtml(res, 200, renderAdminSidebarWidgets({
7743
+ shop_name: deps.shop_name, nav_available: navAvailable,
7744
+ widgets: console_.widgets, placements: console_.placements, page_keys: console_.page_keys,
7745
+ created: url && url.searchParams.get("created"),
7746
+ updated: url && url.searchParams.get("updated"),
7747
+ archived: url && url.searchParams.get("archived"),
7748
+ placed: url && url.searchParams.get("placed"),
7749
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the widget." : null,
7750
+ }));
7751
+ },
7752
+ ));
7753
+
7754
+ router.post("/admin/sidebar-widgets", _pageOrApi(false,
7755
+ W("sidebar_widget.define", async function (req, res) {
7756
+ var row;
7757
+ try { row = await sidebarWidgets.defineWidget(_sidebarWidgetFromForm(req.body || {})); }
7758
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
7759
+ _json(res, 201, row);
7760
+ return { id: row.slug };
7761
+ }),
7762
+ async function (req, res) {
7763
+ try {
7764
+ await sidebarWidgets.defineWidget(_sidebarWidgetFromForm(req.body || {}));
7765
+ } catch (e) {
7766
+ if (!(e instanceof TypeError)) throw e;
7767
+ var n = _safeNotice(e, "sidebar_widget.define");
7768
+ var console_ = await _loadSidebarConsole();
7769
+ return _sendHtml(res, n.status, renderAdminSidebarWidgets({
7770
+ shop_name: deps.shop_name, nav_available: navAvailable,
7771
+ widgets: console_.widgets, placements: console_.placements, page_keys: console_.page_keys,
7772
+ notice: n.message.replace(/^sidebarWidgets[.:]\s*/, ""),
7773
+ }));
7774
+ }
7775
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".sidebar_widget.define", outcome: "success" });
7776
+ _redirect(res, "/admin/sidebar-widgets?created=1");
7777
+ },
7778
+ ));
7779
+
7780
+ router.post("/admin/sidebar-widgets/:slug/archive", _pageOrApi(false,
7781
+ W("sidebar_widget.archive", async function (req, res) {
7782
+ var row;
7783
+ try { row = await sidebarWidgets.archiveWidget(req.params.slug); }
7784
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
7785
+ _json(res, 200, row);
7786
+ return { id: row.slug };
7787
+ }),
7788
+ async function (req, res) {
7789
+ var slug = req.params.slug;
7790
+ try { await sidebarWidgets.archiveWidget(slug); }
7791
+ catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/sidebar-widgets?err=1"); }
7792
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".sidebar_widget.archive", outcome: "success", metadata: { slug: slug } });
7793
+ _redirect(res, "/admin/sidebar-widgets?archived=1");
7794
+ },
7795
+ ));
7796
+
7797
+ // Set a page's ordered placement. The form posts a `page_key` + zero or
7798
+ // more `slug` checkbox values; the order is the form's submitted order
7799
+ // (checkbox values arrive in document order). An empty selection clears
7800
+ // the page's sidebar.
7801
+ router.post("/admin/sidebar-widgets/placement", _pageOrApi(false,
7802
+ W("sidebar_widget.place", async function (req, res) {
7803
+ var body = req.body || {};
7804
+ var pageKey = typeof body.page_key === "string" ? body.page_key : "";
7805
+ var slugs = _sidebarPlacementSlugs(body);
7806
+ var out;
7807
+ try { out = await sidebarWidgets.setPagePlacement(pageKey, slugs); }
7808
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
7809
+ _json(res, 200, out);
7810
+ return { id: pageKey };
7811
+ }),
7812
+ async function (req, res) {
7813
+ var body = req.body || {};
7814
+ var pageKey = typeof body.page_key === "string" ? body.page_key : "";
7815
+ var slugs = _sidebarPlacementSlugs(body);
7816
+ try { await sidebarWidgets.setPagePlacement(pageKey, slugs); }
7817
+ catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/sidebar-widgets?err=1"); }
7818
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".sidebar_widget.place", outcome: "success", metadata: { page_key: pageKey, count: slugs.length } });
7819
+ _redirect(res, "/admin/sidebar-widgets?placed=1");
7820
+ },
7821
+ ));
7822
+ }
7823
+
7213
7824
  // ---- search suggestions ---------------------------------------------
7214
7825
  // Operator curation for the header autocomplete dropdown: featured
7215
7826
  // suggestions (typing "free" surfaces "Free shipping over $50") plus a
@@ -13129,6 +13740,10 @@ var DASHBOARD_LAYOUT =
13129
13740
  " <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n" +
13130
13741
  " <meta name=\"robots\" content=\"noindex,nofollow\">\n" +
13131
13742
  " <title>{{page_title}} — {{shop_name}}</title>\n" +
13743
+ " <link rel=\"icon\" type=\"image/svg+xml\" href=\"/assets/brand/favicon.svg\">\n" +
13744
+ " <link rel=\"icon\" type=\"image/png\" href=\"/assets/brand/favicon.png\">\n" +
13745
+ " <link rel=\"apple-touch-icon\" href=\"/assets/brand/favicon.png\">\n" +
13746
+ " <meta name=\"theme-color\" content=\"#08080a\">\n" +
13132
13747
  " RAW_ADMIN_CSS\n" +
13133
13748
  "</head>\n" +
13134
13749
  "<body>\n" +
@@ -13431,6 +14046,7 @@ var ADMIN_NAV_ITEMS = [
13431
14046
  { key: "inventory-transfers", href: "/admin/inventory/transfers", label: "Transfers", requires: "stockTransfers" },
13432
14047
  { key: "inventory-writeoffs", href: "/admin/inventory/writeoffs", label: "Write-offs", requires: "inventoryWriteoffs" },
13433
14048
  { key: "orders", href: "/admin/orders", label: "Orders" },
14049
+ { key: "inbox", href: "/admin/inbox", label: "Inbox", requires: "inbox", badge: true },
13434
14050
  { key: "quotes", href: "/admin/quotes", label: "Quotes", requires: "quotes" },
13435
14051
  { key: "carts", href: "/admin/carts", label: "Abandoned carts", requires: "carts" },
13436
14052
  { key: "reports", href: "/admin/reports", label: "Reports" },
@@ -13467,6 +14083,8 @@ var ADMIN_NAV_ITEMS = [
13467
14083
  { key: "pick-lists", href: "/admin/pick-lists", label: "Pick lists", requires: "pickLists" },
13468
14084
  { key: "announcements", href: "/admin/announcements", label: "Announcements", requires: "announcementBar" },
13469
14085
  { key: "promo-banners", href: "/admin/promo-banners", label: "Promo banners", requires: "promoBanners" },
14086
+ { key: "suggestions", href: "/admin/suggestions", label: "Suggestion box", requires: "suggestionBox" },
14087
+ { key: "sidebar-widgets", href: "/admin/sidebar-widgets", label: "Sidebar widgets", requires: "sidebarWidgets" },
13470
14088
  { key: "campaigns", href: "/admin/campaigns", label: "Email campaigns", requires: "emailCampaigns" },
13471
14089
  { key: "blog", href: "/admin/blog", label: "Blog", requires: "blog" },
13472
14090
  { key: "help", href: "/admin/help", label: "Help center", requires: "knowledgeBase" },
@@ -13488,12 +14106,34 @@ function _adminNav(active, available) {
13488
14106
  var links = ADMIN_NAV_ITEMS.filter(function (it) {
13489
14107
  return !it.requires || !available || available[it.requires];
13490
14108
  }).map(function (it) {
14109
+ // A badge-bearing nav item (the inbox) carries an unread-count
14110
+ // placeholder spliced post-render from the per-request ALS store, so
14111
+ // the count is current on every page without each render call threading
14112
+ // it. A non-badge item renders unchanged.
14113
+ var badge = it.badge ? "{{INBOX_BADGE}}" : "";
13491
14114
  return "<a href=\"" + it.href + "\"" + (it.key === active ? " class=\"active\" aria-current=\"page\"" : "") + ">" +
13492
- _htmlEscape(it.label) + "</a>";
14115
+ _htmlEscape(it.label) + badge + "</a>";
13493
14116
  }).join("");
13494
14117
  return "<nav class=\"admin-nav\"><div class=\"admin-nav__inner\">" + links + "</div></nav>";
13495
14118
  }
13496
14119
 
14120
+ // Replace the inbox nav badge placeholder with the per-request unread
14121
+ // count read from the ALS store. A zero count renders no pill (an empty
14122
+ // badge is noise); a positive count renders a small count pill, capped at
14123
+ // "99+" so the nav layout stays stable. Reached outside a request (no
14124
+ // store) the placeholder collapses to empty.
14125
+ function _injectAdminInboxBadge(html) {
14126
+ if (typeof html !== "string" || html.indexOf("{{INBOX_BADGE}}") === -1) return html;
14127
+ var store = _csrfAls.getStore();
14128
+ var n = (store && Number(store.inbox_unread)) || 0;
14129
+ var pill = "";
14130
+ if (n > 0) {
14131
+ var label = n > 99 ? "99+" : String(n);
14132
+ pill = " <span class=\"nav-badge\" aria-label=\"" + _htmlEscape(label) + " unread\">" + _htmlEscape(label) + "</span>";
14133
+ }
14134
+ return html.split("{{INBOX_BADGE}}").join(pill);
14135
+ }
14136
+
13497
14137
  function _renderAdminShell(shopName, subtitle, bodyHtml, active, available) {
13498
14138
  var html = _renderTemplate(DASHBOARD_LAYOUT, {
13499
14139
  shop_name: shopName || "blamejs.shop",
@@ -13506,10 +14146,11 @@ function _renderAdminShell(shopName, subtitle, bodyHtml, active, available) {
13506
14146
  // Splice the admin body literally so a `$`-bearing fragment can't trip
13507
14147
  // `String.replace`'s dollar substitution. See `_spliceRaw`.
13508
14148
  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.
14149
+ // Stamp the per-request inbox unread count into the nav badge (seeded on
14150
+ // the ALS by mount()'s inbox-badge middleware), then token every admin
14151
+ // POST form with the per-request double-submit CSRF value (also ALS-
14152
+ // seeded). Single funnel — every authenticated admin page assembles here.
14153
+ html = _injectAdminInboxBadge(html);
13513
14154
  return _injectAdminCsrfFields(html);
13514
14155
  }
13515
14156
 
@@ -14771,6 +15412,28 @@ function renderAdminOrder(opts) {
14771
15412
  opts.note_authors || [], opts.note_visibility || []);
14772
15413
  }
14773
15414
 
15415
+ // Partial-refund panel — only when a payment provider is wired AND the
15416
+ // order has a captured intent (`can_refund`) AND a balance remains. The
15417
+ // operator types a decimal amount capped at the remaining balance; the
15418
+ // backend re-validates the cap before any money moves. Refund banners
15419
+ // (`refund_done` / `refund_err`) ride the same PRG pattern as the others.
15420
+ var refundPanel = "";
15421
+ if (opts.can_refund) {
15422
+ refundPanel = _orderRefundPanel(o,
15423
+ Number(opts.refunded_minor) || 0,
15424
+ Number(opts.refundable_minor) || 0,
15425
+ opts.refund_done ? "<div class=\"banner banner--ok\">Refund issued.</div>" : "",
15426
+ opts.refund_err ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.refund_err) + "</div>" : "");
15427
+ }
15428
+
15429
+ // Order timeline panel — only when the timeline primitive is wired. A
15430
+ // single chronological feed across every post-checkout source, escaped
15431
+ // at render (titles / bodies are operator + system + carrier free text).
15432
+ var timelinePanel = "";
15433
+ if (opts.can_timeline) {
15434
+ timelinePanel = _orderTimelinePanel(opts.timeline || []);
15435
+ }
15436
+
14774
15437
  var body =
14775
15438
  "<section class=\"mw-48\">" +
14776
15439
  "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/orders\">&larr; Orders</a></div>" +
@@ -14789,6 +15452,8 @@ function renderAdminOrder(opts) {
14789
15452
  "<div class=\"panel mt\"><h3 class=\"subhead\">Actions</h3>" +
14790
15453
  "<div class=\"order-actions\">" + actions + "</div>" +
14791
15454
  "</div>" +
15455
+ refundPanel +
15456
+ timelinePanel +
14792
15457
  documentsPanel +
14793
15458
  resendPanel +
14794
15459
  notesPanel +
@@ -14870,6 +15535,147 @@ function _orderNotesPanel(orderId, notes, doneBanner, errBanner, authors, visibi
14870
15535
  "</div>";
14871
15536
  }
14872
15537
 
15538
+ // Refund panel for the order detail. States the order total, how much is
15539
+ // already refunded, and how much remains, then offers a partial-refund
15540
+ // form (a decimal amount capped at the remaining balance) that POSTs to
15541
+ // the `/refund/partial` route. The amount input's max + step are derived
15542
+ // from the currency exponent (display hints only — the backend re-parses
15543
+ // via b.money and enforces the cap), and the form is omitted once the
15544
+ // order is fully refunded. The full-refund button lives in the Actions
15545
+ // panel (a legal FSM transition); this panel owns the partial path.
15546
+ function _orderRefundPanel(o, refundedMinor, refundableMinor, doneBanner, errBanner) {
15547
+ var currency = o.currency;
15548
+ var totalFmt = pricing.format(Number(o.grand_total_minor) || 0, currency);
15549
+ var refundedFmt = pricing.format(refundedMinor, currency);
15550
+ var refundableFmt = pricing.format(refundableMinor, currency);
15551
+ var summary =
15552
+ "<p class=\"refund-summary\">" +
15553
+ "Order total " + _htmlEscape(totalFmt) +
15554
+ " · refunded " + _htmlEscape(refundedFmt) +
15555
+ " · refundable " + _htmlEscape(refundableFmt) +
15556
+ "</p>";
15557
+ var form;
15558
+ if (refundableMinor > 0) {
15559
+ // The currency exponent sets the input's decimal granularity: a 2-dp
15560
+ // currency steps in 0.01, a 0-dp currency (JPY) steps in 1. The max is
15561
+ // the remaining balance as a decimal string built from the same money
15562
+ // primitive so the form hint and the server cap agree.
15563
+ var exp = 2;
15564
+ try { exp = b.money.CURRENCIES[currency]; if (typeof exp !== "number") exp = 2; } catch (_e) { exp = 2; }
15565
+ var step = exp <= 0 ? "1" : "0." + new Array(exp).join("0") + "1";
15566
+ var maxDecimal;
15567
+ try { maxDecimal = b.money.fromMinorUnits(BigInt(refundableMinor), currency).toString().replace(/\s+[A-Z]{3}$/, ""); }
15568
+ catch (_me) { maxDecimal = String(refundableMinor); }
15569
+ form =
15570
+ "<form method=\"post\" action=\"/admin/orders/" + _htmlEscape(o.id) + "/refund/partial\" class=\"return-action\">" +
15571
+ "<label class=\"form-field\"><span>Refund amount (" + _htmlEscape(currency) + ")</span>" +
15572
+ "<input type=\"number\" name=\"amount\" inputmode=\"decimal\" min=\"" + _htmlEscape(step) + "\" max=\"" + _htmlEscape(maxDecimal) + "\" step=\"" + _htmlEscape(step) + "\" placeholder=\"" + _htmlEscape(maxDecimal) + "\" required>" +
15573
+ "<small>Up to " + _htmlEscape(refundableFmt) + " remaining. The amount is charged back through the payment provider.</small>" +
15574
+ "</label>" +
15575
+ "<button class=\"btn btn--danger\" type=\"submit\">Issue refund</button>" +
15576
+ "</form>";
15577
+ } else {
15578
+ form = "<p class=\"empty\">This order is fully refunded — nothing remains to refund.</p>";
15579
+ }
15580
+ return "<div class=\"panel mt\"><h3 class=\"subhead\">Refunds</h3>" +
15581
+ doneBanner + errBanner + summary + form +
15582
+ "</div>";
15583
+ }
15584
+
15585
+ // Order timeline panel for the order detail. Renders the aggregated
15586
+ // operator-facing event feed (newest-first) as a vertical timeline. Every
15587
+ // interpolated value (title / body / actor) is operator-, system-, or
15588
+ // carrier-supplied free text, so each is escaped at render. A link, when
15589
+ // present, is a carrier tracking URL — rendered with rel="noopener
15590
+ // nofollow" + target="_blank" like the tracking panel's links.
15591
+ function _orderTimelinePanel(events) {
15592
+ var items = (events || []).map(function (e) {
15593
+ var when = "<span class=\"tl-when\">" + _htmlEscape(_fmtDate(e.occurred_at)) + "</span>";
15594
+ var title = "<span class=\"tl-title\">" + _htmlEscape(String(e.title == null ? "" : e.title)) + "</span>";
15595
+ var bodyLine = e.body
15596
+ ? "<div class=\"tl-body\">" + _htmlEscape(String(e.body)) + "</div>"
15597
+ : "";
15598
+ var actorLine = e.actor
15599
+ ? "<div class=\"tl-actor\">" + _htmlEscape(String(e.actor)) + "</div>"
15600
+ : "";
15601
+ var linkLine = e.link
15602
+ ? "<div><a href=\"" + _htmlEscape(String(e.link)) + "\" rel=\"noopener nofollow\" target=\"_blank\">Track ↗</a></div>"
15603
+ : "";
15604
+ return "<li>" +
15605
+ "<div>" + when + " · " + title + "</div>" +
15606
+ bodyLine + actorLine + linkLine +
15607
+ "</li>";
15608
+ }).join("");
15609
+ return "<div class=\"panel mt\"><h3 class=\"subhead\">Timeline</h3>" +
15610
+ (items
15611
+ ? "<ul class=\"timeline\">" + items + "</ul>"
15612
+ : "<p class=\"empty\">No recorded events for this order yet.</p>") +
15613
+ "</div>";
15614
+ }
15615
+
15616
+ // Operator inbox screen. Lists the role's notifications newest-first with
15617
+ // each message's severity pill, subject, body, and timestamp, plus the
15618
+ // per-message actions (mark read on an unread one, archive on any active
15619
+ // one). Subject + body are application-supplied (a new-order ping today,
15620
+ // other system events tomorrow), so both are escaped at render. A toggle
15621
+ // link switches between the active feed and the archive. Every form
15622
+ // renders through _renderAdminShell, which tokens each POST.
15623
+ function _renderAdminInbox(opts) {
15624
+ opts = opts || {};
15625
+ var messages = opts.messages || [];
15626
+ var includeArchived = !!opts.include_archived;
15627
+ var done = opts.done ? "<div class=\"banner banner--ok\">Inbox updated.</div>" : "";
15628
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
15629
+
15630
+ var items = messages.map(function (m) {
15631
+ var enc = _htmlEscape(encodeURIComponent(m.id));
15632
+ var isUnread = m.read_at == null;
15633
+ var isArchived = m.archived_at != null;
15634
+ var sev = String(m.severity || "info");
15635
+ // Map severity to the existing status-pill colour classes so the feed
15636
+ // reads at a glance without new colour tokens.
15637
+ var sevClass = sev === "critical" || sev === "urgent" ? "cancelled"
15638
+ : (sev === "warning" ? "pending" : "paid");
15639
+ var pills =
15640
+ "<span class=\"status-pill " + _htmlEscape(sevClass) + "\">" + _htmlEscape(sev) + "</span>" +
15641
+ (isUnread ? "" : "<span class=\"status-pill\">Read</span>") +
15642
+ (isArchived ? "<span class=\"status-pill refunded\">Archived</span>" : "");
15643
+ var actions = "";
15644
+ if (!isArchived) {
15645
+ if (isUnread) {
15646
+ actions += "<form method=\"post\" action=\"/admin/inbox/" + enc + "/read\" class=\"form-inline\">" +
15647
+ "<button class=\"btn btn--ghost\" type=\"submit\">Mark read</button></form>";
15648
+ }
15649
+ actions += "<form method=\"post\" action=\"/admin/inbox/" + enc + "/archive\" class=\"form-inline\">" +
15650
+ "<button class=\"btn btn--ghost\" type=\"submit\">Archive</button></form>";
15651
+ }
15652
+ return "<li class=\"" + (isUnread ? "unread" : "") + "\">" +
15653
+ "<div class=\"inbox-meta\">" + _htmlEscape(_fmtDate(m.created_at)) + " " + pills + "</div>" +
15654
+ "<div class=\"inbox-subject\">" + _htmlEscape(String(m.subject == null ? "" : m.subject)) + "</div>" +
15655
+ "<div class=\"tl-body\">" + _htmlEscape(String(m.body == null ? "" : m.body)) + "</div>" +
15656
+ (actions ? "<div class=\"actions-row\">" + actions + "</div>" : "") +
15657
+ "</li>";
15658
+ }).join("");
15659
+
15660
+ var toggle = includeArchived
15661
+ ? "<a class=\"btn btn--ghost\" href=\"/admin/inbox\">Show active only</a>"
15662
+ : "<a class=\"btn btn--ghost\" href=\"/admin/inbox?archived=1\">Include archived</a>";
15663
+
15664
+ var body =
15665
+ "<section class=\"mw-48\">" +
15666
+ "<h2>Inbox</h2>" +
15667
+ "<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>" +
15668
+ done + notice +
15669
+ "<div class=\"actions-row\">" + toggle + "</div>" +
15670
+ "<div class=\"panel mt\">" +
15671
+ (messages.length
15672
+ ? "<ul class=\"inbox-list\">" + items + "</ul>"
15673
+ : "<p class=\"empty\">" + (includeArchived ? "No messages." : "No new messages.") + "</p>") +
15674
+ "</div>" +
15675
+ "</section>";
15676
+ return _renderAdminShell(opts.shop_name, "Inbox", body, "inbox", opts.nav_available);
15677
+ }
15678
+
14873
15679
  // Per-shipment carrier-label sub-panel for the order detail. Lists the
14874
15680
  // recorded labels (tracking number, broker, cost, status pill) with a
14875
15681
  // "Mark used" action on purchased ones, then a form to record a freshly-
@@ -15197,6 +16003,8 @@ function renderPickListPrint(opts) {
15197
16003
  "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">" +
15198
16004
  "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">" +
15199
16005
  "<meta name=\"robots\" content=\"noindex, nofollow\">" +
16006
+ "<link rel=\"icon\" type=\"image/svg+xml\" href=\"/assets/brand/favicon.svg\">" +
16007
+ "<link rel=\"icon\" type=\"image/png\" href=\"/assets/brand/favicon.png\">" +
15200
16008
  "<title>Pick list " + _htmlEscape(l.id.slice(0, 8)) + "</title>" + style + "</head><body>" +
15201
16009
  "<h1>" + _htmlEscape(shopName) + " — pick list</h1>" +
15202
16010
  "<p class=\"meta\">List " + _htmlEscape(l.id.slice(0, 8)) + " · location " + _htmlEscape(String(l.location_code)) +
@@ -18805,6 +19613,120 @@ function _announcementPatchFromForm(body) {
18805
19613
  return patch;
18806
19614
  }
18807
19615
 
19616
+ // ---- suggestion-box form coercion -----------------------------------
19617
+
19618
+ // The responder string for a respondToSuggestion call. The signed-in
19619
+ // operator's id (stamped onto req by the role gate on the browser path, or
19620
+ // resolvable from the bearer) is the audit attribution; a console label is
19621
+ // the fallback so the responder field (which the primitive requires non-
19622
+ // empty) always lands a value. A hidden form `responder` is ignored — the
19623
+ // attribution comes from the authenticated actor, not the request body.
19624
+ function _suggestionResponder(req, _body) {
19625
+ if (req && req.operatorActor && req.operatorActor.operator_id) {
19626
+ return String(req.operatorActor.operator_id).slice(0, 200);
19627
+ }
19628
+ return "operator";
19629
+ }
19630
+
19631
+ // ---- sidebar-widget form coercion -----------------------------------
19632
+
19633
+ // Coerce the create form into the shape sidebarWidgets.defineWidget expects.
19634
+ // The kind determines the payload shape, so the coercion reads only the
19635
+ // fields that kind owns (the primitive refuses unknown payload keys). Every
19636
+ // numeric payload field is parsed to an integer; blank optional fields drop
19637
+ // out so the primitive applies its own validation. The schedule window
19638
+ // (starts_at / expires_at) is required by the primitive — a blank bound
19639
+ // defaults to "now" / "now + ~10 years" so an operator who just wants an
19640
+ // always-on widget doesn't have to fill the dates.
19641
+ function _sidebarWidgetPayloadFromForm(kind, body) {
19642
+ function _int(v) {
19643
+ if (v == null || String(v).trim() === "") return null;
19644
+ var n = parseInt(String(v), 10);
19645
+ return Number.isFinite(n) ? n : null;
19646
+ }
19647
+ if (kind === "newsletter_signup") {
19648
+ return { list_id: body.list_id, headline: body.headline, cta_label: body.cta_label };
19649
+ }
19650
+ if (kind === "recently_viewed") {
19651
+ var rl = _int(body.limit);
19652
+ return rl == null ? {} : { limit: rl };
19653
+ }
19654
+ if (kind === "trust_badges") {
19655
+ var badges = typeof body.badges === "string"
19656
+ ? body.badges.split(",").map(function (s) { return s.trim(); }).filter(function (s) { return s.length; })
19657
+ : [];
19658
+ return { badges: badges };
19659
+ }
19660
+ if (kind === "featured_collection") {
19661
+ var out = { collection_slug: body.collection_slug };
19662
+ var fl = _int(body.limit); if (fl != null) out.limit = fl;
19663
+ return out;
19664
+ }
19665
+ if (kind === "social_proof") {
19666
+ return { headline: body.headline, message_template: body.message_template };
19667
+ }
19668
+ if (kind === "size_chart") {
19669
+ return { chart_slug: body.chart_slug };
19670
+ }
19671
+ if (kind === "live_visitors") {
19672
+ var lv = {};
19673
+ var win = _int(body.window_minutes); if (win != null) lv.window_minutes = win;
19674
+ var thr = _int(body.min_threshold); if (thr != null) lv.min_threshold = thr;
19675
+ return lv;
19676
+ }
19677
+ if (kind === "countdown_timer") {
19678
+ var ct = {};
19679
+ var target = _epochFromForm(body.target_at);
19680
+ if (target != null) ct.target_at = target;
19681
+ ct.completed_label = body.completed_label;
19682
+ return ct;
19683
+ }
19684
+ // sticky_addtocart
19685
+ return { variant_slug: body.variant_slug };
19686
+ }
19687
+
19688
+ function _sidebarWidgetFromForm(body) {
19689
+ body = body || {};
19690
+ var kind = typeof body.kind === "string" ? body.kind : "";
19691
+ var out = {
19692
+ slug: typeof body.slug === "string" ? body.slug.trim() : body.slug,
19693
+ title: body.title,
19694
+ kind: kind,
19695
+ payload: _sidebarWidgetPayloadFromForm(kind, body),
19696
+ audience: typeof body.audience === "string" && body.audience ? body.audience : "all",
19697
+ };
19698
+ var pr = body.priority;
19699
+ if (pr != null && String(pr).trim() !== "") {
19700
+ var pn = parseInt(String(pr), 10);
19701
+ if (Number.isFinite(pn)) out.priority = pn;
19702
+ }
19703
+ // The schedule window is mandatory at the primitive layer. A blank start
19704
+ // means "live now"; a blank expiry means "no end" — represented as a
19705
+ // far-future bound so the always-on case needs no operator dates.
19706
+ var sa = _epochFromForm(body.starts_at);
19707
+ out.starts_at = sa != null ? sa : Date.now();
19708
+ var ea = _epochFromForm(body.expires_at);
19709
+ out.expires_at = ea != null ? ea : (out.starts_at + 315360000000); // +10y
19710
+ return out;
19711
+ }
19712
+
19713
+ // Read the ordered placement slugs from the placement form. A multi-value
19714
+ // `slug` field arrives as an array (multiple checked boxes) or a single
19715
+ // string (one box) or undefined (none) depending on the body parser; all
19716
+ // three normalise to an array. Each value is trimmed; blanks drop out. The
19717
+ // document order is the placement order.
19718
+ function _sidebarPlacementSlugs(body) {
19719
+ body = body || {};
19720
+ var raw = body.slug;
19721
+ var arr = Array.isArray(raw) ? raw : (raw == null ? [] : [raw]);
19722
+ var out = [];
19723
+ for (var i = 0; i < arr.length; i += 1) {
19724
+ var s = typeof arr[i] === "string" ? arr[i].trim() : "";
19725
+ if (s.length) out.push(s);
19726
+ }
19727
+ return out;
19728
+ }
19729
+
18808
19730
  // ---- search-suggestion form coercion --------------------------------
18809
19731
 
18810
19732
  // Read every featured suggestion for the curation table — through the
@@ -19118,6 +20040,297 @@ function renderAdminAnnouncements(opts) {
19118
20040
  return _renderAdminShell(opts.shop_name, "Announcements", bodyHtml, "announcements", opts.nav_available);
19119
20041
  }
19120
20042
 
20043
+ // ---- suggestion-box console -----------------------------------------
20044
+
20045
+ var _ADMIN_SUGGESTION_STATUS_LABEL = {
20046
+ open: "Open",
20047
+ under_consideration: "Under review",
20048
+ planned: "Planned",
20049
+ shipped: "Shipped",
20050
+ declined: "Not planned",
20051
+ duplicate: "Merged",
20052
+ };
20053
+ var _ADMIN_SUGGESTION_CATEGORY_LABEL = {
20054
+ product_idea: "Product idea",
20055
+ feature_request: "Feature request",
20056
+ improvement: "Improvement",
20057
+ complaint: "Issue",
20058
+ general: "General",
20059
+ };
20060
+
20061
+ // The suggestion triage list — a filterable table of customer-submitted
20062
+ // ideas with their net vote score + status, each linking to the detail
20063
+ // screen where the operator responds / archives / flags. Every echoed
20064
+ // customer free-text value (title) is _htmlEscape'd at the sink.
20065
+ function renderAdminSuggestions(opts) {
20066
+ opts = opts || {};
20067
+ var rows = opts.suggestions || [];
20068
+ var responded = opts.responded ? "<div class=\"banner banner--ok\">Response saved.</div>" : "";
20069
+ var archived = opts.archived ? "<div class=\"banner banner--ok\">Suggestion archived.</div>" : "";
20070
+ var flagged = opts.flagged ? "<div class=\"banner banner--ok\">Spam flag updated.</div>" : "";
20071
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
20072
+
20073
+ var sf = opts.status_filter;
20074
+ var cf = opts.category_filter;
20075
+ var statusChips = ["open", "under_consideration", "planned", "shipped", "declined"].map(function (s) {
20076
+ var on = (sf === s) ? " chip--on" : "";
20077
+ return "<a class=\"chip" + on + "\" href=\"/admin/suggestions?status=" + s + "\">" + _htmlEscape(_ADMIN_SUGGESTION_STATUS_LABEL[s]) + "</a>";
20078
+ }).join("");
20079
+ var chips = "<div class=\"order-filters\">" +
20080
+ "<a class=\"chip" + (sf == null && cf == null ? " chip--on" : "") + "\" href=\"/admin/suggestions\">All</a>" +
20081
+ statusChips +
20082
+ "</div>";
20083
+
20084
+ var bodyRows = rows.map(function (r) {
20085
+ var enc = _htmlEscape(encodeURIComponent(r.id));
20086
+ var statusKey = _ADMIN_SUGGESTION_STATUS_LABEL[r.status] ? r.status : "open";
20087
+ var catLbl = _ADMIN_SUGGESTION_CATEGORY_LABEL[r.category] || "General";
20088
+ return "<tr>" +
20089
+ "<td>" + _htmlEscape(r.title) + "</td>" +
20090
+ "<td>" + _htmlEscape(catLbl) + "</td>" +
20091
+ "<td><span class=\"status-pill\">" + _htmlEscape(_ADMIN_SUGGESTION_STATUS_LABEL[statusKey]) + "</span></td>" +
20092
+ "<td class=\"num\">" + _htmlEscape(String(Number(r.vote_count) || 0)) + "</td>" +
20093
+ "<td><div class=\"actions-row\">" +
20094
+ "<a class=\"btn btn--ghost\" href=\"/admin/suggestions/" + enc + "\">Review</a>" +
20095
+ "</div></td>" +
20096
+ "</tr>";
20097
+ }).join("");
20098
+
20099
+ var table = rows.length
20100
+ ? "<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>"
20101
+ : "<p class=\"empty\">No suggestions" + (sf ? " in this status" : " yet") + ".</p>";
20102
+
20103
+ var more = "";
20104
+ if (opts.next_cursor) {
20105
+ var moreHref = "/admin/suggestions?" +
20106
+ (sf ? "status=" + _htmlEscape(encodeURIComponent(sf)) + "&" : "") +
20107
+ (cf ? "category=" + _htmlEscape(encodeURIComponent(cf)) + "&" : "") +
20108
+ "cursor=" + _htmlEscape(encodeURIComponent(opts.next_cursor));
20109
+ more = "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"" + moreHref + "\">Load more</a></div>";
20110
+ }
20111
+
20112
+ var bodyHtml = "<section><h2>Suggestion box</h2>" +
20113
+ "<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>" +
20114
+ responded + archived + flagged + notice + chips + table + more + "</section>";
20115
+ return _renderAdminShell(opts.shop_name, "Suggestion box", bodyHtml, "suggestions", opts.nav_available);
20116
+ }
20117
+
20118
+ // One suggestion's detail + the operator respond / archive / flag controls.
20119
+ // The respond form drives the roadmap FSM; the response textarea is the
20120
+ // public-visible reply (optional — a blank reply is a status-only move).
20121
+ function renderAdminSuggestion(opts) {
20122
+ opts = opts || {};
20123
+ var s = opts.suggestion;
20124
+ if (!s) {
20125
+ var nf = "<section><h2>Suggestion</h2><p class=\"empty\">Suggestion not found.</p>" +
20126
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/suggestions\">Back to suggestions</a></div></section>";
20127
+ return _renderAdminShell(opts.shop_name, "Suggestion", nf, "suggestions", opts.nav_available);
20128
+ }
20129
+ var responded = opts.responded ? "<div class=\"banner banner--ok\">Response saved.</div>" : "";
20130
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
20131
+ var enc = _htmlEscape(encodeURIComponent(s.id));
20132
+ var isArchived = s.archived_at != null;
20133
+ var isTerminal = ["shipped", "declined", "duplicate"].indexOf(s.status) !== -1;
20134
+ var statusKey = _ADMIN_SUGGESTION_STATUS_LABEL[s.status] ? s.status : "open";
20135
+ var catLbl = _ADMIN_SUGGESTION_CATEGORY_LABEL[s.category] || "General";
20136
+
20137
+ // The destination statuses an operator can move to from the current
20138
+ // status. duplicate is excluded (it requires a canonical id — operators
20139
+ // merge via the API). open is the entry state and not a response target.
20140
+ var allowed = {
20141
+ open: ["under_consideration", "planned", "shipped", "declined"],
20142
+ under_consideration: ["planned", "shipped", "declined"],
20143
+ planned: ["shipped", "declined"],
20144
+ };
20145
+ var nextStatuses = allowed[s.status] || [];
20146
+ var statusOpts = nextStatuses.map(function (st) {
20147
+ return "<option value=\"" + st + "\">" + _htmlEscape(_ADMIN_SUGGESTION_STATUS_LABEL[st]) + "</option>";
20148
+ }).join("");
20149
+
20150
+ var existingResponse = (s.response_text && String(s.response_text).trim().length)
20151
+ ? "<div class=\"panel\"><h3 class=\"subhead\">Current response</h3><p>" + _htmlEscape(s.response_text) + "</p>" +
20152
+ (s.response_by ? "<p class=\"meta\">By " + _htmlEscape(s.response_by) + "</p>" : "") + "</div>"
20153
+ : "";
20154
+
20155
+ var respondForm = (isArchived || isTerminal || !nextStatuses.length)
20156
+ ? "<p class=\"empty\">" + (isArchived ? "This suggestion is archived." : "This suggestion is in a terminal status and can't be moved further.") + "</p>"
20157
+ : "<div class=\"panel mw-40\"><h3 class=\"subhead\">Respond</h3>" +
20158
+ "<form method=\"post\" action=\"/admin/suggestions/" + enc + "/respond\">" +
20159
+ "<label class=\"form-field\"><span>Move to</span><select name=\"status\">" + statusOpts + "</select></label>" +
20160
+ "<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>" +
20161
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Save response</button></div>" +
20162
+ "</form>" +
20163
+ "</div>";
20164
+
20165
+ // Archive + spam-flag controls. The flag toggle posts the desired next
20166
+ // state (flag → 1, un-flag → 0).
20167
+ var spamFlagged = !!s.spam_flagged;
20168
+ var manageRow = isArchived
20169
+ ? ""
20170
+ : "<div class=\"actions-row\">" +
20171
+ "<form method=\"post\" action=\"/admin/suggestions/" + enc + "/flag\" class=\"form-inline\">" +
20172
+ "<input type=\"hidden\" name=\"flagged\" value=\"" + (spamFlagged ? "0" : "1") + "\">" +
20173
+ "<button class=\"btn btn--ghost\" type=\"submit\">" + (spamFlagged ? "Unflag spam" : "Flag as spam") + "</button>" +
20174
+ "</form>" +
20175
+ "<form method=\"post\" action=\"/admin/suggestions/" + enc + "/archive\" class=\"form-inline\">" +
20176
+ "<button class=\"btn btn--danger\" type=\"submit\">Archive</button>" +
20177
+ "</form>" +
20178
+ "</div>";
20179
+
20180
+ var bodyHtml = "<section><h2>Suggestion</h2>" + responded + notice +
20181
+ "<div class=\"panel\">" +
20182
+ "<h3 class=\"subhead\">" + _htmlEscape(s.title) + "</h3>" +
20183
+ "<dl class=\"detail-grid\">" +
20184
+ "<div><dt>Category</dt><dd>" + _htmlEscape(catLbl) + "</dd></div>" +
20185
+ "<div><dt>Status</dt><dd><span class=\"status-pill\">" + _htmlEscape(_ADMIN_SUGGESTION_STATUS_LABEL[statusKey]) + "</span></dd></div>" +
20186
+ "<div><dt>Votes</dt><dd>" + _htmlEscape(String(Number(s.vote_count) || 0)) + "</dd></div>" +
20187
+ "<div><dt>Spam</dt><dd>" + (spamFlagged ? "Flagged" : "No") + "</dd></div>" +
20188
+ "</dl>" +
20189
+ "<p>" + _htmlEscape(s.body) + "</p>" +
20190
+ "</div>" +
20191
+ existingResponse +
20192
+ respondForm +
20193
+ manageRow +
20194
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/suggestions\">Back to suggestions</a></div>" +
20195
+ "</section>";
20196
+ return _renderAdminShell(opts.shop_name, "Suggestion", bodyHtml, "suggestions", opts.nav_available);
20197
+ }
20198
+
20199
+ // ---- sidebar-widget console -----------------------------------------
20200
+
20201
+ var _SIDEBAR_KIND_LABEL = {
20202
+ newsletter_signup: "Newsletter signup",
20203
+ recently_viewed: "Recently viewed",
20204
+ trust_badges: "Trust badges",
20205
+ featured_collection: "Featured collection",
20206
+ social_proof: "Social proof",
20207
+ size_chart: "Size chart",
20208
+ live_visitors: "Live visitors",
20209
+ countdown_timer: "Countdown timer",
20210
+ sticky_addtocart: "Sticky add-to-cart",
20211
+ };
20212
+
20213
+ // The per-kind payload field set for the create form. Each entry is the HTML
20214
+ // for the kind's payload inputs, shown/hidden by the kind <select> via a
20215
+ // small island; with no JS every field is visible and the operator fills the
20216
+ // ones their chosen kind needs (the primitive refuses payload keys that don't
20217
+ // belong to the kind, so a wrong-kind field is a clean validation error).
20218
+ function _sidebarPayloadFields() {
20219
+ return "<fieldset class=\"panel\"><legend>Content</legend>" +
20220
+ "<p class=\"meta\">Fill the fields for the widget kind you chose above.</p>" +
20221
+ // newsletter_signup
20222
+ _setupField("List id (newsletter)", "list_id", "", "text", "newsletter_signup", " maxlength=\"120\"") +
20223
+ _setupField("Headline", "headline", "", "text", "newsletter_signup / social_proof", " maxlength=\"200\"") +
20224
+ _setupField("CTA label", "cta_label", "", "text", "newsletter_signup", " maxlength=\"80\"") +
20225
+ // social_proof
20226
+ "<label class=\"form-field\"><span>Message</span><textarea name=\"message_template\" maxlength=\"500\" rows=\"2\"></textarea><small>social_proof</small></label>" +
20227
+ // trust_badges
20228
+ _setupField("Badge slugs (comma-separated)", "badges", "", "text", "trust_badges", " maxlength=\"500\"") +
20229
+ // featured_collection
20230
+ _setupField("Collection slug", "collection_slug", "", "text", "featured_collection", " maxlength=\"120\"") +
20231
+ // size_chart
20232
+ _setupField("Chart slug", "chart_slug", "", "text", "size_chart", " maxlength=\"120\"") +
20233
+ // sticky_addtocart
20234
+ _setupField("Variant slug", "variant_slug", "", "text", "sticky_addtocart", " maxlength=\"120\"") +
20235
+ // recently_viewed / featured_collection limit + live_visitors + countdown
20236
+ _setupField("Limit", "limit", "", "number", "recently_viewed / featured_collection", " min=\"1\" max=\"24\"") +
20237
+ _setupField("Window minutes", "window_minutes", "", "number", "live_visitors", " min=\"1\" max=\"240\"") +
20238
+ _setupField("Min threshold", "min_threshold", "", "number", "live_visitors", " min=\"0\"") +
20239
+ "<label class=\"form-field\"><span>Countdown target</span><input type=\"datetime-local\" name=\"target_at\"><small>countdown_timer</small></label>" +
20240
+ _setupField("Completed label", "completed_label", "", "text", "countdown_timer", " maxlength=\"200\"") +
20241
+ "</fieldset>";
20242
+ }
20243
+
20244
+ // The widget definitions table + the create form + the per-page placement
20245
+ // editor. Every echoed operator free-text value (title, slug) is
20246
+ // _htmlEscape'd at the sink.
20247
+ function renderAdminSidebarWidgets(opts) {
20248
+ opts = opts || {};
20249
+ var widgets = opts.widgets || [];
20250
+ var placements = opts.placements || {};
20251
+ var pageKeys = opts.page_keys || [];
20252
+ var created = opts.created ? "<div class=\"banner banner--ok\">Widget saved.</div>" : "";
20253
+ var updated = opts.updated ? "<div class=\"banner banner--ok\">Widget updated.</div>" : "";
20254
+ var archived = opts.archived ? "<div class=\"banner banner--ok\">Widget archived.</div>" : "";
20255
+ var placed = opts.placed ? "<div class=\"banner banner--ok\">Page placement saved.</div>" : "";
20256
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
20257
+
20258
+ var widgetRows = widgets.map(function (w) {
20259
+ var enc = _htmlEscape(encodeURIComponent(w.slug));
20260
+ var isArchived = w.archived_at != null;
20261
+ return "<tr>" +
20262
+ "<td><code class=\"order-id\">" + _htmlEscape(w.slug) + "</code></td>" +
20263
+ "<td>" + _htmlEscape(w.title) + "</td>" +
20264
+ "<td>" + _htmlEscape(_SIDEBAR_KIND_LABEL[w.kind] || w.kind) + "</td>" +
20265
+ "<td>" + _htmlEscape(w.audience) + "</td>" +
20266
+ "<td><span class=\"status-pill " + (isArchived ? "cancelled" : "paid") + "\">" + (isArchived ? "archived" : "active") + "</span></td>" +
20267
+ "<td><div class=\"actions-row\">" +
20268
+ (isArchived ? "" :
20269
+ "<form method=\"post\" action=\"/admin/sidebar-widgets/" + enc + "/archive\" class=\"form-inline\">" +
20270
+ "<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form>") +
20271
+ "</div></td>" +
20272
+ "</tr>";
20273
+ }).join("");
20274
+
20275
+ var table = widgets.length
20276
+ ? "<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>"
20277
+ : "<p class=\"empty\">No sidebar widgets yet.</p>";
20278
+
20279
+ var kindOpts = Object.keys(_SIDEBAR_KIND_LABEL).map(function (k) {
20280
+ return "<option value=\"" + k + "\">" + _htmlEscape(_SIDEBAR_KIND_LABEL[k]) + "</option>";
20281
+ }).join("");
20282
+ var audienceOpts = ["all", "guest", "logged_in"].map(function (au) {
20283
+ return "<option value=\"" + au + "\">" + au + "</option>";
20284
+ }).join("");
20285
+
20286
+ var createForm =
20287
+ "<div class=\"panel mt mw-40\">" +
20288
+ "<h3 class=\"subhead\">Define a widget</h3>" +
20289
+ "<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>" +
20290
+ "<form method=\"post\" action=\"/admin/sidebar-widgets\">" +
20291
+ _setupField("Slug", "slug", "", "text", "Lowercase, hyphenated — a stable id.", " maxlength=\"80\" required") +
20292
+ _setupField("Title", "title", "", "text", "Shown as the widget heading.", " maxlength=\"200\" required") +
20293
+ "<label class=\"form-field\"><span>Kind</span><select name=\"kind\">" + kindOpts + "</select></label>" +
20294
+ "<label class=\"form-field\"><span>Audience</span><select name=\"audience\">" + audienceOpts + "</select></label>" +
20295
+ _setupField("Priority", "priority", "", "number", "Higher shows first in a list.", " min=\"0\"") +
20296
+ "<label class=\"form-field\"><span>Starts at (optional)</span><input type=\"datetime-local\" name=\"starts_at\"></label>" +
20297
+ "<label class=\"form-field\"><span>Expires at (optional)</span><input type=\"datetime-local\" name=\"expires_at\"></label>" +
20298
+ _sidebarPayloadFields() +
20299
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Define widget</button></div>" +
20300
+ "</form>" +
20301
+ "</div>";
20302
+
20303
+ // Per-page placement editor. For each page, a form of checkboxes (one per
20304
+ // active widget, pre-checked when already placed) posts the ordered slug
20305
+ // set. Archived widgets are omitted (they can't be placed).
20306
+ var activeWidgets = widgets.filter(function (w) { return w.archived_at == null; });
20307
+ var placementForms = pageKeys.map(function (key) {
20308
+ var placedSlugs = placements[key] || [];
20309
+ var boxes = activeWidgets.map(function (w) {
20310
+ var checked = placedSlugs.indexOf(w.slug) !== -1 ? " checked" : "";
20311
+ return "<label class=\"kv\"><input type=\"checkbox\" name=\"slug\" value=\"" + _htmlEscape(w.slug) + "\"" + checked + "> " +
20312
+ _htmlEscape(w.title) + " <span class=\"meta\">(" + _htmlEscape(_SIDEBAR_KIND_LABEL[w.kind] || w.kind) + ")</span></label>";
20313
+ }).join("");
20314
+ var inner = activeWidgets.length
20315
+ ? boxes + "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Save " + _htmlEscape(key) + " sidebar</button></div>"
20316
+ : "<p class=\"empty\">Define a widget first.</p>";
20317
+ return "<div class=\"panel\"><h3 class=\"subhead\">" + _htmlEscape(key) + " page</h3>" +
20318
+ "<form method=\"post\" action=\"/admin/sidebar-widgets/placement\">" +
20319
+ "<input type=\"hidden\" name=\"page_key\" value=\"" + _htmlEscape(key) + "\">" +
20320
+ inner +
20321
+ "</form>" +
20322
+ "</div>";
20323
+ }).join("");
20324
+ var placementSection = "<section class=\"mt\"><h3 class=\"subhead\">Page placement</h3>" +
20325
+ "<p class=\"meta\">Choose which widgets render in each page's right rail, in checkbox order.</p>" +
20326
+ placementForms + "</section>";
20327
+
20328
+ var bodyHtml = "<section><h2>Sidebar widgets</h2>" +
20329
+ created + updated + archived + placed + notice +
20330
+ table + createForm + placementSection + "</section>";
20331
+ return _renderAdminShell(opts.shop_name, "Sidebar widgets", bodyHtml, "sidebar-widgets", opts.nav_available);
20332
+ }
20333
+
19121
20334
  // Search-suggestions curation screen: the featured-suggestion table (with
19122
20335
  // a per-row inline priority/status edit + a delete), a create form, and a
19123
20336
  // read-only "Popular searches" view over the recent query log. Every