@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/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 &amp; 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 &ldquo;Request a quote&rdquo; 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.15",
3
+ "version": "0.4.17",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {