@blamejs/blamejs-shop 0.4.15 → 0.4.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/admin.js CHANGED
@@ -237,6 +237,30 @@ function _strictMinorInt(value, prefix, label) {
237
237
  return n;
238
238
  }
239
239
 
240
+ // Convert an operator-entered major-unit amount (e.g. "19.99") into integer
241
+ // minor units via the money primitive — so the cents math is the
242
+ // framework's, not a hand-rolled `* 100` that loses precision under IEEE
243
+ // 754, and the conversion honours the target currency's exponent (JPY=0,
244
+ // KWD=3, USD=2). Refuses a missing / non-decimal-shaped / negative value
245
+ // with a TypeError the browser path surfaces as a 400 notice. `b.money.of`
246
+ // rejects Number inputs at the boundary, so the value is normalized to a
247
+ // trimmed decimal string first; the resulting BigInt minor units is range-
248
+ // checked back to a safe integer (quote money fits comfortably).
249
+ function _dollarsToMinor(value, label, currency) {
250
+ var cur = typeof currency === "string" && /^[A-Z]{3}$/.test(currency) ? currency : "USD";
251
+ var s = typeof value === "number" ? String(value) : (typeof value === "string" ? value.trim() : "");
252
+ if (!/^\d+(?:\.\d+)?$/.test(s)) {
253
+ throw new TypeError("admin: " + label + " must be a non-negative amount (e.g. 19.99)");
254
+ }
255
+ var minor;
256
+ try { minor = b.money.of(s, cur).toMinorUnits(); }
257
+ catch (_e) { throw new TypeError("admin: " + label + " has more decimal places than " + cur + " allows"); }
258
+ if (minor > BigInt(Number.MAX_SAFE_INTEGER)) {
259
+ throw new TypeError("admin: " + label + " is out of range");
260
+ }
261
+ return Number(minor);
262
+ }
263
+
240
264
  // Strict non-negative integer for a form field (money minor units,
241
265
  // dimensions). Refuses "", floats, and parseInt's loose-prefix "12abc"
242
266
  // → 12 — the /^\d+$/ test is anchored so the whole string must be
@@ -579,6 +603,9 @@ function mount(router, deps) {
579
603
  var inventoryReceive = deps.inventoryReceive || null; // inbound-stock receive console disabled when absent
580
604
  var stockTransfers = deps.stockTransfers || null; // location→location transfer console (dispatch/receive FSM) disabled when absent
581
605
  var inventoryWriteoffs = deps.inventoryWriteoffs || null; // reason-coded write-off / shrinkage console disabled when absent
606
+ var quotes = deps.quotes || null; // RFQ negotiation console (queue/detail/respond/withdraw/convert) disabled when absent
607
+ var emailCampaigns = deps.emailCampaigns || null; // consent-gated broadcast/campaign console disabled when absent
608
+ var mailingAudiences = deps.mailingAudiences || null; // audience picker for the campaign console (target-an-audience dropdown)
582
609
  // Read-only activity log at /admin/audit. Defaults ON — the framework
583
610
  // audit chain is always booted by createApp, so the screen always has a
584
611
  // data source (unlike the optional primitives above, which default off).
@@ -598,7 +625,7 @@ function mount(router, deps) {
598
625
  // `reports` is always present in the nav (read-only sales summary needs no
599
626
  // extra dep); its route mounts unconditionally and renders an unconfigured
600
627
  // notice when the salesReports primitive isn't wired.
601
- 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 };
628
+ 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 };
602
629
 
603
630
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
604
631
 
@@ -5678,6 +5705,250 @@ function mount(router, deps) {
5678
5705
  ));
5679
5706
  }
5680
5707
 
5708
+ // ---- email campaigns (consent-gated broadcast) ----------------------
5709
+ //
5710
+ // The marketing-broadcast console. An operator authors a campaign
5711
+ // (subject + Markdown body), targets an existing mailing audience, sees
5712
+ // the RESOLVED REACHABLE count (who is actually marketing-consented +
5713
+ // deliverable right now — never the raw membership), test-sends to their
5714
+ // own inbox, then sends. Consent is resolved AT SEND TIME, per recipient:
5715
+ // only a marketing-consented (newsletter-subscribed, not suppressed)
5716
+ // recipient with a deliverable plaintext address gets the broadcast, and
5717
+ // someone who unsubscribes after the send starts is honored mid-send.
5718
+ // Every broadcast carries an RFC 8058 one-click List-Unsubscribe header
5719
+ // pair plus an in-body unsubscribe link. The operator-authored body is
5720
+ // treated as hostile: it renders escape-by-default (any `<` lands as
5721
+ // `&lt;`; links pass the https-only safeUrl gate) so a compromised admin
5722
+ // key can't inject script into mail or stored XSS into this console.
5723
+ if (emailCampaigns) {
5724
+ var _campaignAudiences = function () {
5725
+ if (!mailingAudiences) return Promise.resolve([]);
5726
+ return mailingAudiences.listAudiences({ include_archived: false });
5727
+ };
5728
+
5729
+ // List — every campaign with status + per-campaign delivery counts.
5730
+ // Content-negotiated: bearer → the JSON array; browser → the table.
5731
+ router.get("/admin/campaigns", _pageOrApi(true,
5732
+ R(async function (req, res) {
5733
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
5734
+ var status = url && url.searchParams.get("status");
5735
+ var rows = await emailCampaigns.listCampaigns(status ? { status: status } : {});
5736
+ _json(res, 200, { campaigns: rows, can_broadcast: emailCampaigns.canBroadcast() });
5737
+ }),
5738
+ async function (req, res) {
5739
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
5740
+ var rows = await emailCampaigns.listCampaigns({});
5741
+ // Roll each campaign's per-recipient send ledger up for the count
5742
+ // column — bounded read per row (the list is operator-scale).
5743
+ var withCounts = [];
5744
+ for (var i = 0; i < rows.length; i += 1) {
5745
+ var counts = null;
5746
+ try { counts = await emailCampaigns.sendCounts(rows[i].slug); }
5747
+ catch (_e) { counts = null; }
5748
+ withCounts.push({ campaign: rows[i], counts: counts });
5749
+ }
5750
+ _sendHtml(res, 200, renderAdminCampaigns({
5751
+ shop_name: deps.shop_name, nav_available: navAvailable,
5752
+ rows: withCounts, can_broadcast: emailCampaigns.canBroadcast(),
5753
+ sent: url && url.searchParams.get("sent"),
5754
+ saved: url && url.searchParams.get("saved"),
5755
+ tested: url && url.searchParams.get("tested"),
5756
+ notice: (url && url.searchParams.get("err")) ? _campaignErrNotice(url.searchParams.get("err")) : null,
5757
+ }));
5758
+ },
5759
+ ));
5760
+
5761
+ // New-campaign form — its own GET so a bad submit's err redirect
5762
+ // keeps the operator's context.
5763
+ router.get("/admin/campaigns/new", _pageOrApi(true,
5764
+ R(async function (_req, res) {
5765
+ _json(res, 200, { audiences: await _campaignAudiences() });
5766
+ }),
5767
+ async function (req, res) {
5768
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
5769
+ _sendHtml(res, 200, renderAdminCampaignNew({
5770
+ shop_name: deps.shop_name, nav_available: navAvailable,
5771
+ audiences: await _campaignAudiences(),
5772
+ notice: (url && url.searchParams.get("err")) ? _campaignErrNotice(url.searchParams.get("err")) : null,
5773
+ }));
5774
+ },
5775
+ ));
5776
+
5777
+ function _campaignInput(body) {
5778
+ return {
5779
+ slug: body.slug,
5780
+ subject: body.subject,
5781
+ body_html: body.body_html,
5782
+ // Markdown source is the single authored body; the text alt is
5783
+ // derived from it at send time, so persist the same source in
5784
+ // both columns (the renderer produces the HTML + text views).
5785
+ body_text: body.body_text != null && body.body_text !== "" ? body.body_text : body.body_html,
5786
+ audience_slug: body.audience_slug,
5787
+ from_address: body.from_address,
5788
+ from_name: body.from_name,
5789
+ reply_to: body.reply_to != null && body.reply_to !== "" ? body.reply_to : undefined,
5790
+ };
5791
+ }
5792
+
5793
+ // Create — composes defineCampaign (validates slug / subject / body /
5794
+ // audience / sender identity, throws TypeError → 400 / err redirect).
5795
+ router.post("/admin/campaigns", _pageOrApi(false,
5796
+ W("email_campaign.create", async function (req, res) {
5797
+ var c;
5798
+ try { c = await emailCampaigns.defineCampaign(_campaignInput(req.body || {})); }
5799
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5800
+ _json(res, 201, c);
5801
+ return { id: c.slug };
5802
+ }),
5803
+ async function (req, res) {
5804
+ try { await emailCampaigns.defineCampaign(_campaignInput(req.body || {})); }
5805
+ catch (e) {
5806
+ var n = _safeNotice(e, "email_campaign.create");
5807
+ if (n.status >= 500) throw e;
5808
+ return _redirect(res, "/admin/campaigns/new?err=bad");
5809
+ }
5810
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".email_campaign.create", outcome: "success" });
5811
+ _redirect(res, "/admin/campaigns?saved=1");
5812
+ },
5813
+ ));
5814
+
5815
+ // Detail — the campaign + its rendered (escape-by-default) preview, the
5816
+ // resolved reachable count (computed live), the send-ledger counts, and
5817
+ // the test-send + send actions. Content-negotiated.
5818
+ router.get("/admin/campaigns/:slug", _pageOrApi(true,
5819
+ R(async function (req, res) {
5820
+ var c = await emailCampaigns.getCampaign(req.params.slug);
5821
+ if (!c) return _problem(res, 404, "email-campaign-not-found");
5822
+ var reach = null;
5823
+ try { reach = await emailCampaigns.reachability(req.params.slug); }
5824
+ catch (_e) { reach = null; }
5825
+ var counts = await emailCampaigns.sendCounts(req.params.slug);
5826
+ _json(res, 200, Object.assign({}, c, { reachability: reach, send_counts: counts, can_broadcast: emailCampaigns.canBroadcast() }));
5827
+ }),
5828
+ async function (req, res) {
5829
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
5830
+ var c = await emailCampaigns.getCampaign(req.params.slug);
5831
+ if (!c) return _sendHtml(res, 404, renderAdminCampaigns({
5832
+ shop_name: deps.shop_name, nav_available: navAvailable, rows: [],
5833
+ can_broadcast: emailCampaigns.canBroadcast(), notice: "Campaign not found.",
5834
+ }));
5835
+ var preview = null;
5836
+ try { preview = await emailCampaigns.previewCampaign(req.params.slug); }
5837
+ catch (_e) { preview = null; }
5838
+ var reach = null;
5839
+ try { reach = await emailCampaigns.reachability(req.params.slug); }
5840
+ catch (_e) { reach = null; }
5841
+ var counts = await emailCampaigns.sendCounts(req.params.slug);
5842
+ _sendHtml(res, 200, renderAdminCampaign({
5843
+ shop_name: deps.shop_name, nav_available: navAvailable,
5844
+ campaign: c, preview: preview, reachability: reach, counts: counts,
5845
+ can_broadcast: emailCampaigns.canBroadcast(),
5846
+ sent: url && url.searchParams.get("sent"),
5847
+ tested: url && url.searchParams.get("tested"),
5848
+ notice: (url && url.searchParams.get("err")) ? _campaignErrNotice(url.searchParams.get("err")) : null,
5849
+ }));
5850
+ },
5851
+ ));
5852
+
5853
+ // Test-send — render + mail the campaign to ONE operator-supplied
5854
+ // address (bypasses the audience + consent gate; it's the operator's
5855
+ // own inbox). Rate-bound on the shared send window.
5856
+ router.post("/admin/campaigns/:slug/test", _pageOrApi(false,
5857
+ W("email_campaign.test", async function (req, res) {
5858
+ var slug = req.params.slug;
5859
+ var out;
5860
+ try { out = await emailCampaigns.testSend(slug, (req.body || {}).to); }
5861
+ catch (e) {
5862
+ if (e instanceof TypeError) {
5863
+ var code = e.code === "EMAIL_CAMPAIGN_RATE_LIMITED" ? 429 : 400;
5864
+ return _problem(res, code, e.code === "EMAIL_CAMPAIGN_RATE_LIMITED" ? "rate-limited" : "bad-request", e.message);
5865
+ }
5866
+ _safeNotice(e, "email_campaign.test");
5867
+ return _problem(res, 502, "send-failed", "The test message could not be sent.");
5868
+ }
5869
+ _json(res, 200, out);
5870
+ return { id: slug };
5871
+ }),
5872
+ async function (req, res) {
5873
+ var slug = req.params.slug;
5874
+ var enc = encodeURIComponent(slug);
5875
+ try { await emailCampaigns.testSend(slug, (req.body || {}).to); }
5876
+ catch (e) {
5877
+ if (e instanceof TypeError) {
5878
+ var reason = e.code === "EMAIL_CAMPAIGN_RATE_LIMITED" ? "rate" : "test";
5879
+ return _redirect(res, "/admin/campaigns/" + enc + "?err=" + reason);
5880
+ }
5881
+ _safeNotice(e, "email_campaign.test");
5882
+ return _redirect(res, "/admin/campaigns/" + enc + "?err=send");
5883
+ }
5884
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".email_campaign.test", outcome: "success", metadata: { slug: slug } });
5885
+ _redirect(res, "/admin/campaigns/" + enc + "?tested=1");
5886
+ },
5887
+ ));
5888
+
5889
+ // Send — the consent-gated broadcast. Resolves reachability at the
5890
+ // send moment, drains the audience, only marketing-consented +
5891
+ // deliverable recipients receive, every message carries the one-click
5892
+ // unsubscribe pair. One bad address is counted, never fatal.
5893
+ router.post("/admin/campaigns/:slug/send", _pageOrApi(false,
5894
+ W("email_campaign.send", async function (req, res) {
5895
+ var slug = req.params.slug;
5896
+ var c = await emailCampaigns.getCampaign(slug);
5897
+ if (!c) return _problem(res, 404, "email-campaign-not-found");
5898
+ var out;
5899
+ try { out = await emailCampaigns.broadcast(slug); }
5900
+ catch (e) {
5901
+ if (e instanceof TypeError) {
5902
+ var code = e.code === "EMAIL_CAMPAIGN_BROADCAST_UNAVAILABLE" ? 409 : 400;
5903
+ return _problem(res, code, e.code === "EMAIL_CAMPAIGN_BROADCAST_UNAVAILABLE" ? "broadcast-unavailable" : "bad-request", e.message);
5904
+ }
5905
+ throw e;
5906
+ }
5907
+ _json(res, 200, out);
5908
+ return { id: slug };
5909
+ }),
5910
+ async function (req, res) {
5911
+ var slug = req.params.slug;
5912
+ var enc = encodeURIComponent(slug);
5913
+ var c = await emailCampaigns.getCampaign(slug);
5914
+ if (!c) return _redirect(res, "/admin/campaigns?err=notfound");
5915
+ try { await emailCampaigns.broadcast(slug); }
5916
+ catch (e) {
5917
+ if (e instanceof TypeError) {
5918
+ var reason = e.code === "EMAIL_CAMPAIGN_BROADCAST_UNAVAILABLE" ? "unavailable" : "send";
5919
+ return _redirect(res, "/admin/campaigns/" + enc + "?err=" + reason);
5920
+ }
5921
+ _safeNotice(e, "email_campaign.send");
5922
+ return _redirect(res, "/admin/campaigns/" + enc + "?err=send");
5923
+ }
5924
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".email_campaign.send", outcome: "success", metadata: { slug: slug } });
5925
+ _redirect(res, "/admin/campaigns/" + enc + "?sent=1");
5926
+ },
5927
+ ));
5928
+
5929
+ // Cancel — terminal off-ramp for a draft / scheduled campaign.
5930
+ router.post("/admin/campaigns/:slug/cancel", _pageOrApi(false,
5931
+ W("email_campaign.cancel", async function (req, res) {
5932
+ var slug = req.params.slug;
5933
+ var reason = (req.body || {}).reason || "Cancelled from the console.";
5934
+ var out;
5935
+ try { out = await emailCampaigns.cancelCampaign(slug, reason); }
5936
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5937
+ _json(res, 200, out);
5938
+ return { id: slug };
5939
+ }),
5940
+ async function (req, res) {
5941
+ var slug = req.params.slug;
5942
+ var enc = encodeURIComponent(slug);
5943
+ var reason = (req.body || {}).reason || "Cancelled from the console.";
5944
+ try { await emailCampaigns.cancelCampaign(slug, reason); }
5945
+ catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/campaigns/" + enc + "?err=cancel"); }
5946
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".email_campaign.cancel", outcome: "success", metadata: { slug: slug } });
5947
+ _redirect(res, "/admin/campaigns?saved=1");
5948
+ },
5949
+ ));
5950
+ }
5951
+
5681
5952
  // ---- gift wraps -----------------------------------------------------
5682
5953
  //
5683
5954
  // The operator-defined gift-wrap catalog: define / update / archive a wrap
@@ -6862,6 +7133,187 @@ function mount(router, deps) {
6862
7133
  ));
6863
7134
  }
6864
7135
 
7136
+ // ---- quotes (B2B request-for-quote negotiation) ---------------------
7137
+ // The operator side of the RFQ lifecycle. The list is the response queue
7138
+ // (oldest-waiting requests first) plus a recent-activity view; the detail
7139
+ // screen shows the requested lines + the customer message, and — for a
7140
+ // still-requested quote — a per-line pricing form that responds with a
7141
+ // priced quote + validity window. Responded/accepted quotes show the
7142
+ // quoted totals; an operator can withdraw a quote that hasn't been
7143
+ // accepted, or convert an accepted one into a pending order. Content-
7144
+ // negotiated like the other consoles (bearer → JSON, browser → HTML).
7145
+ if (quotes) {
7146
+ // Build the respondToQuote line_prices array from the per-line
7147
+ // `price_<sku>` dollar fields the detail form posts, converting each to
7148
+ // minor units. Every quote line must be priced; a missing / non-numeric
7149
+ // field throws a TypeError the route maps to a 400 re-render. The dollar
7150
+ // string is parsed to integer cents WITHOUT floating-point (split on the
7151
+ // decimal point) so 19.99 never lands as 1998 via float drift.
7152
+ function _quoteLinePricesFromForm(body, lines, currency) {
7153
+ var out = [];
7154
+ for (var i = 0; i < lines.length; i += 1) {
7155
+ var sku = lines[i].sku;
7156
+ var raw = body["price_" + sku];
7157
+ if (raw == null || (typeof raw === "string" && !raw.trim().length)) {
7158
+ throw new TypeError("quotes: a unit price is required for every line (missing " + sku + ")");
7159
+ }
7160
+ out.push({ sku: sku, unit_price_minor: _dollarsToMinor(raw, "unit price for " + sku, currency) });
7161
+ }
7162
+ return out;
7163
+ }
7164
+
7165
+ router.get("/admin/quotes", _pageOrApi(true,
7166
+ R(async function (req, res) {
7167
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
7168
+ var cid = url && url.searchParams.get("customer_id");
7169
+ var rows = cid
7170
+ ? await quotes.quotesForCustomer(cid, { limit: 200 })
7171
+ : await quotes.pendingResponse({ limit: 200 });
7172
+ _json(res, 200, { rows: rows });
7173
+ }),
7174
+ async function (req, res) {
7175
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
7176
+ var cid = url && url.searchParams.get("customer_id");
7177
+ var rows = [];
7178
+ try {
7179
+ rows = cid
7180
+ ? await quotes.quotesForCustomer(cid, { limit: 200 })
7181
+ : await quotes.pendingResponse({ limit: 200 });
7182
+ } catch (e) { if (!(e instanceof TypeError)) throw e; }
7183
+ _sendHtml(res, 200, renderAdminQuotes({
7184
+ shop_name: deps.shop_name, nav_available: navAvailable, quotes: rows,
7185
+ customer_filter: cid,
7186
+ responded: url && url.searchParams.get("responded"),
7187
+ withdrawn: url && url.searchParams.get("withdrawn"),
7188
+ converted: url && url.searchParams.get("converted"),
7189
+ notice: (url && url.searchParams.get("err"))
7190
+ ? "That action couldn't be completed for the quote." : null,
7191
+ }));
7192
+ },
7193
+ ));
7194
+
7195
+ // Detail: the quote + its lines, plus the per-line respond form (when
7196
+ // still requested) and the withdraw / convert actions.
7197
+ router.get("/admin/quotes/:id", _pageOrApi(true,
7198
+ R(async function (req, res) {
7199
+ var row = null;
7200
+ try { row = await quotes.getQuote(req.params.id); }
7201
+ catch (e) { if (e instanceof TypeError) return _problem(res, 404, "quote-not-found"); throw e; }
7202
+ if (!row) return _problem(res, 404, "quote-not-found");
7203
+ _json(res, 200, row);
7204
+ }),
7205
+ async function (req, res) {
7206
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
7207
+ var row = null;
7208
+ try { row = await quotes.getQuote(req.params.id); }
7209
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
7210
+ if (!row) return _sendHtml(res, 404, renderAdminQuoteDetail({
7211
+ shop_name: deps.shop_name, nav_available: navAvailable, quote: null,
7212
+ }));
7213
+ _sendHtml(res, 200, renderAdminQuoteDetail({
7214
+ shop_name: deps.shop_name, nav_available: navAvailable, quote: row,
7215
+ notice: (url && url.searchParams.get("err"))
7216
+ ? "That action couldn't be completed for the quote." : null,
7217
+ }));
7218
+ },
7219
+ ));
7220
+
7221
+ // Respond: price every line + set shipping / tax / validity. The browser
7222
+ // form posts dollar amounts (converted to minor units here) + a
7223
+ // validity-in-days; the bearer JSON contract takes the primitive's native
7224
+ // shape (minor units + an absolute valid_until). A bad shape is a clean
7225
+ // 400 (bearer) / err re-render (browser), never a 500.
7226
+ router.post("/admin/quotes/:id/respond", _pageOrApi(false,
7227
+ W("quote.respond", async function (req, res) {
7228
+ var row;
7229
+ try { row = await quotes.respondToQuote(Object.assign({}, req.body || {}, { quote_id: req.params.id })); }
7230
+ catch (e) {
7231
+ if (e && e.code === "QUOTE_NOT_FOUND") return _problem(res, 404, "quote-not-found");
7232
+ if (e && e.code === "QUOTE_TRANSITION_REFUSED") return _problem(res, 409, "quote-transition-refused", e.message);
7233
+ if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
7234
+ throw e;
7235
+ }
7236
+ _json(res, 200, row);
7237
+ return { id: row.id };
7238
+ }),
7239
+ async function (req, res) {
7240
+ var id = req.params.id;
7241
+ var enc = encodeURIComponent(id);
7242
+ var body = req.body || {};
7243
+ var current = null;
7244
+ try { current = await quotes.getQuote(id); } catch (_e) { current = null; }
7245
+ if (!current) return _redirect(res, "/admin/quotes?err=1");
7246
+ try {
7247
+ var validDays = _strictNonNegIntField(body.valid_days, "valid_days");
7248
+ if (validDays <= 0) throw new TypeError("admin: valid_days must be at least 1");
7249
+ var quoteCurrency = typeof body.currency === "string" && body.currency
7250
+ ? body.currency.toUpperCase() : (current.currency || "USD");
7251
+ await quotes.respondToQuote({
7252
+ quote_id: id,
7253
+ line_prices: _quoteLinePricesFromForm(body, current.lines, quoteCurrency),
7254
+ shipping_minor: body.shipping == null || body.shipping === "" ? 0 : _dollarsToMinor(body.shipping, "shipping", quoteCurrency),
7255
+ tax_minor: body.tax == null || body.tax === "" ? 0 : _dollarsToMinor(body.tax, "tax", quoteCurrency),
7256
+ valid_until: Date.now() + b.constants.TIME.days(validDays),
7257
+ currency: quoteCurrency,
7258
+ operator_notes: body.operator_notes || null,
7259
+ });
7260
+ } catch (e) {
7261
+ if (!(e instanceof TypeError) && !(e && (e.code === "QUOTE_TRANSITION_REFUSED" || e.code === "QUOTE_NOT_FOUND"))) throw e;
7262
+ var msg = _safeNotice(e, "quote.respond");
7263
+ var fresh = await quotes.getQuote(id);
7264
+ return _sendHtml(res, msg.status, renderAdminQuoteDetail({
7265
+ shop_name: deps.shop_name, nav_available: navAvailable, quote: fresh,
7266
+ notice: msg.message.replace(/^(quotes|admin)[.:]\s*/, ""),
7267
+ }));
7268
+ }
7269
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".quote.respond", outcome: "success", metadata: { quote_id: id } });
7270
+ // Fire the quote-responded email (drop-silent, fire-and-forget) when a
7271
+ // notifier is wired — the customer learns their quote is priced.
7272
+ if (typeof deps.notifyQuoteResponded === "function") {
7273
+ try { await deps.notifyQuoteResponded(id); }
7274
+ catch (_e) { /* drop-silent — the response already persisted */ }
7275
+ }
7276
+ _redirect(res, "/admin/quotes/" + enc + "?responded=1");
7277
+ },
7278
+ ));
7279
+
7280
+ // Withdraw: cancel a quote that hasn't been accepted yet (requested or
7281
+ // responded). Accepted / terminal quotes refuse — the FSM gate is the
7282
+ // single source of truth, surfaced as a 409.
7283
+ router.post("/admin/quotes/:id/withdraw", _pageOrApi(false,
7284
+ W("quote.withdraw", async function (req, res) {
7285
+ var row;
7286
+ try {
7287
+ row = await quotes.cancelQuote({
7288
+ quote_id: req.params.id,
7289
+ cancel_reason: (req.body && req.body.cancel_reason) || "Withdrawn by operator",
7290
+ });
7291
+ } catch (e) {
7292
+ if (e && e.code === "QUOTE_NOT_FOUND") return _problem(res, 404, "quote-not-found");
7293
+ if (e && e.code === "QUOTE_TRANSITION_REFUSED") return _problem(res, 409, "quote-transition-refused", e.message);
7294
+ if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
7295
+ throw e;
7296
+ }
7297
+ _json(res, 200, row);
7298
+ return { id: row.id };
7299
+ }),
7300
+ async function (req, res) {
7301
+ var id = req.params.id;
7302
+ try {
7303
+ await quotes.cancelQuote({
7304
+ quote_id: id,
7305
+ cancel_reason: (req.body && req.body.cancel_reason) || "Withdrawn by operator",
7306
+ });
7307
+ } catch (e) {
7308
+ if (!(e instanceof TypeError) && !(e && (e.code === "QUOTE_TRANSITION_REFUSED" || e.code === "QUOTE_NOT_FOUND"))) throw e;
7309
+ return _redirect(res, "/admin/quotes/" + encodeURIComponent(id) + "?err=1");
7310
+ }
7311
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".quote.withdraw", outcome: "success", metadata: { quote_id: id } });
7312
+ _redirect(res, "/admin/quotes?withdrawn=1");
7313
+ },
7314
+ ));
7315
+ }
7316
+
6865
7317
  // ---- search ranking -------------------------------------------------
6866
7318
  // Operator-tunable storefront search ranking: named weight sets (one
6867
7319
  // active at a time), per-query manual pins, and a per-set metrics rollup.
@@ -12356,6 +12808,7 @@ var ADMIN_NAV_ITEMS = [
12356
12808
  { key: "inventory-transfers", href: "/admin/inventory/transfers", label: "Transfers", requires: "stockTransfers" },
12357
12809
  { key: "inventory-writeoffs", href: "/admin/inventory/writeoffs", label: "Write-offs", requires: "inventoryWriteoffs" },
12358
12810
  { key: "orders", href: "/admin/orders", label: "Orders" },
12811
+ { key: "quotes", href: "/admin/quotes", label: "Quotes", requires: "quotes" },
12359
12812
  { key: "carts", href: "/admin/carts", label: "Abandoned carts", requires: "carts" },
12360
12813
  { key: "reports", href: "/admin/reports", label: "Reports" },
12361
12814
  { key: "analytics", href: "/admin/analytics", label: "Analytics", requires: "analytics" },
@@ -12391,6 +12844,7 @@ var ADMIN_NAV_ITEMS = [
12391
12844
  { key: "pick-lists", href: "/admin/pick-lists", label: "Pick lists", requires: "pickLists" },
12392
12845
  { key: "announcements", href: "/admin/announcements", label: "Announcements", requires: "announcementBar" },
12393
12846
  { key: "promo-banners", href: "/admin/promo-banners", label: "Promo banners", requires: "promoBanners" },
12847
+ { key: "campaigns", href: "/admin/campaigns", label: "Email campaigns", requires: "emailCampaigns" },
12394
12848
  { key: "blog", href: "/admin/blog", label: "Blog", requires: "blog" },
12395
12849
  { key: "help", href: "/admin/help", label: "Help center", requires: "knowledgeBase" },
12396
12850
  { key: "pages", href: "/admin/pages", label: "Pages", requires: "storefrontPages" },
@@ -15214,6 +15668,195 @@ function renderAdminInvReceive(opts) {
15214
15668
  return _renderAdminShell(opts.shop_name, "Receive stock", body, "inventory-receive", opts.nav_available);
15215
15669
  }
15216
15670
 
15671
+ // ---- email campaign console renders ----------------------------------
15672
+
15673
+ // Map an ?err= code on a campaign console redirect to an operator-facing
15674
+ // notice. The codes are emitted by the campaign routes, never operator
15675
+ // free text, so no escaping concern here.
15676
+ function _campaignErrNotice(code) {
15677
+ if (code === "bad") return "Check the campaign — slug, subject, body, audience, and a valid sender address are all required.";
15678
+ if (code === "rate") return "Send rate limit reached. Try again in a moment.";
15679
+ if (code === "test") return "The test recipient address wasn't valid.";
15680
+ if (code === "send") return "The send couldn't be completed. Check the error log.";
15681
+ if (code === "unavailable") return "Broadcast isn't available — this deployment has no deliverable-address source (newsletter list) or no configured unsubscribe origin.";
15682
+ if (code === "cancel") return "That campaign can't be cancelled from its current state.";
15683
+ if (code === "notfound") return "Campaign not found.";
15684
+ return "That action couldn't be completed.";
15685
+ }
15686
+
15687
+ function _campaignStatusPill(status) {
15688
+ var cls = "status-pill";
15689
+ if (status === "sent") cls = "status-pill status-pill--ok";
15690
+ if (status === "cancelled") cls = "status-pill status-pill--muted";
15691
+ if (status === "sending") cls = "status-pill status-pill--warn";
15692
+ return "<span class=\"" + cls + "\">" + _htmlEscape(String(status)) + "</span>";
15693
+ }
15694
+
15695
+ // Per-recipient delivery counts cell — "12 sent · 3 skipped · 1 failed".
15696
+ // All four outcomes roll into a compact summary; a zero-everything
15697
+ // campaign shows an em-dash.
15698
+ function _campaignCountsSummary(counts) {
15699
+ if (!counts) return "<span class=\"meta\">—</span>";
15700
+ var sent = Number(counts.sent || 0);
15701
+ var skipped = Number(counts.skipped_unsubscribed || 0) + Number(counts.skipped_suppressed || 0);
15702
+ var failed = Number(counts.failed || 0);
15703
+ if (!sent && !skipped && !failed) return "<span class=\"meta\">—</span>";
15704
+ var parts = [];
15705
+ parts.push(_htmlEscape(String(sent)) + " sent");
15706
+ if (skipped) parts.push(_htmlEscape(String(skipped)) + " skipped");
15707
+ if (failed) parts.push(_htmlEscape(String(failed)) + " failed");
15708
+ return _htmlEscape(parts.join(" · "));
15709
+ }
15710
+
15711
+ function renderAdminCampaigns(opts) {
15712
+ opts = opts || {};
15713
+ var rows = opts.rows || [];
15714
+ var saved = opts.saved ? "<div class=\"banner banner--ok\">Campaign saved.</div>" : "";
15715
+ var sent = opts.sent ? "<div class=\"banner banner--ok\">Campaign sent.</div>" : "";
15716
+ var tested = opts.tested ? "<div class=\"banner banner--ok\">Test message sent.</div>" : "";
15717
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
15718
+
15719
+ // Honesty banner — when the broadcast path isn't wired (no deliverable
15720
+ // address source / no unsubscribe origin), say so plainly. Campaigns
15721
+ // can still be authored + previewed; the Send action will refuse.
15722
+ var reachBanner = opts.can_broadcast
15723
+ ? ""
15724
+ : "<div class=\"banner banner--warn\">Sending is unavailable: this store has no deliverable-address source " +
15725
+ "(a newsletter subscriber list with plaintext addresses) or no configured unsubscribe origin. " +
15726
+ "Customer email is stored hash-only, so only newsletter subscribers are reachable. You can still draft and preview campaigns.</div>";
15727
+
15728
+ var tableRows = rows.map(function (rc) {
15729
+ var c = rc.campaign;
15730
+ var enc = encodeURIComponent(c.slug);
15731
+ return "<tr>" +
15732
+ "<td><a href=\"/admin/campaigns/" + enc + "\">" + _htmlEscape(c.subject) + "</a><div class=\"meta\"><code>" + _htmlEscape(c.slug) + "</code></div></td>" +
15733
+ "<td>" + _htmlEscape(c.audience_slug) + "</td>" +
15734
+ "<td>" + _campaignStatusPill(c.status) + "</td>" +
15735
+ "<td>" + _campaignCountsSummary(rc.counts) + "</td>" +
15736
+ "</tr>";
15737
+ }).join("");
15738
+
15739
+ var list = rows.length
15740
+ ? "<div class=\"panel\">" + _tableWrap("<table><thead><tr><th scope=\"col\">Subject</th><th scope=\"col\">Audience</th><th scope=\"col\">Status</th><th scope=\"col\">Delivery</th></tr></thead><tbody>" + tableRows + "</tbody></table>") + "</div>"
15741
+ : "<p class=\"empty\">No campaigns yet. Create one to broadcast to a mailing audience.</p>";
15742
+
15743
+ var body =
15744
+ "<section><h2>Email campaigns</h2>" +
15745
+ "<p class=\"meta\">Broadcast a message to a mailing audience. Only marketing-consented, reachable subscribers receive a campaign — consent is checked at send time, and every message carries a one-click unsubscribe.</p>" +
15746
+ saved + sent + tested + notice + reachBanner +
15747
+ "<div class=\"actions-row\"><a class=\"btn\" href=\"/admin/campaigns/new\">New campaign</a></div>" +
15748
+ list +
15749
+ "</section>";
15750
+ return _renderAdminShell(opts.shop_name, "Email campaigns", body, "campaigns", opts.nav_available);
15751
+ }
15752
+
15753
+ function renderAdminCampaignNew(opts) {
15754
+ opts = opts || {};
15755
+ var audiences = opts.audiences || [];
15756
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
15757
+
15758
+ var audOptions = audiences.map(function (a) {
15759
+ return "<option value=\"" + _htmlEscape(a.slug) + "\">" + _htmlEscape(a.title) + " (" + _htmlEscape(a.slug) + ")</option>";
15760
+ }).join("");
15761
+ var audField = audiences.length
15762
+ ? "<label class=\"form-field\"><span>Audience</span><select name=\"audience_slug\" required>" + audOptions + "</select>" +
15763
+ "<small>The mailing audience to broadcast to. Manage audiences from the mailing-audiences API.</small></label>"
15764
+ : "<label class=\"form-field\"><span>Audience slug</span><input type=\"text\" name=\"audience_slug\" maxlength=\"64\" required>" +
15765
+ "<small>No mailing audiences are defined yet — enter the slug of one you've created via the API.</small></label>";
15766
+
15767
+ var body =
15768
+ "<section class=\"mw-42\"><h2>New campaign</h2>" + notice +
15769
+ "<form method=\"post\" action=\"/admin/campaigns\">" +
15770
+ _setupField("Campaign slug", "slug", "", "text", "Lowercase id, e.g. spring-sale-2026.", " maxlength=\"64\" required") +
15771
+ _setupField("Subject", "subject", "", "text", "The email subject line.", " maxlength=\"200\" required") +
15772
+ "<label class=\"form-field\"><span>Body (Markdown)</span>" +
15773
+ "<textarea name=\"body_html\" rows=\"10\" maxlength=\"100000\" required></textarea>" +
15774
+ "<small>Markdown — headings, lists, links, bold/italic. Rendered escape-by-default: raw HTML is shown as text, links must be https.</small></label>" +
15775
+ audField +
15776
+ _setupField("From address", "from_address", "", "email", "The sender address (must be a domain you can send from).", " maxlength=\"254\" required") +
15777
+ _setupField("From name", "from_name", "", "text", "The friendly sender name.", " maxlength=\"100\" required") +
15778
+ _setupField("Reply-to", "reply_to", "", "email", "Optional — where replies land.", " maxlength=\"254\"") +
15779
+ "<div class=\"actions-row\"><button type=\"submit\" class=\"btn\">Create campaign</button>" +
15780
+ "<a class=\"btn btn--ghost\" href=\"/admin/campaigns\">Cancel</a></div>" +
15781
+ "</form>" +
15782
+ "</section>";
15783
+ return _renderAdminShell(opts.shop_name, "New campaign", body, "campaigns", opts.nav_available);
15784
+ }
15785
+
15786
+ function renderAdminCampaign(opts) {
15787
+ opts = opts || {};
15788
+ var c = opts.campaign;
15789
+ var enc = encodeURIComponent(c.slug);
15790
+ var sent = opts.sent ? "<div class=\"banner banner--ok\">Campaign sent.</div>" : "";
15791
+ var tested = opts.tested ? "<div class=\"banner banner--ok\">Test message sent.</div>" : "";
15792
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
15793
+
15794
+ // Resolved reachable count — the true send size, computed live. The
15795
+ // operator sees this BEFORE confirming a send.
15796
+ var reach = opts.reachability;
15797
+ var reachPanel = reach
15798
+ ? "<div class=\"panel\"><h3 class=\"subhead\">Reachable recipients</h3>" +
15799
+ "<p class=\"big-stat\">" + _htmlEscape(String(reach.reachable)) + "</p>" +
15800
+ "<p class=\"meta\">Resolved at send time from " + _htmlEscape(String(reach.resolved)) + " audience members: " +
15801
+ _htmlEscape(String(reach.reachable)) + " reachable, " +
15802
+ _htmlEscape(String(reach.suppressed)) + " suppressed, " +
15803
+ _htmlEscape(String(reach.unsubscribed)) + " unsubscribed, " +
15804
+ _htmlEscape(String(reach.no_address)) + " with no deliverable address.</p>" +
15805
+ "</div>"
15806
+ : "<div class=\"panel\"><p class=\"meta\">Reachability can't be resolved — no deliverable-address source is wired.</p></div>";
15807
+
15808
+ // Per-recipient delivery counts from the send ledger.
15809
+ var counts = opts.counts || {};
15810
+ var countsPanel = "<div class=\"panel\"><h3 class=\"subhead\">Delivery</h3>" +
15811
+ "<ul class=\"kv-list\">" +
15812
+ "<li><span>Sent</span><strong>" + _htmlEscape(String(counts.sent || 0)) + "</strong></li>" +
15813
+ "<li><span>Skipped (unsubscribed)</span><strong>" + _htmlEscape(String(counts.skipped_unsubscribed || 0)) + "</strong></li>" +
15814
+ "<li><span>Skipped (suppressed)</span><strong>" + _htmlEscape(String(counts.skipped_suppressed || 0)) + "</strong></li>" +
15815
+ "<li><span>Failed</span><strong>" + _htmlEscape(String(counts.failed || 0)) + "</strong></li>" +
15816
+ "</ul></div>";
15817
+
15818
+ // Rendered preview — the renderer already escaped the operator body, so
15819
+ // the preview HTML is splice-safe (no double-escape). Spliced literally
15820
+ // so a `$` in the body can't trip String.replace dollar substitution.
15821
+ var previewBody = opts.preview ? opts.preview.body_html : "<span class=\"meta\">Preview unavailable.</span>";
15822
+ var previewPanel = "<div class=\"panel\"><h3 class=\"subhead\">Preview</h3>" +
15823
+ "<div class=\"mail-preview\">RAW_PREVIEW_BODY</div></div>";
15824
+
15825
+ // Send / test actions only when the campaign isn't terminal.
15826
+ var isTerminal = c.status === "sent" || c.status === "cancelled";
15827
+ var actions = "";
15828
+ if (!isTerminal) {
15829
+ var sendBtn = opts.can_broadcast
15830
+ ? "<form method=\"post\" action=\"/admin/campaigns/" + enc + "/send\" class=\"form-inline\">" +
15831
+ "<button class=\"btn\" type=\"submit\">Send to " + _htmlEscape(reach ? String(reach.reachable) : "0") + " recipient(s)</button></form>"
15832
+ : "<span class=\"meta\">Sending is unavailable on this deployment.</span>";
15833
+ actions =
15834
+ "<div class=\"panel\"><h3 class=\"subhead\">Send a test</h3>" +
15835
+ "<form method=\"post\" action=\"/admin/campaigns/" + enc + "/test\" class=\"form-inline\">" +
15836
+ "<input type=\"email\" name=\"to\" placeholder=\"you@example.com\" maxlength=\"254\" required>" +
15837
+ "<button class=\"btn btn--ghost\" type=\"submit\">Send test</button></form></div>" +
15838
+ "<div class=\"panel\"><h3 class=\"subhead\">Send campaign</h3>" + sendBtn +
15839
+ "<form method=\"post\" action=\"/admin/campaigns/" + enc + "/cancel\" class=\"form-inline mt\">" +
15840
+ "<button class=\"btn btn--ghost btn--sm\" type=\"submit\">Cancel campaign</button></form></div>";
15841
+ }
15842
+
15843
+ var meta = "<p class=\"meta\">Audience <code>" + _htmlEscape(c.audience_slug) + "</code> · " +
15844
+ "from " + _htmlEscape(c.from_name) + " &lt;" + _htmlEscape(c.from_address) + "&gt; · " +
15845
+ _campaignStatusPill(c.status) + "</p>";
15846
+
15847
+ var body =
15848
+ "<section><h2>" + _htmlEscape(c.subject) + "</h2>" + meta +
15849
+ sent + tested + notice +
15850
+ reachPanel + countsPanel + previewPanel + actions +
15851
+ "<div class=\"actions-row mt\"><a class=\"btn btn--ghost\" href=\"/admin/campaigns\">&larr; All campaigns</a></div>" +
15852
+ "</section>";
15853
+ var html = _renderAdminShell(opts.shop_name, c.subject, body, "campaigns", opts.nav_available);
15854
+ // Splice the already-escaped preview body literally (it contains only the
15855
+ // fixed tag set the escape-by-default renderer emits; the operator's
15856
+ // bytes were escaped before this point).
15857
+ return _spliceRaw(html, "RAW_PREVIEW_BODY", previewBody);
15858
+ }
15859
+
15217
15860
  // Location→location transfer console: the open form + the open-transfer
15218
15861
  // queue with the FSM action legal from each row's status. Reasons,
15219
15862
  // carriers, and tracking numbers are operator free text — escaped.
@@ -18963,6 +19606,149 @@ function _standardSurveyQuestions(kind) {
18963
19606
  ];
18964
19607
  }
18965
19608
 
19609
+ // Map a quote status to a status-pill modifier class (reusing the order
19610
+ // pills: paid=green for the live/positive states, cancelled=grey for the
19611
+ // terminal-without-sale ones). Purely cosmetic.
19612
+ function _quotePillClass(status) {
19613
+ if (status === "responded") return "paid";
19614
+ if (status === "accepted" || status === "converted") return "paid";
19615
+ if (status === "requested") return "pending";
19616
+ return "cancelled"; // rejected / expired / cancelled
19617
+ }
19618
+
19619
+ function renderAdminQuotes(opts) {
19620
+ opts = opts || {};
19621
+ var rows = opts.quotes || [];
19622
+ var responded = opts.responded ? "<div class=\"banner banner--ok\">Quote sent to the customer.</div>" : "";
19623
+ var withdrawn = opts.withdrawn ? "<div class=\"banner banner--ok\">Quote withdrawn.</div>" : "";
19624
+ var converted = opts.converted ? "<div class=\"banner banner--ok\">Quote converted to an order.</div>" : "";
19625
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
19626
+
19627
+ var cf = opts.customer_filter;
19628
+ var heading = cf ? "Quotes for this customer" : "Quotes awaiting a response";
19629
+ var chips = "<div class=\"order-filters\">" +
19630
+ "<a class=\"chip" + (cf == null ? " chip--on" : "") + "\" href=\"/admin/quotes\">Response queue</a>" +
19631
+ (cf ? "<a class=\"chip chip--on\" href=\"/admin/quotes?customer_id=" + _htmlEscape(encodeURIComponent(cf)) + "\">This customer</a>" : "") +
19632
+ "</div>";
19633
+
19634
+ var bodyRows = rows.map(function (q) {
19635
+ var enc = _htmlEscape(encodeURIComponent(q.id));
19636
+ var total = q.total_minor == null
19637
+ ? "<span class=\"u-mute\">—</span>"
19638
+ : _htmlEscape(pricing.format(q.total_minor, q.currency || "USD"));
19639
+ var lineCount = (q.lines || []).length;
19640
+ return "<tr>" +
19641
+ "<td><a href=\"/admin/quotes/" + enc + "\"><code class=\"order-id\">" + _htmlEscape(String(q.id).slice(0, 8)) + "</code></a></td>" +
19642
+ "<td><a href=\"/admin/customers/" + _htmlEscape(encodeURIComponent(q.customer_id)) + "\">" + _htmlEscape(String(q.customer_id).slice(0, 8)) + "</a></td>" +
19643
+ "<td><span class=\"status-pill " + _quotePillClass(q.status) + "\">" + _htmlEscape(q.status) + "</span></td>" +
19644
+ "<td class=\"num\">" + lineCount + "</td>" +
19645
+ "<td class=\"num\">" + total + "</td>" +
19646
+ "<td><a class=\"btn btn--ghost\" href=\"/admin/quotes/" + enc + "\">Open</a></td>" +
19647
+ "</tr>";
19648
+ }).join("");
19649
+
19650
+ var table = rows.length
19651
+ ? "<div class=\"panel\">" + _tableWrap("<table><thead><tr><th scope=\"col\">Quote</th><th scope=\"col\">Customer</th><th scope=\"col\">Status</th><th scope=\"col\" class=\"num\">Lines</th><th scope=\"col\" class=\"num\">Total</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + bodyRows + "</tbody></table>") + "</div>"
19652
+ : "<p class=\"empty\">" + (cf ? "No quotes for this customer." : "No quotes are waiting for a response.") + "</p>";
19653
+
19654
+ var bodyHtml = "<section><h2>Quotes</h2>" + responded + withdrawn + converted + notice +
19655
+ "<p class=\"meta\">Request-for-quote negotiations. The response queue lists the requests waiting on you, oldest first — open one to price its lines and send the customer a quote.</p>" +
19656
+ chips + "<h3 class=\"subhead\">" + _htmlEscape(heading) + "</h3>" + table + "</section>";
19657
+ return _renderAdminShell(opts.shop_name, "Quotes", bodyHtml, "quotes", opts.nav_available);
19658
+ }
19659
+
19660
+ function renderAdminQuoteDetail(opts) {
19661
+ opts = opts || {};
19662
+ var q = opts.quote;
19663
+ if (!q) {
19664
+ var nf = "<section><h2>Quote</h2><p class=\"empty\">Quote not found.</p>" +
19665
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/quotes\">Back to quotes</a></div></section>";
19666
+ return _renderAdminShell(opts.shop_name, "Quote", nf, "quotes", opts.nav_available);
19667
+ }
19668
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
19669
+ var enc = _htmlEscape(encodeURIComponent(q.id));
19670
+ var currency = q.currency || "USD";
19671
+
19672
+ // Header summary.
19673
+ var summary =
19674
+ "<div class=\"panel\">" +
19675
+ "<p class=\"meta\">Status: <span class=\"status-pill " + _quotePillClass(q.status) + "\">" + _htmlEscape(q.status) + "</span></p>" +
19676
+ "<p class=\"meta\">Customer: <a href=\"/admin/customers/" + _htmlEscape(encodeURIComponent(q.customer_id)) + "\">" + _htmlEscape(q.customer_id) + "</a></p>" +
19677
+ (q.delivery_terms ? "<p class=\"meta\">Delivery terms: " + _htmlEscape(q.delivery_terms) + "</p>" : "") +
19678
+ (q.payment_terms ? "<p class=\"meta\">Payment terms: " + _htmlEscape(q.payment_terms) + "</p>" : "") +
19679
+ (q.message ? "<p class=\"meta\">Customer message: <q>" + _htmlEscape(q.message) + "</q></p>" : "") +
19680
+ (q.valid_until ? "<p class=\"meta\">Valid until: " + _htmlEscape(new Date(Number(q.valid_until)).toISOString()) + "</p>" : "") +
19681
+ (q.total_minor != null ? "<p class=\"meta\">Quoted total: <strong>" + _htmlEscape(pricing.format(q.total_minor, currency)) + "</strong></p>" : "") +
19682
+ (q.converted_order_id ? "<p class=\"meta\">Converted to order: <a href=\"/admin/orders/" + _htmlEscape(encodeURIComponent(q.converted_order_id)) + "\"><code class=\"order-id\">" + _htmlEscape(String(q.converted_order_id).slice(0, 8)) + "</code></a></p>" : "") +
19683
+ "</div>";
19684
+
19685
+ // Lines table — shows the requested qty + (once responded) the priced
19686
+ // unit + line total.
19687
+ var lineRows = (q.lines || []).map(function (l) {
19688
+ var unit = l.unit_price_minor == null
19689
+ ? "<span class=\"u-mute\">—</span>"
19690
+ : _htmlEscape(pricing.format(l.unit_price_minor, l.currency || currency));
19691
+ var lineTotal = l.unit_price_minor == null
19692
+ ? "<span class=\"u-mute\">—</span>"
19693
+ : _htmlEscape(pricing.format(l.unit_price_minor * l.quantity, l.currency || currency));
19694
+ return "<tr>" +
19695
+ "<td><code class=\"order-id\">" + _htmlEscape(l.sku) + "</code></td>" +
19696
+ "<td class=\"num\">" + _htmlEscape(String(l.quantity)) + "</td>" +
19697
+ "<td class=\"num\">" + unit + "</td>" +
19698
+ "<td class=\"num\">" + lineTotal + "</td>" +
19699
+ (l.notes ? "<td>" + _htmlEscape(l.notes) + "</td>" : "<td></td>") +
19700
+ "</tr>";
19701
+ }).join("");
19702
+ var linesPanel = "<div class=\"panel\">" +
19703
+ _tableWrap("<table><thead><tr><th scope=\"col\">SKU</th><th scope=\"col\" class=\"num\">Qty</th><th scope=\"col\" class=\"num\">Unit price</th><th scope=\"col\" class=\"num\">Line total</th><th scope=\"col\">Customer note</th></tr></thead><tbody>" + lineRows + "</tbody></table>") +
19704
+ "</div>";
19705
+
19706
+ // Respond form — only for a still-requested quote. One unit-price field per
19707
+ // line plus shipping / tax / validity. Major-unit dollar inputs (converted
19708
+ // to minor units server-side).
19709
+ var respondForm = "";
19710
+ if (q.status === "requested") {
19711
+ var priceFields = (q.lines || []).map(function (l) {
19712
+ return _setupField(l.sku + " — unit price (" + currency + ")", "price_" + l.sku, "", "text",
19713
+ "Quantity " + l.quantity + ".", " inputmode=\"decimal\" pattern=\"\\d+(\\.\\d+)?\" required");
19714
+ }).join("");
19715
+ respondForm =
19716
+ "<div class=\"panel mt mw-40\">" +
19717
+ "<h3 class=\"subhead\">Respond with a priced quote</h3>" +
19718
+ "<p class=\"meta\">Set a unit price for every line, plus optional shipping + tax and how long the quote stays valid. The customer is notified and can accept or decline.</p>" +
19719
+ "<form method=\"post\" action=\"/admin/quotes/" + enc + "/respond\">" +
19720
+ priceFields +
19721
+ _setupField("Shipping (" + currency + ")", "shipping", "", "text", "Optional. Leave blank for free shipping.", " inputmode=\"decimal\" pattern=\"\\d+(\\.\\d+)?\"") +
19722
+ _setupField("Tax (" + currency + ")", "tax", "", "text", "Optional.", " inputmode=\"decimal\" pattern=\"\\d+(\\.\\d+)?\"") +
19723
+ _setupField("Valid for (days)", "valid_days", "14", "number", "How many days the customer has to accept.", " min=\"1\" max=\"365\" required") +
19724
+ _setupField("Currency", "currency", currency, "text", "ISO-4217, e.g. USD.", " maxlength=\"3\" pattern=\"[A-Za-z]{3}\"") +
19725
+ "<label class=\"form-field\"><span>Note to the customer</span><textarea name=\"operator_notes\" maxlength=\"4000\" rows=\"3\"></textarea></label>" +
19726
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Send quote</button></div>" +
19727
+ "</form>" +
19728
+ "</div>";
19729
+ }
19730
+
19731
+ // Withdraw — available while the quote hasn't been accepted (requested or
19732
+ // responded). The FSM refuses it for accepted/terminal quotes; we only
19733
+ // render the button when it would succeed.
19734
+ var withdrawForm = "";
19735
+ if (q.status === "requested" || q.status === "responded") {
19736
+ withdrawForm =
19737
+ "<form method=\"post\" action=\"/admin/quotes/" + enc + "/withdraw\" class=\"form-inline\">" +
19738
+ "<button class=\"btn btn--danger\" type=\"submit\">Withdraw quote</button>" +
19739
+ "</form>";
19740
+ }
19741
+
19742
+ var actions = "<div class=\"actions-row\">" +
19743
+ "<a class=\"btn btn--ghost\" href=\"/admin/quotes\">Back to quotes</a>" +
19744
+ withdrawForm +
19745
+ "</div>";
19746
+
19747
+ var bodyHtml = "<section><h2>Quote " + _htmlEscape(String(q.id).slice(0, 8)) + "</h2>" +
19748
+ notice + summary + linesPanel + respondForm + actions + "</section>";
19749
+ return _renderAdminShell(opts.shop_name, "Quote " + String(q.id).slice(0, 8), bodyHtml, "quotes", opts.nav_available);
19750
+ }
19751
+
18966
19752
  function renderAdminSurveys(opts) {
18967
19753
  opts = opts || {};
18968
19754
  var rows = opts.surveys || [];