@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/CHANGELOG.md +4 -0
- package/README.md +3 -2
- package/lib/admin.js +787 -1
- package/lib/asset-manifest.json +1 -1
- package/lib/email-campaigns.js +799 -9
- package/lib/email.js +51 -0
- package/lib/quotes.js +306 -82
- package/lib/security-middleware.js +1 -0
- package/lib/storefront.js +416 -0
- package/package.json +1 -1
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
|
+
// `<`; 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) + " <" + _htmlEscape(c.from_address) + "> · " +
|
|
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\">← 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 || [];
|