@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/CHANGELOG.md +4 -0
- package/README.md +6 -1
- package/SECURITY.md +13 -0
- package/lib/admin.js +1328 -49
- package/lib/asset-manifest.json +5 -5
- package/lib/compliance-export.js +61 -7
- package/lib/customers.js +53 -0
- package/lib/cycle-counting.js +24 -4
- package/lib/gift-card-ledger.js +81 -10
- package/lib/giftcards.js +88 -0
- package/lib/inventory-allocations.js +33 -14
- package/lib/inventory-receive.js +116 -20
- package/lib/inventory-writeoffs.js +53 -64
- package/lib/loyalty-earn-rules.js +117 -0
- package/lib/loyalty.js +79 -0
- package/lib/newsletter.js +39 -2
- package/lib/operator-audit-log.js +20 -0
- package/lib/operator-inbox.js +202 -9
- package/lib/order.js +227 -27
- package/lib/payment.js +91 -18
- package/lib/quotes.js +107 -15
- package/lib/referrals.js +71 -0
- package/lib/security-middleware.js +33 -1
- package/lib/stock-transfers.js +185 -53
- package/lib/storefront.js +979 -126
- package/lib/translations.js +1 -0
- package/lib/webhook-receiver.js +15 -19
- package/lib/wishlist-alerts.js +37 -0
- package/package.json +1 -1
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
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
111
|
-
//
|
|
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
|
|
143
|
-
//
|
|
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
|
|
192
|
+
if (typeof auditAction !== "string" || !auditAction.length) return OWNER_ONLY_PERMISSION;
|
|
146
193
|
var seg = auditAction.split(".")[0];
|
|
147
|
-
return _ACTION_PERMISSION[seg] ||
|
|
194
|
+
return _ACTION_PERMISSION[seg] || OWNER_ONLY_PERMISSION;
|
|
148
195
|
}
|
|
149
196
|
|
|
150
|
-
// True when `role` grants `permission`.
|
|
151
|
-
//
|
|
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
|
-
|
|
154
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
13510
|
-
//
|
|
13511
|
-
//
|
|
13512
|
-
//
|
|
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
|
|
13549
|
-
// it is escaped anyway to keep the render
|
|
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(
|
|
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\">← 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,
|