@metron-labs/quote-react 0.1.43 → 0.1.44

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.
@@ -166,8 +166,11 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
166
166
  const hash = window.location.hash;
167
167
  if (hash === "#quote-request")
168
168
  setStage("basket");
169
- else if (hash === "#matched-products" && scheduleResult)
169
+ else if (hash === "#matched-products" && scheduleResult) {
170
+ // Opening an entry tab after browser-back clears the mode; results only render in schedule mode.
171
+ setEntryMode("schedule");
170
172
  setStage("results");
173
+ }
171
174
  else
172
175
  setStage("entry");
173
176
  // Turbo's popstate listener (registered before ours, so it can't be pre-empted) stamps its
@@ -256,6 +259,10 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
256
259
  const lines = job.result.lineItems.length;
257
260
  const matched = job.result.results.filter((result) => result.candidates.length > 0).length;
258
261
  track("results_shown", { lines, matched, unmatched: lines - matched });
262
+ // A new schedule is a new quote. Browser-back to the entry screen only swaps the
263
+ // screen, so the previous quote's basket is still here - drop it.
264
+ clearCart();
265
+ setCartSeeded(false);
259
266
  setScheduleReviewGateComplete(false);
260
267
  setScheduleResult(job.result);
261
268
  writeQuoteHistory("results");
@@ -270,7 +277,7 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
270
277
  schedulePollCancelRef.current?.();
271
278
  schedulePollCancelRef.current = null;
272
279
  }
273
- }, [writeQuoteHistory, track]);
280
+ }, [writeQuoteHistory, track, clearCart]);
274
281
  // Entry-page "paste your line items" shortcut: skip the upload step entirely,
275
282
  // wrap the pasted text as a .txt — verbatim, so the copy archived in blob storage reads
276
283
  // exactly as pasted — and feed it straight into the same match pipeline.
@@ -9,6 +9,7 @@ import { getCartSubtotal, getPricingBreakdown } from "../../util/pricing";
9
9
  import { exportBasketExcel } from "../../util/exportScheduleExcel";
10
10
  import { renderDisplayUnit, unitDisplayText } from "../components/UnitDisplay";
11
11
  import { clientHeaders, getBoqClientId, readJson } from "../../lib/api/boqClient";
12
+ import { MAX_ORDER_QTY } from "../../lib/boq/inputLimits";
12
13
  // ponytail: flat UK VAT rate, matching fmtCurrency's GBP-only assumption elsewhere in this app.
13
14
  const VAT_RATE = 0.2;
14
15
  const PAGE_SIZE = 10;
@@ -128,11 +129,11 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
128
129
  function commitRequestedQty(lineId, raw) {
129
130
  const n = parseFloat(raw);
130
131
  if (!isNaN(n) && n >= 0)
131
- onUpdateQty(lineId, n);
132
+ onUpdateQty(lineId, Math.min(n, MAX_ORDER_QTY));
132
133
  clearQtyDraft(lineId);
133
134
  }
134
135
  function stepRequestedQty(lineId, current, delta) {
135
- onUpdateQty(lineId, Math.max(0, current + delta));
136
+ onUpdateQty(lineId, Math.min(MAX_ORDER_QTY, Math.max(0, current + delta)));
136
137
  clearQtyDraft(lineId);
137
138
  }
138
139
  function clearSellUnitsDraft(lineId) {
@@ -145,11 +146,11 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
145
146
  function commitSellUnits(lineId, raw) {
146
147
  const n = parseFloat(raw);
147
148
  if (!isNaN(n) && n >= 0)
148
- onUpdateSellUnits(lineId, n);
149
+ onUpdateSellUnits(lineId, Math.min(n, MAX_ORDER_QTY));
149
150
  clearSellUnitsDraft(lineId);
150
151
  }
151
152
  function stepSellUnits(lineId, current, delta) {
152
- onUpdateSellUnits(lineId, Math.max(0, current + delta));
153
+ onUpdateSellUnits(lineId, Math.min(MAX_ORDER_QTY, Math.max(0, current + delta)));
153
154
  clearSellUnitsDraft(lineId);
154
155
  }
155
156
  // Sends the basket to the merchant as a quote request (POST /v1/quote-requests). Success only
@@ -229,11 +230,15 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
229
230
  if (quoteRequestSent) {
230
231
  return (_jsx("div", { className: "wizard-step entry-stage", children: _jsxs("div", { className: "quote-request-sent", role: "status", children: [_jsx("div", { className: "quote-request-sent-icon", "aria-hidden": "true", children: _jsx("svg", { width: "40", height: "40", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("polyline", { points: "5 12.5 10 17.5 19 7" }) }) }), _jsx("h1", { children: "Quote requested" }), _jsxs("p", { children: ["You're all done. We'll email your sharper price to ", _jsx("strong", { children: reference.trim() }), ", usually within one working day."] }), _jsx("button", { type: "button", className: "cta-btn", onClick: onStartNewQuote, children: _jsx("span", { children: "Start a new quote" }) }), _jsx("p", { className: "quote-request-sent-note", children: "Nothing is charged until you approve the quote." })] }) }));
231
232
  }
233
+ // A customer's empty basket gets the same full-page treatment as the confirmation above.
234
+ if (!internal && items.length === 0) {
235
+ return (_jsx("div", { className: "wizard-step entry-stage", children: _jsxs("div", { className: "quote-request-sent", children: [_jsx("div", { className: "quote-request-sent-icon", "aria-hidden": "true", children: _jsxs("svg", { width: "40", height: "40", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M3 9h18l-2 11H5z" }), _jsx("path", { d: "M8 9l4-6 4 6" })] }) }), _jsx("h1", { children: qzaBasketLayout ? "Your quote request is empty" : "Your basket is empty" }), _jsx("p", { children: qzaBasketLayout ? "Start again with your list to build your quote." : "Start again with your list to fill your basket." }), _jsx("button", { type: "button", className: "cta-btn", onClick: onStartNewQuote, children: _jsx("span", { children: "Back to start" }) })] }) }));
236
+ }
232
237
  return (_jsx("div", { className: `wizard-step entry-stage${qzaBasketLayout ? " basket-layout--qza6-mra4" : ""}`, children: _jsxs("div", { className: `quick-order-card${internal ? "" : " quick-order-card--customer"}`, children: [_jsxs("div", { className: `quick-order-hero${internal ? "" : " quick-order-hero--customer"}`, children: [internal && _jsx("div", { className: "quick-order-eyebrow", children: "Construction Services" }), _jsxs("div", { className: "basket-hero-copy", children: [qzaBasketLayout && (_jsxs("button", { type: "button", className: "cta-btn cta-btn-secondary basket-hero-back", onClick: onBack, children: [_jsx(BackArrow, {}), _jsx("span", { children: "Back to results" })] })), _jsxs("div", { className: "quick-order-hero-title-row", children: [_jsx("h1", { children: internal ? "Your Selection" : qzaBasketLayout ? "Request a quote" : "Your Basket" }), internal && (_jsxs("span", { className: "summary-chip summary-chip--scope", children: [_jsx("strong", { children: items.length }), " ", items.length === 1 ? "item" : "items"] }))] }), _jsx("p", { children: internal
233
238
  ? "Review the items you've added before exporting them for review."
234
239
  : qzaBasketLayout
235
240
  ? "Check your items & quantities below, and we'll do our best to beat list prices."
236
- : "Review your quantities and order summary before you buy." })] })] }), _jsx("div", { className: "quick-order-panel", style: { paddingTop: 16 }, children: items.length === 0 ? (_jsxs("div", { className: "quick-order-history-empty", children: [_jsx("div", { className: "basket-empty-icon", "aria-hidden": "true", children: internal ? "📋" : "🧺" }), _jsx("p", { children: internal ? "Your selection is empty." : qzaBasketLayout ? "Your quote request is empty." : "Your basket is empty." }), _jsx("p", { className: "basket-empty-hint", children: qzaBasketLayout ? "Go back to your results and add items for your quote." : "Go back to your results and add quoted items." }), _jsx("div", { className: "btn-row", style: { marginTop: 20, justifyContent: "center" }, children: _jsx("button", { className: "cta-btn", style: { width: "auto", maxWidth: 280 }, onClick: onBack, "aria-label": "Back to results", children: _jsx("span", { children: "Back to Results" }) }) })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "basket-layout", children: [_jsxs("div", { className: "basket-main", children: [_jsx("div", { className: "basket-list", "aria-label": internal ? "Selection items" : qzaBasketLayout ? "Quote request items" : "Basket items", children: pagedItems.map(({ item, pricing }) => {
241
+ : "Review your quantities and order summary before you buy." })] })] }), _jsx("div", { className: "quick-order-panel", style: { paddingTop: 16 }, children: items.length === 0 ? (_jsxs("div", { className: "quick-order-history-empty", children: [_jsx("div", { className: "basket-empty-icon", "aria-hidden": "true", children: "\uD83D\uDCCB" }), _jsx("p", { children: "Your selection is empty." }), _jsx("p", { className: "basket-empty-hint", children: "Go back to your results and add quoted items." }), _jsx("div", { className: "btn-row", style: { marginTop: 20, justifyContent: "center" }, children: _jsx("button", { className: "cta-btn", style: { width: "auto", maxWidth: 280 }, onClick: onBack, "aria-label": "Back to results", children: _jsx("span", { children: "Back to Results" }) }) })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "basket-layout", children: [_jsxs("div", { className: "basket-main", children: [_jsx("div", { className: "basket-list", "aria-label": internal ? "Selection items" : qzaBasketLayout ? "Quote request items" : "Basket items", children: pagedItems.map(({ item, pricing }) => {
237
242
  const requestedQtyValue = qtyDrafts[item.lineId] ?? String(pricing.requestedAmount);
238
243
  const requestedLocked = pricing.requestedUnit === pricing.sellUnit || pricing.costingMode === "review";
239
244
  const rawDiffersFromOrder = pricing.requiredSellUnits == null || pricing.requestedAmount !== pricing.requiredSellUnits || pricing.requestedUnit !== pricing.sellUnit;
@@ -253,7 +258,7 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
253
258
  if (e.key === "Enter")
254
259
  e.target.blur();
255
260
  }, "aria-label": `${item.description} order quantity` }), _jsxs("div", { className: "basket-qty-arrows", children: [_jsx("button", { type: "button", className: "basket-qty-arrow", onClick: () => stepSellUnits(item.lineId, parseFloat(sellUnitsDrafts[item.lineId] ?? String(pricing.requiredSellUnits ?? "0")) || 0, 1), "aria-label": `Increase ${item.description} order quantity`, children: _jsx(ChevronIcon, {}) }), _jsx("button", { type: "button", className: "basket-qty-arrow basket-qty-arrow--down", onClick: () => stepSellUnits(item.lineId, parseFloat(sellUnitsDrafts[item.lineId] ?? String(pricing.requiredSellUnits ?? "0")) || 0, -1), "aria-label": `Decrease ${item.description} order quantity`, children: _jsx(ChevronIcon, {}) })] })] }), _jsx("span", { className: "basket-metric-unit", children: renderDisplayUnit(pricing.sellUnit, parseFloat(sellUnitsDrafts[item.lineId] ?? String(pricing.requiredSellUnits ?? "0")) || 0) })] })] }), _jsxs("div", { className: "basket-stat basket-stat--total", children: [_jsx("span", { className: "basket-stat-label", children: "Total" }), _jsxs("div", { className: "basket-stat-value", children: [_jsx("strong", { children: pricing.lineTotal != null ? fmtCurrency(pricing.lineTotal) : "-" }), pricing.lineTotal != null && _jsx("span", { className: "basket-metric-unit", children: "ex. VAT" })] })] })] })] }, item.lineId));
256
- }) }), totalPages > 1 && (_jsxs("div", { className: "results-pagination", children: [_jsxs("span", { className: "results-pagination-count", children: ["Showing ", (currentPage - 1) * PAGE_SIZE + 1, "\u2013", Math.min(currentPage * PAGE_SIZE, pricedItems.length), " of ", pricedItems.length] }), _jsxs("div", { className: "results-pagination-controls", children: [_jsx("button", { type: "button", className: "bom-toolbar-btn", onClick: () => setPage(currentPage - 1), disabled: currentPage === 1, children: "Previous" }), _jsxs("span", { className: "results-pagination-page", children: ["Page ", currentPage, " of ", totalPages] }), _jsx("button", { type: "button", className: "bom-toolbar-btn", onClick: () => setPage(currentPage + 1), disabled: currentPage === totalPages, children: "Next" })] })] }))] }), _jsxs("aside", { className: "basket-sidebar", children: [_jsx("div", { className: "basket-sidebar-title", children: qzaBasketLayout ? "Quote request summary" : "Order summary" }), _jsxs("div", { className: "basket-sidebar-row", children: [_jsx("span", { children: qzaBasketLayout ? "List price (ex. VAT)" : "Subtotal (ex. VAT)" }), _jsx("span", { children: fmtCurrency(subtotal) })] }), _jsxs("div", { className: "basket-sidebar-row", children: [_jsx("span", { children: qzaBasketLayout ? "VAT (20%)" : "VAT (20%)" }), _jsx("span", { children: fmtCurrency(vat) })] }), _jsxs("div", { className: "basket-sidebar-row basket-sidebar-row--total", children: [_jsx("span", { children: qzaBasketLayout ? "List price (inc. VAT)" : "Total" }), _jsx("span", { children: fmtCurrency(total) })] }), !qzaBasketLayout && (_jsx("div", { className: "basket-summary-waste", children: _jsxs("div", { className: "basket-waste-control", children: [_jsxs("label", { className: "basket-global-waste", children: [_jsx("input", { type: "checkbox", checked: wasteEnabled, onChange: (e) => setWasteEnabled(e.target.checked) }), _jsx("span", { children: "Add waste factor" })] }), _jsxs("label", { className: "basket-global-waste-percent", children: [_jsx("input", { type: "number", inputMode: "decimal", min: "0", step: "0.1", value: wastePercent, onChange: (e) => setWastePercent(e.target.value), disabled: !wasteEnabled, "aria-label": "Waste factor percent" }), _jsx("span", { children: "%" })] })] }) })), _jsx("label", { htmlFor: `${uid}-basket-reference`, className: "basket-sidebar-label", children: qzaBasketLayout ? "Enter email for your sharper quote" : "Your reference (optional)" }), _jsx("input", { id: `${uid}-basket-reference`, type: qzaBasketLayout ? "email" : "text", className: "basket-sidebar-input", value: reference, onChange: (e) => {
261
+ }) }), totalPages > 1 && (_jsxs("div", { className: "results-pagination", children: [_jsxs("span", { className: "results-pagination-count", children: ["Showing ", (currentPage - 1) * PAGE_SIZE + 1, "\u2013", Math.min(currentPage * PAGE_SIZE, pricedItems.length), " of ", pricedItems.length] }), _jsxs("div", { className: "results-pagination-controls", children: [_jsx("button", { type: "button", className: "bom-toolbar-btn", onClick: () => setPage(currentPage - 1), disabled: currentPage === 1, children: "Previous" }), _jsxs("span", { className: "results-pagination-page", children: ["Page ", currentPage, " of ", totalPages] }), _jsx("button", { type: "button", className: "bom-toolbar-btn", onClick: () => setPage(currentPage + 1), disabled: currentPage === totalPages, children: "Next" })] })] }))] }), _jsxs("aside", { className: "basket-sidebar", children: [_jsx("div", { className: "basket-sidebar-title", children: qzaBasketLayout ? "Quote request summary" : "Order summary" }), _jsxs("div", { className: "basket-sidebar-row", children: [_jsx("span", { children: qzaBasketLayout ? "List price (ex. VAT)" : "Subtotal (ex. VAT)" }), _jsx("span", { children: fmtCurrency(subtotal) })] }), _jsxs("div", { className: "basket-sidebar-row", children: [_jsx("span", { children: qzaBasketLayout ? "VAT (20%)" : "VAT (20%)" }), _jsx("span", { children: fmtCurrency(vat) })] }), _jsxs("div", { className: "basket-sidebar-row basket-sidebar-row--total", children: [_jsx("span", { children: qzaBasketLayout ? "List price (inc. VAT)" : "Total" }), _jsx("span", { children: fmtCurrency(total) })] }), !qzaBasketLayout && (_jsx("div", { className: "basket-summary-waste", children: _jsxs("div", { className: "basket-waste-control", children: [_jsxs("label", { className: "basket-global-waste", children: [_jsx("input", { type: "checkbox", checked: wasteEnabled, onChange: (e) => setWasteEnabled(e.target.checked) }), _jsx("span", { children: "Add waste factor" })] }), _jsxs("label", { className: "basket-global-waste-percent", children: [_jsx("input", { type: "number", inputMode: "decimal", min: "0", step: "0.1", value: wastePercent, onChange: (e) => setWastePercent(e.target.value), disabled: !wasteEnabled, "aria-label": "Waste factor percent" }), _jsx("span", { children: "%" })] })] }) })), _jsx("label", { htmlFor: `${uid}-basket-reference`, className: "basket-sidebar-label", children: qzaBasketLayout ? "Enter email for your sharper quote" : "Your reference (optional)" }), _jsx("input", { id: `${uid}-basket-reference`, type: qzaBasketLayout ? "email" : "text", className: "basket-sidebar-input", value: reference, onChange: (e) => {
257
262
  setReference(e.target.value);
258
263
  if (qzaBasketLayout)
259
264
  setQuoteContactError("");