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