@blamejs/blamejs-shop 0.4.15 → 0.4.16
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 +2 -0
- package/README.md +3 -2
- package/lib/admin.js +351 -1
- package/lib/asset-manifest.json +1 -1
- package/lib/email.js +51 -0
- package/lib/quotes.js +306 -82
- package/lib/storefront.js +416 -0
- package/package.json +1 -1
package/lib/storefront.js
CHANGED
|
@@ -8164,6 +8164,36 @@ function _cartCouponBlock(opts) {
|
|
|
8164
8164
|
"</section>";
|
|
8165
8165
|
}
|
|
8166
8166
|
|
|
8167
|
+
// CONTAINER-ONLY "Request a quote" block for the cart page. Rendered only for
|
|
8168
|
+
// a signed-in customer when the quotes primitive is wired (the route gates the
|
|
8169
|
+
// flag on both). Buying B2B quantities? Ask for a custom price instead of
|
|
8170
|
+
// checking out at list — the form snapshots the active cart into a new RFQ.
|
|
8171
|
+
// The optional message field rides along. POST /account/quotes/request is NOT
|
|
8172
|
+
// an edge-exempt prefix, so _injectCsrfFields tokens the form. A `?quote_*`
|
|
8173
|
+
// PRG notice surfaces the empty-cart / error redirect.
|
|
8174
|
+
function _cartQuoteBlock(opts) {
|
|
8175
|
+
if (!opts.can_request_quote) return "";
|
|
8176
|
+
var notice = "";
|
|
8177
|
+
if (opts.quote_empty) {
|
|
8178
|
+
notice = "<p class=\"cart-quote__notice cart-quote__notice--err\" role=\"status\">Add an item to your cart before requesting a quote.</p>";
|
|
8179
|
+
} else if (opts.quote_err) {
|
|
8180
|
+
notice = "<p class=\"cart-quote__notice cart-quote__notice--err\" role=\"status\">We couldn't start a quote from this cart. Please try again.</p>";
|
|
8181
|
+
}
|
|
8182
|
+
return "<section class=\"cart-quote\">" +
|
|
8183
|
+
"<details class=\"cart-quote__details\"" + (opts.quote_err || opts.quote_empty ? " open" : "") + ">" +
|
|
8184
|
+
"<summary class=\"cart-quote__summary\">Buying in bulk? Request a quote</summary>" +
|
|
8185
|
+
notice +
|
|
8186
|
+
"<p class=\"cart-quote__lede\">We'll review the items in your cart and reply with custom pricing you can accept online.</p>" +
|
|
8187
|
+
"<form method=\"post\" action=\"/account/quotes/request\" class=\"cart-quote__form\">" +
|
|
8188
|
+
"<label class=\"form-field\"><span>Anything we should know? (optional)</span>" +
|
|
8189
|
+
"<textarea name=\"message\" maxlength=\"4000\" rows=\"3\" placeholder=\"Quantities, timelines, delivery needs…\"></textarea>" +
|
|
8190
|
+
"</label>" +
|
|
8191
|
+
"<button type=\"submit\" class=\"btn-secondary\">Request a quote</button>" +
|
|
8192
|
+
"</form>" +
|
|
8193
|
+
"</details>" +
|
|
8194
|
+
"</section>";
|
|
8195
|
+
}
|
|
8196
|
+
|
|
8167
8197
|
function renderCart(opts) {
|
|
8168
8198
|
if (!opts) throw new TypeError("storefront.renderCart: opts required");
|
|
8169
8199
|
var lines = opts.lines || [];
|
|
@@ -8323,6 +8353,11 @@ function renderCart(opts) {
|
|
|
8323
8353
|
// code echo is escaped at the sink; appended, not String.replace'd, so a
|
|
8324
8354
|
// `$` in a typed code can't trip dollar substitution).
|
|
8325
8355
|
body = body + _cartCouponBlock(opts);
|
|
8356
|
+
// CONTAINER-ONLY "Request a quote" entry — a signed-in customer can turn
|
|
8357
|
+
// the cart into a B2B RFQ. Same append-not-replace placement + edge
|
|
8358
|
+
// reasoning; the block carries no shopper free text in its markup (the
|
|
8359
|
+
// typed message rides the POST, never the rendered page).
|
|
8360
|
+
body = body + _cartQuoteBlock(opts);
|
|
8326
8361
|
}
|
|
8327
8362
|
return _wrap(Object.assign({
|
|
8328
8363
|
title: "Cart",
|
|
@@ -9625,6 +9660,7 @@ var ACCOUNT_DASH_PAGE =
|
|
|
9625
9660
|
" <a class=\"btn-secondary\" href=\"/account/referrals\">Refer a friend</a>\n" +
|
|
9626
9661
|
" <a class=\"btn-secondary\" href=\"/account/subscriptions\">Subscriptions</a>\n" +
|
|
9627
9662
|
" RAW_PREORDER_LINK\n" +
|
|
9663
|
+
" RAW_QUOTES_LINK\n" +
|
|
9628
9664
|
// begin: profile + passkey management actions
|
|
9629
9665
|
" <a class=\"btn-secondary\" href=\"/account/profile\">Edit profile</a>\n" +
|
|
9630
9666
|
" <a class=\"btn-secondary\" href=\"/account/privacy\">Privacy & data</a>\n" +
|
|
@@ -9745,6 +9781,12 @@ function renderAccount(opts) {
|
|
|
9745
9781
|
: "")
|
|
9746
9782
|
.replace("RAW_PAYMENT_METHODS_LINK", opts.payment_methods_enabled
|
|
9747
9783
|
? "<a class=\"btn-secondary\" href=\"/account/payment-methods\">Payment methods</a>"
|
|
9784
|
+
: "")
|
|
9785
|
+
// The Quotes link renders only when the quotes primitive is wired (the
|
|
9786
|
+
// /account/quotes route is mounted), so a deploy without it never links
|
|
9787
|
+
// to a 404.
|
|
9788
|
+
.replace("RAW_QUOTES_LINK", opts.quotes_enabled
|
|
9789
|
+
? "<a class=\"btn-secondary\" href=\"/account/quotes\">Quotes</a>"
|
|
9748
9790
|
: "");
|
|
9749
9791
|
return _wrap({
|
|
9750
9792
|
title: "Account",
|
|
@@ -10241,6 +10283,151 @@ function renderSurveyPage(opts) {
|
|
|
10241
10283
|
});
|
|
10242
10284
|
}
|
|
10243
10285
|
|
|
10286
|
+
// ---- quote (token-gated / account-gated) -------------------------------
|
|
10287
|
+
|
|
10288
|
+
// One quote's status → the customer-facing label + lede shown when the quote
|
|
10289
|
+
// is not in the actionable "responded" state. Keeps the page honest about
|
|
10290
|
+
// where the negotiation stands without leaking operator-side internals.
|
|
10291
|
+
var _QUOTE_STATE_COPY = {
|
|
10292
|
+
requested: ["We're preparing your quote", "Thanks for your request. We're pricing it now and will email you when it's ready to review."],
|
|
10293
|
+
accepted: ["Quote accepted", "You've accepted this quote. We're turning it into an order — you'll hear from us shortly."],
|
|
10294
|
+
converted: ["Quote accepted", "You've accepted this quote and it's been turned into an order."],
|
|
10295
|
+
rejected: ["Quote declined", "You declined this quote. If that wasn't intended, get in touch and we'll prepare a fresh one."],
|
|
10296
|
+
expired: ["This quote has expired", "The acceptance window for this quote has passed. Request a new quote and we'll re-price it."],
|
|
10297
|
+
cancelled: ["This quote was withdrawn", "This quote is no longer available. Request a new one and we'll be happy to help."],
|
|
10298
|
+
};
|
|
10299
|
+
|
|
10300
|
+
// Render the customer quote page. `opts.state` is "view" (a responded quote
|
|
10301
|
+
// the customer can accept/decline), "notice" (any other status — copy from
|
|
10302
|
+
// _QUOTE_STATE_COPY), or "notfound". `opts.quote` is the hydrated quote;
|
|
10303
|
+
// `opts.token` (when present) drives the accept/decline form actions on the
|
|
10304
|
+
// tokened path, otherwise the account path posts to /account/quotes/:id/*.
|
|
10305
|
+
function renderQuotePage(opts) {
|
|
10306
|
+
opts = opts || {};
|
|
10307
|
+
var esc = function (s) { return b.template.escapeHtml(String(s == null ? "" : s)); };
|
|
10308
|
+
var state = opts.state || "notfound";
|
|
10309
|
+
var body;
|
|
10310
|
+
|
|
10311
|
+
if (state === "view") {
|
|
10312
|
+
var q = opts.quote || {};
|
|
10313
|
+
var currency = q.currency || "USD";
|
|
10314
|
+
var notice = opts.notice ? "<p class=\"form-notice\">" + esc(opts.notice) + "</p>" : "";
|
|
10315
|
+
var lineRows = (q.lines || []).map(function (l) {
|
|
10316
|
+
var unit = l.unit_price_minor == null ? "—" : pricing.format(l.unit_price_minor, l.currency || currency);
|
|
10317
|
+
var total = l.unit_price_minor == null ? "—" : pricing.format(l.unit_price_minor * l.quantity, l.currency || currency);
|
|
10318
|
+
return "<tr><td>" + esc(l.sku) + "</td><td class=\"num\">" + esc(String(l.quantity)) + "</td>" +
|
|
10319
|
+
"<td class=\"num\">" + esc(unit) + "</td><td class=\"num\">" + esc(total) + "</td></tr>";
|
|
10320
|
+
}).join("");
|
|
10321
|
+
var rowsHtml =
|
|
10322
|
+
"<tr><td colspan=\"3\">Subtotal</td><td class=\"num\">" +
|
|
10323
|
+
esc(pricing.format((q.total_minor || 0) - (q.shipping_minor || 0) - (q.tax_minor || 0), currency)) + "</td></tr>" +
|
|
10324
|
+
"<tr><td colspan=\"3\">Shipping</td><td class=\"num\">" + esc(pricing.format(q.shipping_minor || 0, currency)) + "</td></tr>" +
|
|
10325
|
+
"<tr><td colspan=\"3\">Tax</td><td class=\"num\">" + esc(pricing.format(q.tax_minor || 0, currency)) + "</td></tr>" +
|
|
10326
|
+
"<tr><td colspan=\"3\"><strong>Total</strong></td><td class=\"num\"><strong>" + esc(pricing.format(q.total_minor || 0, currency)) + "</strong></td></tr>";
|
|
10327
|
+
|
|
10328
|
+
var validNote = q.valid_until
|
|
10329
|
+
? "<p class=\"quote-page__valid\">This quote is valid until " + esc(new Date(Number(q.valid_until)).toUTCString()) + ".</p>"
|
|
10330
|
+
: "";
|
|
10331
|
+
var opMsg = q.operator_notes
|
|
10332
|
+
? "<div class=\"quote-page__message\"><h2>A note from us</h2><p>" + esc(q.operator_notes) + "</p></div>"
|
|
10333
|
+
: "";
|
|
10334
|
+
|
|
10335
|
+
// The accept / decline form actions. Tokened path posts to /quote/:token;
|
|
10336
|
+
// account path posts to /account/quotes/:id. Both POST forms are tokened
|
|
10337
|
+
// for CSRF by _injectCsrfFields (neither prefix is edge-exempt).
|
|
10338
|
+
var actionBase = opts.token
|
|
10339
|
+
? "/quote/" + esc(opts.token)
|
|
10340
|
+
: "/account/quotes/" + esc(q.id);
|
|
10341
|
+
var actions =
|
|
10342
|
+
"<div class=\"quote-page__actions\">" +
|
|
10343
|
+
"<form method=\"post\" action=\"" + actionBase + "/accept\" class=\"quote-action\">" +
|
|
10344
|
+
"<button class=\"btn-primary\" type=\"submit\">Accept this quote</button>" +
|
|
10345
|
+
"</form>" +
|
|
10346
|
+
"<form method=\"post\" action=\"" + actionBase + "/decline\" class=\"quote-action\">" +
|
|
10347
|
+
"<button class=\"btn-ghost\" type=\"submit\">Decline</button>" +
|
|
10348
|
+
"</form>" +
|
|
10349
|
+
"</div>";
|
|
10350
|
+
|
|
10351
|
+
body =
|
|
10352
|
+
"<section class=\"quote-page\"><div class=\"quote-page__inner\">" +
|
|
10353
|
+
"<p class=\"eyebrow\">Your quote</p>" +
|
|
10354
|
+
"<h1 class=\"quote-page__title\">Quote " + esc(String(q.id).slice(0, 8)) + "</h1>" +
|
|
10355
|
+
notice +
|
|
10356
|
+
opMsg +
|
|
10357
|
+
"<table class=\"quote-table\"><thead><tr><th scope=\"col\">Item</th><th scope=\"col\" class=\"num\">Qty</th><th scope=\"col\" class=\"num\">Unit</th><th scope=\"col\" class=\"num\">Line total</th></tr></thead>" +
|
|
10358
|
+
"<tbody>" + lineRows + "</tbody>" +
|
|
10359
|
+
"<tfoot>" + rowsHtml + "</tfoot></table>" +
|
|
10360
|
+
validNote +
|
|
10361
|
+
actions +
|
|
10362
|
+
"</div></section>";
|
|
10363
|
+
} else {
|
|
10364
|
+
var copy;
|
|
10365
|
+
if (state === "notice" && opts.quote && _QUOTE_STATE_COPY[opts.quote.status]) {
|
|
10366
|
+
copy = _QUOTE_STATE_COPY[opts.quote.status];
|
|
10367
|
+
} else {
|
|
10368
|
+
copy = ["Quote not found", "This quote link isn't valid. Check the link we sent you, or sign in to your account to view your quotes."];
|
|
10369
|
+
}
|
|
10370
|
+
body =
|
|
10371
|
+
"<section class=\"quote-page\"><div class=\"quote-page__inner quote-page__inner--msg\">" +
|
|
10372
|
+
"<h1 class=\"quote-page__title\">" + esc(copy[0]) + "</h1>" +
|
|
10373
|
+
"<p class=\"quote-page__lede\">" + esc(copy[1]) + "</p>" +
|
|
10374
|
+
"<a class=\"btn-ghost\" href=\"/\">Back to the shop</a>" +
|
|
10375
|
+
"</div></section>";
|
|
10376
|
+
}
|
|
10377
|
+
|
|
10378
|
+
return _wrap({
|
|
10379
|
+
title: opts.title || "Your quote",
|
|
10380
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
10381
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
10382
|
+
theme_css: opts.theme_css,
|
|
10383
|
+
body: body,
|
|
10384
|
+
});
|
|
10385
|
+
}
|
|
10386
|
+
|
|
10387
|
+
// Render the signed-in customer's quote list (/account/quotes). `opts.quotes`
|
|
10388
|
+
// is the hydrated quote array (newest activity first). Each row links to the
|
|
10389
|
+
// account-gated detail page.
|
|
10390
|
+
function renderQuoteList(opts) {
|
|
10391
|
+
opts = opts || {};
|
|
10392
|
+
var esc = function (s) { return b.template.escapeHtml(String(s == null ? "" : s)); };
|
|
10393
|
+
var rows = opts.quotes || [];
|
|
10394
|
+
var body;
|
|
10395
|
+
if (!rows.length) {
|
|
10396
|
+
body =
|
|
10397
|
+
"<section class=\"quote-page\"><div class=\"quote-page__inner quote-page__inner--msg\">" +
|
|
10398
|
+
"<h1 class=\"quote-page__title\">Your quotes</h1>" +
|
|
10399
|
+
"<p class=\"quote-page__lede\">You don't have any quotes yet. Build a cart and choose “Request a quote” to ask us for B2B pricing.</p>" +
|
|
10400
|
+
"<a class=\"btn-ghost\" href=\"/account\">Back to your account</a>" +
|
|
10401
|
+
"</div></section>";
|
|
10402
|
+
} else {
|
|
10403
|
+
var items = rows.map(function (q) {
|
|
10404
|
+
var currency = q.currency || "USD";
|
|
10405
|
+
var total = q.total_minor == null ? "Awaiting price" : pricing.format(q.total_minor, currency);
|
|
10406
|
+
var statusLabel = q.status === "responded" ? "ready to review" : q.status;
|
|
10407
|
+
return "<li class=\"quote-list__item\">" +
|
|
10408
|
+
"<a href=\"/account/quotes/" + esc(q.id) + "\">" +
|
|
10409
|
+
"<span class=\"quote-list__id\">Quote " + esc(String(q.id).slice(0, 8)) + "</span>" +
|
|
10410
|
+
"<span class=\"quote-list__status\">" + esc(statusLabel) + "</span>" +
|
|
10411
|
+
"<span class=\"quote-list__total\">" + esc(total) + "</span>" +
|
|
10412
|
+
"</a></li>";
|
|
10413
|
+
}).join("");
|
|
10414
|
+
body =
|
|
10415
|
+
"<section class=\"quote-page\"><div class=\"quote-page__inner\">" +
|
|
10416
|
+
"<p class=\"eyebrow\">Account</p>" +
|
|
10417
|
+
"<h1 class=\"quote-page__title\">Your quotes</h1>" +
|
|
10418
|
+
"<ul class=\"quote-list\">" + items + "</ul>" +
|
|
10419
|
+
"<a class=\"btn-ghost\" href=\"/account\">Back to your account</a>" +
|
|
10420
|
+
"</div></section>";
|
|
10421
|
+
}
|
|
10422
|
+
return _wrap({
|
|
10423
|
+
title: "Your quotes",
|
|
10424
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
10425
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
10426
|
+
theme_css: opts.theme_css,
|
|
10427
|
+
body: body,
|
|
10428
|
+
});
|
|
10429
|
+
}
|
|
10430
|
+
|
|
10244
10431
|
// ---- business-hours page -----------------------------------------------
|
|
10245
10432
|
|
|
10246
10433
|
var _DOW_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
@@ -11332,6 +11519,100 @@ function mount(router, deps) {
|
|
|
11332
11519
|
});
|
|
11333
11520
|
}
|
|
11334
11521
|
|
|
11522
|
+
// ---- quote (token-gated customer page) ------------------------------
|
|
11523
|
+
// A request-for-quote a customer can review + accept / decline via a single
|
|
11524
|
+
// capability link (/quote/:token) — no login. The token IS the access (the
|
|
11525
|
+
// signed-in owner reaches the same quote through /account/quotes). The
|
|
11526
|
+
// quotes primitive hashes the presented token and constant-time-compares it,
|
|
11527
|
+
// so a garbage / unknown token resolves to null → the not-found state.
|
|
11528
|
+
// Container-only (never edge-cached). Resilient: any read error renders a
|
|
11529
|
+
// clean state page, never a 500.
|
|
11530
|
+
if (deps.quotes) {
|
|
11531
|
+
var _quoteCtx = function (_req) {
|
|
11532
|
+
return {
|
|
11533
|
+
shop_name: (deps.config && deps.config.shop_name) || "blamejs.shop",
|
|
11534
|
+
cart_count: 0,
|
|
11535
|
+
theme_css: (deps.theme && deps.theme.assetUrl) ? deps.theme.assetUrl("css/main.css") : DEFAULT_THEME_CSS_URL,
|
|
11536
|
+
};
|
|
11537
|
+
};
|
|
11538
|
+
|
|
11539
|
+
// Map a hydrated quote to the page state: "view" when the customer can act
|
|
11540
|
+
// on it (responded + not yet expired), otherwise "notice" (status copy).
|
|
11541
|
+
var _quotePageState = function (quote) {
|
|
11542
|
+
if (!quote) return "notfound";
|
|
11543
|
+
if (quote.status === "responded") {
|
|
11544
|
+
if (quote.valid_until != null && Number(quote.valid_until) <= Date.now()) return "notice";
|
|
11545
|
+
return "view";
|
|
11546
|
+
}
|
|
11547
|
+
return "notice";
|
|
11548
|
+
};
|
|
11549
|
+
|
|
11550
|
+
router.get("/quote/:token", async function (req, res) {
|
|
11551
|
+
var token = (req.params && req.params.token) || "";
|
|
11552
|
+
var ctx = _quoteCtx(req);
|
|
11553
|
+
var quote = null;
|
|
11554
|
+
try { quote = await deps.quotes.getByViewToken(token); }
|
|
11555
|
+
catch (_e) { quote = null; }
|
|
11556
|
+
var state = _quotePageState(quote);
|
|
11557
|
+
_send(res, state === "notfound" ? 404 : 200, renderQuotePage({
|
|
11558
|
+
state: state, quote: quote, token: token,
|
|
11559
|
+
shop_name: ctx.shop_name, cart_count: ctx.cart_count, theme_css: ctx.theme_css,
|
|
11560
|
+
}));
|
|
11561
|
+
});
|
|
11562
|
+
|
|
11563
|
+
// Accept / decline a quote via the token. The action segment is the last
|
|
11564
|
+
// path token; both verbs replay the quote FSM (accept refuses on an
|
|
11565
|
+
// expired / non-responded quote with a coded error → mapped to the
|
|
11566
|
+
// matching state notice). On a successful accept, when the order +
|
|
11567
|
+
// conversion machinery is wired (deps.convertQuoteToOrder), the quote is
|
|
11568
|
+
// converted into a pending order here so the holds land at acceptance.
|
|
11569
|
+
var _quoteTokenAction = function (action) {
|
|
11570
|
+
return async function (req, res) {
|
|
11571
|
+
var token = (req.params && req.params.token) || "";
|
|
11572
|
+
var ctx = _quoteCtx(req);
|
|
11573
|
+
var quote = null;
|
|
11574
|
+
try { quote = await deps.quotes.getByViewToken(token); }
|
|
11575
|
+
catch (_e) { quote = null; }
|
|
11576
|
+
if (!quote) {
|
|
11577
|
+
return _send(res, 404, renderQuotePage({ state: "notfound", token: token, shop_name: ctx.shop_name, theme_css: ctx.theme_css }));
|
|
11578
|
+
}
|
|
11579
|
+
try {
|
|
11580
|
+
if (action === "accept") {
|
|
11581
|
+
await deps.quotes.customerAccept({ quote_id: quote.id, accepted_by_customer: "customer" });
|
|
11582
|
+
if (typeof deps.convertQuoteToOrder === "function") {
|
|
11583
|
+
try { await deps.convertQuoteToOrder(quote.id); }
|
|
11584
|
+
catch (_eConv) { /* drop-silent — acceptance stands; the operator converts from the console */ }
|
|
11585
|
+
}
|
|
11586
|
+
} else {
|
|
11587
|
+
await deps.quotes.customerReject({ quote_id: quote.id });
|
|
11588
|
+
}
|
|
11589
|
+
} catch (e) {
|
|
11590
|
+
// FSM refusal (already accepted / expired / withdrawn) → re-render
|
|
11591
|
+
// the current state honestly; a validation TypeError → the same.
|
|
11592
|
+
var fresh = null;
|
|
11593
|
+
try { fresh = await deps.quotes.getByViewToken(token); } catch (_e2) { fresh = quote; }
|
|
11594
|
+
var st = _quotePageState(fresh);
|
|
11595
|
+
if (e instanceof TypeError && st === "view") {
|
|
11596
|
+
return _send(res, 400, renderQuotePage({
|
|
11597
|
+
state: "view", quote: fresh, token: token,
|
|
11598
|
+
notice: "We couldn't process that — please try again.",
|
|
11599
|
+
shop_name: ctx.shop_name, theme_css: ctx.theme_css,
|
|
11600
|
+
}));
|
|
11601
|
+
}
|
|
11602
|
+
return _send(res, 200, renderQuotePage({ state: st, quote: fresh, token: token, shop_name: ctx.shop_name, theme_css: ctx.theme_css }));
|
|
11603
|
+
}
|
|
11604
|
+
var after = null;
|
|
11605
|
+
try { after = await deps.quotes.getByViewToken(token); } catch (_e3) { after = null; }
|
|
11606
|
+
_send(res, 200, renderQuotePage({
|
|
11607
|
+
state: "notice", quote: after, token: token,
|
|
11608
|
+
shop_name: ctx.shop_name, theme_css: ctx.theme_css,
|
|
11609
|
+
}));
|
|
11610
|
+
};
|
|
11611
|
+
};
|
|
11612
|
+
router.post("/quote/:token/accept", _quoteTokenAction("accept"));
|
|
11613
|
+
router.post("/quote/:token/decline", _quoteTokenAction("decline"));
|
|
11614
|
+
}
|
|
11615
|
+
|
|
11335
11616
|
// ---- business hours (public /hours page) ----------------------------
|
|
11336
11617
|
// Lists every active schedule with its weekly grid + a live open/closed
|
|
11337
11618
|
// status computed at request time in the schedule's timezone. Container-
|
|
@@ -13214,6 +13495,12 @@ function mount(router, deps) {
|
|
|
13214
13495
|
// the inline PRG banner.
|
|
13215
13496
|
coupon_enabled: !!(deps.autoDiscount && typeof deps.cart.listDiscountCodes === "function"),
|
|
13216
13497
|
applied_codes: appliedCodes,
|
|
13498
|
+
// "Request a quote" entry — only for a signed-in customer (the request
|
|
13499
|
+
// route pins the quote to their customer_id) when the quotes primitive
|
|
13500
|
+
// is wired. A guest sees no quote CTA (they can't own a quote).
|
|
13501
|
+
// `_currentCustomerEnv` is the mount-scope resolver (`_currentCustomer`
|
|
13502
|
+
// lives inside the customers block, out of reach here).
|
|
13503
|
+
can_request_quote: !!(deps.quotes && _currentCustomerEnv(req)),
|
|
13217
13504
|
shop_name: shopName,
|
|
13218
13505
|
theme: theme,
|
|
13219
13506
|
}, ccy, extraOpts)));
|
|
@@ -13239,6 +13526,9 @@ function mount(router, deps) {
|
|
|
13239
13526
|
code_applied: _cartQp("code_applied"),
|
|
13240
13527
|
code_removed: _cartQp("code_removed"),
|
|
13241
13528
|
code_err: _cartQp("code_err"),
|
|
13529
|
+
// Quote-request PRG outcomes (set by POST /account/quotes/request).
|
|
13530
|
+
quote_empty: _cartQp("quote_empty"),
|
|
13531
|
+
quote_err: _cartQp("quote_err"),
|
|
13242
13532
|
});
|
|
13243
13533
|
});
|
|
13244
13534
|
|
|
@@ -14955,6 +15245,7 @@ function mount(router, deps) {
|
|
|
14955
15245
|
preorders_enabled: !!preorder,
|
|
14956
15246
|
pickups_enabled: !!deps.clickAndCollect,
|
|
14957
15247
|
payment_methods_enabled: !!(deps.paymentMethods && deps.payment),
|
|
15248
|
+
quotes_enabled: !!deps.quotes,
|
|
14958
15249
|
shop_name: shopName,
|
|
14959
15250
|
cart_count: cartCount,
|
|
14960
15251
|
}));
|
|
@@ -15002,6 +15293,131 @@ function mount(router, deps) {
|
|
|
15002
15293
|
});
|
|
15003
15294
|
}
|
|
15004
15295
|
|
|
15296
|
+
// ---- account quotes (signed-in owner view) -----------------------
|
|
15297
|
+
// The signed-in customer's request-for-quote surface: a list of their
|
|
15298
|
+
// quotes, the per-quote detail (with accept / decline for a responded
|
|
15299
|
+
// quote), and a "request a quote" action that snapshots their active cart
|
|
15300
|
+
// into a new RFQ. Access is pinned to the session customer_id — a quote
|
|
15301
|
+
// belonging to another customer 404s (never leaks). Mounts only when the
|
|
15302
|
+
// quotes primitive is wired.
|
|
15303
|
+
if (deps.quotes) {
|
|
15304
|
+
var _quoteThemeCss = function () {
|
|
15305
|
+
return (deps.theme && deps.theme.assetUrl) ? deps.theme.assetUrl("css/main.css") : DEFAULT_THEME_CSS_URL;
|
|
15306
|
+
};
|
|
15307
|
+
|
|
15308
|
+
// Resolve a quote BY ID, but only if it belongs to this customer.
|
|
15309
|
+
// Returns null otherwise (the route maps null to a 404), so a customer
|
|
15310
|
+
// can never read or act on someone else's quote by guessing the id.
|
|
15311
|
+
async function _ownedQuote(customerId, quoteId) {
|
|
15312
|
+
var q = null;
|
|
15313
|
+
try { q = await deps.quotes.getQuote(quoteId); }
|
|
15314
|
+
catch (e) { if (e instanceof TypeError) return null; throw e; }
|
|
15315
|
+
if (!q || q.customer_id !== customerId) return null;
|
|
15316
|
+
return q;
|
|
15317
|
+
}
|
|
15318
|
+
|
|
15319
|
+
router.get("/account/quotes", async function (req, res) {
|
|
15320
|
+
var auth = _accountAuth(req, res);
|
|
15321
|
+
if (!auth) return;
|
|
15322
|
+
var rows = [];
|
|
15323
|
+
try { rows = await deps.quotes.quotesForCustomer(auth.customer_id, { limit: 100 }); }
|
|
15324
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; }
|
|
15325
|
+
var count = await _cartCountForReq(req);
|
|
15326
|
+
_send(res, 200, renderQuoteList({
|
|
15327
|
+
quotes: rows, shop_name: shopName, cart_count: count, theme_css: _quoteThemeCss(),
|
|
15328
|
+
}));
|
|
15329
|
+
});
|
|
15330
|
+
|
|
15331
|
+
router.get("/account/quotes/:id", async function (req, res) {
|
|
15332
|
+
var auth = _accountAuth(req, res);
|
|
15333
|
+
if (!auth) return;
|
|
15334
|
+
var q = await _ownedQuote(auth.customer_id, req.params && req.params.id);
|
|
15335
|
+
var count = await _cartCountForReq(req);
|
|
15336
|
+
if (!q) {
|
|
15337
|
+
return _send(res, 404, renderQuotePage({ state: "notfound", shop_name: shopName, cart_count: count, theme_css: _quoteThemeCss() }));
|
|
15338
|
+
}
|
|
15339
|
+
var state = "notice";
|
|
15340
|
+
if (q.status === "responded") {
|
|
15341
|
+
state = (q.valid_until != null && Number(q.valid_until) <= Date.now()) ? "notice" : "view";
|
|
15342
|
+
}
|
|
15343
|
+
_send(res, state === "notfound" ? 404 : 200, renderQuotePage({
|
|
15344
|
+
state: state, quote: q, shop_name: shopName, cart_count: count, theme_css: _quoteThemeCss(),
|
|
15345
|
+
}));
|
|
15346
|
+
});
|
|
15347
|
+
|
|
15348
|
+
// Accept / decline via the account path. Owner-gated; replays the FSM
|
|
15349
|
+
// (a non-responded / expired quote refuses → honest re-render). A
|
|
15350
|
+
// successful accept converts to a pending order when the conversion
|
|
15351
|
+
// machinery is wired, so the holds land at acceptance.
|
|
15352
|
+
var _accountQuoteAction = function (action) {
|
|
15353
|
+
return async function (req, res) {
|
|
15354
|
+
var auth = _accountAuth(req, res);
|
|
15355
|
+
if (!auth) return;
|
|
15356
|
+
var q = await _ownedQuote(auth.customer_id, req.params && req.params.id);
|
|
15357
|
+
var count = await _cartCountForReq(req);
|
|
15358
|
+
if (!q) {
|
|
15359
|
+
return _send(res, 404, renderQuotePage({ state: "notfound", shop_name: shopName, cart_count: count, theme_css: _quoteThemeCss() }));
|
|
15360
|
+
}
|
|
15361
|
+
try {
|
|
15362
|
+
if (action === "accept") {
|
|
15363
|
+
await deps.quotes.customerAccept({ quote_id: q.id, accepted_by_customer: auth.customer_id });
|
|
15364
|
+
if (typeof deps.convertQuoteToOrder === "function") {
|
|
15365
|
+
try { await deps.convertQuoteToOrder(q.id); }
|
|
15366
|
+
catch (_eConv) { /* drop-silent — acceptance stands; operator converts from the console */ }
|
|
15367
|
+
}
|
|
15368
|
+
} else {
|
|
15369
|
+
await deps.quotes.customerReject({ quote_id: q.id });
|
|
15370
|
+
}
|
|
15371
|
+
} catch (e) {
|
|
15372
|
+
if (!(e instanceof TypeError) && !(e && e.code === "QUOTE_TRANSITION_REFUSED") && !(e && e.code === "QUOTE_EXPIRED")) throw e;
|
|
15373
|
+
// refused — fall through to re-render the now-current state.
|
|
15374
|
+
}
|
|
15375
|
+
var after = await _ownedQuote(auth.customer_id, q.id);
|
|
15376
|
+
var st = "notice";
|
|
15377
|
+
if (after && after.status === "responded") {
|
|
15378
|
+
st = (after.valid_until != null && Number(after.valid_until) <= Date.now()) ? "notice" : "view";
|
|
15379
|
+
}
|
|
15380
|
+
_send(res, 200, renderQuotePage({ state: st, quote: after, shop_name: shopName, cart_count: count, theme_css: _quoteThemeCss() }));
|
|
15381
|
+
};
|
|
15382
|
+
};
|
|
15383
|
+
router.post("/account/quotes/:id/accept", _accountQuoteAction("accept"));
|
|
15384
|
+
router.post("/account/quotes/:id/decline", _accountQuoteAction("decline"));
|
|
15385
|
+
|
|
15386
|
+
// Request a quote from the signed-in customer's active cart. Snapshots
|
|
15387
|
+
// the cart's lines into a new RFQ (the quotes primitive aggregates by
|
|
15388
|
+
// SKU), pins it to the session customer, then redirects to the new
|
|
15389
|
+
// quote's account page. An empty / missing cart re-renders the cart
|
|
15390
|
+
// with a notice. The optional `message` field rides along.
|
|
15391
|
+
router.post("/account/quotes/request", async function (req, res) {
|
|
15392
|
+
var auth = _accountAuth(req, res);
|
|
15393
|
+
if (!auth) return;
|
|
15394
|
+
var sid = _readSidCookie(req);
|
|
15395
|
+
var cart = sid ? await deps.cart.bySession(sid) : null;
|
|
15396
|
+
var lines = cart ? await deps.cart.listLines(cart.id) : [];
|
|
15397
|
+
if (!cart || !lines.length) {
|
|
15398
|
+
res.status(303); res.setHeader && res.setHeader("location", "/cart?quote_empty=1");
|
|
15399
|
+
return res.end ? res.end() : res.send("");
|
|
15400
|
+
}
|
|
15401
|
+
var body = req.body || {};
|
|
15402
|
+
var message = typeof body.message === "string" && body.message.trim().length ? body.message.trim() : null;
|
|
15403
|
+
var created;
|
|
15404
|
+
try {
|
|
15405
|
+
created = await deps.quotes.requestQuote({
|
|
15406
|
+
customer_id: auth.customer_id,
|
|
15407
|
+
cart_id: cart.id,
|
|
15408
|
+
message: message,
|
|
15409
|
+
});
|
|
15410
|
+
} catch (e) {
|
|
15411
|
+
if (!(e instanceof TypeError) && !(e && e.code === "QUOTE_CART_NOT_FOUND")) throw e;
|
|
15412
|
+
res.status(303); res.setHeader && res.setHeader("location", "/cart?quote_err=1");
|
|
15413
|
+
return res.end ? res.end() : res.send("");
|
|
15414
|
+
}
|
|
15415
|
+
res.status(303);
|
|
15416
|
+
res.setHeader && res.setHeader("location", "/account/quotes/" + created.id);
|
|
15417
|
+
return res.end ? res.end() : res.send("");
|
|
15418
|
+
});
|
|
15419
|
+
}
|
|
15420
|
+
|
|
15005
15421
|
router.post("/account/logout", function (req, res) {
|
|
15006
15422
|
_clearAuthCookie(req, res);
|
|
15007
15423
|
res.status(303); res.setHeader && res.setHeader("location", "/");
|
package/package.json
CHANGED