@metron-labs/quote-react 0.1.46 → 0.1.48

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.
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useState, useRef, useEffect, useCallback } from "react";
4
- import { apiUrl, getQuoteConfig } from "../lib/config";
4
+ import { getQuoteConfig } from "../lib/config";
5
5
  import { TrackContext, useTracker } from "../lib/analytics";
6
6
  import { submitSchedule, pollScheduleJob } from "../lib/api/fetchSchedule";
7
7
  import { pollJob } from "../lib/api/pollJob";
@@ -10,8 +10,6 @@ import { ENTRY_DRAFT_KEY } from "./stages/EntryStage";
10
10
  import ScheduleResultsStage from "./stages/ScheduleResultsStage";
11
11
  import BasketStage from "./stages/BasketStage";
12
12
  import { CartProvider, useCart } from "../lib/cart/CartContext";
13
- import { clientHeaders, readJson } from "../lib/api/boqClient";
14
- import { getPricingBreakdown } from "../util/pricing";
15
13
  const SAVED_QUOTE_KEY = "metron-quick-quote";
16
14
  // Its own provider, separate from the estimating tools' - a quote in progress and a set
17
15
  // of calculator results are different baskets.
@@ -39,82 +37,11 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
39
37
  }
40
38
  catch { }
41
39
  };
42
- const resolvedConversionInputs = useRef(new Map());
43
40
  const rootRef = useRef(null);
44
41
  const track = useTracker("quick-quote", rootRef);
45
42
  useEffect(() => {
46
43
  track("viewed");
47
44
  }, [track]);
48
- useEffect(() => {
49
- for (const item of Object.values(cart)) {
50
- if (getPricingBreakdown(item).requiredSellUnits != null)
51
- resolvedConversionInputs.current.delete(item.lineId);
52
- }
53
- const unresolved = Object.values(cart).filter((item) => {
54
- if (item.productId === "-" || getPricingBreakdown(item).requiredSellUnits != null)
55
- return false;
56
- const input = `${item.productId}|${item.qty}|${item.unit}`;
57
- if (resolvedConversionInputs.current.get(item.lineId) === input)
58
- return false;
59
- resolvedConversionInputs.current.set(item.lineId, input);
60
- return true;
61
- });
62
- if (unresolved.length === 0)
63
- return;
64
- void fetch(apiUrl("/api/catalogue/resolve-conversions"), {
65
- method: "POST",
66
- headers: { "Content-Type": "application/json", ...clientHeaders() },
67
- body: JSON.stringify({
68
- lines: unresolved.map((item) => ({
69
- line_id: item.lineId,
70
- product_id: item.productId,
71
- requested_description: item.requestedDescription ?? item.description,
72
- requested_qty: item.qty,
73
- requested_uom: item.unit || null,
74
- selected_description: item.description,
75
- selected_uoi: item.uoi,
76
- selected_uoi_count: item.uoiCount,
77
- linked_context: item.linkedContext ?? [],
78
- selected_conversion: {
79
- sales_uom_normalised: item.salesUomNormalised,
80
- conversion_type: item.conversionType,
81
- sales_unit_size: item.salesUnitSize,
82
- sales_unit_size_uom: item.salesUnitSizeUom,
83
- coverage_qty: item.coverageQty,
84
- coverage_uom: item.coverageUom,
85
- },
86
- })),
87
- }),
88
- })
89
- .then((response) => (response.ok ? readJson(response) : null))
90
- .then((payload) => {
91
- const updates = {};
92
- for (const resolved of payload?.data?.items ?? payload?.items ?? []) {
93
- const conversion = resolved?.conversion;
94
- if (!conversion || typeof resolved?.line_id !== "string")
95
- continue;
96
- const current = cart[resolved.line_id];
97
- updates[resolved.line_id] = {
98
- conversionType: conversion.conversion_type,
99
- requestUomsSupported: conversion.request_uoms_supported,
100
- salesUomNormalised: conversion.sales_uom_normalised,
101
- salesUnitSize: conversion.sales_unit_size,
102
- salesUnitSizeUom: conversion.sales_unit_size_uom,
103
- coverageQty: conversion.coverage_qty,
104
- coverageUom: conversion.coverage_uom,
105
- roundingRule: conversion.rounding_rule,
106
- conversionConfidence: conversion.conversion_confidence,
107
- conversionNotes: conversion.conversion_notes,
108
- ...(current?.qty === 0 && Number.isFinite(conversion.suggested_order_qty) && conversion.suggested_order_qty > 0
109
- ? { qty: conversion.suggested_order_qty, unit: conversion.suggested_order_uom || current.unit }
110
- : {}),
111
- };
112
- }
113
- if (Object.keys(updates).length > 0)
114
- updateCartConversions(updates);
115
- })
116
- .catch((error) => console.error("Catalogue conversion resolution failed", error));
117
- }, [cart, updateCartConversions]);
118
45
  // The quote survives the widget restarting - the host re-rendering the page under us
119
46
  // (Turbo restoring its own entry), or a refresh. It lives in this tab's sessionStorage and
120
47
  // the URL hash says which step to show. Restored after mount, not in the initial state, so
@@ -321,7 +248,7 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
321
248
  setEntryMode("schedule");
322
249
  await runScheduleMatch(file);
323
250
  }, [runScheduleMatch]);
324
- return (_jsx(TrackContext.Provider, { value: track, children: _jsx("div", { ref: rootRef, className: `app-wrapper${fullPage ? "" : " app-wrapper--embedded"}${hideHeader ? " app-wrapper--no-header" : ""}${!fullPage && stage === "processing" ? " app-wrapper--loading" : ""}`, style: getQuoteConfig().theme, children: _jsxs("main", { className: "main-content", children: [error && (_jsxs("div", { className: "error-box", role: "alert", children: [_jsx("strong", { children: "Error:" }), " ", error] })), stage === "entry" && _jsx(EntryStage, { onPasteSchedule: handlePasteSchedule, onUploadSchedule: handleUploadSchedule, onEntryModeChange: setEntryMode, draft: entryDraft, onSubmitDraft: setEntryDraft, onCancelMatch: cancelMatch }), stage === "results" && entryMode === "schedule" && scheduleResult && (_jsx(ScheduleResultsStage, { scheduleResult: scheduleResult, onStartOver: resetToEntry, cart: cart, onAddToCart: addToCart, onRemoveFromCart: removeFromCart, onGoToBasket: goToBasket, cartSeeded: cartSeeded, onCartSeeded: () => setCartSeeded(true), reviewGateComplete: scheduleReviewGateComplete, onReviewGateComplete: () => setScheduleReviewGateComplete(true), internal: internal })), stage === "basket" && (_jsx(BasketStage, { cart: cart, onUpdateQty: updateCartQty, onUpdateSellUnits: updateCartSellUnits, onRemove: removeFromCart, onBack: backToResults, onStartNewQuote: resetToEntry, onQuoteRequested: () => {
251
+ return (_jsx(TrackContext.Provider, { value: track, children: _jsx("div", { ref: rootRef, className: `app-wrapper${fullPage ? "" : " app-wrapper--embedded"}${hideHeader ? " app-wrapper--no-header" : ""}${!fullPage && stage === "processing" ? " app-wrapper--loading" : ""}`, style: getQuoteConfig().theme, children: _jsxs("main", { className: "main-content", children: [error && (_jsxs("div", { className: "error-box", role: "alert", children: [_jsx("strong", { children: "Error:" }), " ", error] })), stage === "entry" && _jsx(EntryStage, { onPasteSchedule: handlePasteSchedule, onUploadSchedule: handleUploadSchedule, onEntryModeChange: setEntryMode, draft: entryDraft, onSubmitDraft: setEntryDraft, onCancelMatch: cancelMatch }), stage === "results" && entryMode === "schedule" && scheduleResult && (_jsx(ScheduleResultsStage, { scheduleResult: scheduleResult, onStartOver: resetToEntry, cart: cart, onAddToCart: addToCart, onRemoveFromCart: removeFromCart, onGoToBasket: goToBasket, cartSeeded: cartSeeded, onCartSeeded: () => setCartSeeded(true), reviewGateComplete: scheduleReviewGateComplete, onReviewGateComplete: () => setScheduleReviewGateComplete(true), internal: internal })), stage === "basket" && (_jsx(BasketStage, { cart: cart, onUpdateQty: updateCartQty, onUpdateSellUnits: updateCartSellUnits, onResolveConversions: updateCartConversions, onRemove: removeFromCart, onBack: backToResults, onStartNewQuote: resetToEntry, onQuoteRequested: () => {
325
252
  setScheduleResult(null);
326
253
  setScheduleReviewGateComplete(false);
327
254
  setCartSeeded(false);
@@ -10,6 +10,7 @@ import { exportBasketExcel } from "../../util/exportScheduleExcel";
10
10
  import { renderDisplayUnit, unitDisplayText } from "../components/UnitDisplay";
11
11
  import { clientHeaders, getBoqClientId, readJson } from "../../lib/api/boqClient";
12
12
  import { MAX_ORDER_QTY } from "../../lib/boq/inputLimits";
13
+ import { conversionInputKey } from "../../lib/cart/CartContext";
13
14
  // ponytail: flat UK VAT rate, matching fmtCurrency's GBP-only assumption elsewhere in this app.
14
15
  const VAT_RATE = 0.2;
15
16
  const PAGE_SIZE = 10;
@@ -53,18 +54,28 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
53
54
  const [calcOpen, setCalcOpen] = useState({});
54
55
  const [wasteEnabled, setWasteEnabled] = useState(false);
55
56
  const [wastePercent, setWastePercent] = useState("0");
56
- const resolvedProductIds = useRef(new Set());
57
+ const [resolvingConversionInputs, setResolvingConversionInputs] = useState({});
58
+ const resolvedConversionInputs = useRef(new Map());
57
59
  useEffect(() => {
58
60
  if (!onResolveConversions)
59
61
  return;
60
62
  for (const item of Object.values(cart)) {
61
63
  if (getPricingBreakdown(item).requiredSellUnits != null)
62
- resolvedProductIds.current.delete(item.lineId);
64
+ resolvedConversionInputs.current.delete(item.lineId);
63
65
  }
64
- const unresolved = Object.values(cart).filter((item) => item.productId !== "-" && getPricingBreakdown(item).requiredSellUnits == null && !resolvedProductIds.current.has(item.lineId));
66
+ const unresolved = Object.values(cart).filter((item) => {
67
+ if (item.productId === "-" || getPricingBreakdown(item).requiredSellUnits != null)
68
+ return false;
69
+ const input = conversionInputKey(item);
70
+ if (resolvedConversionInputs.current.get(item.lineId) === input)
71
+ return false;
72
+ resolvedConversionInputs.current.set(item.lineId, input);
73
+ return true;
74
+ });
65
75
  if (unresolved.length === 0)
66
76
  return;
67
- unresolved.forEach((item) => resolvedProductIds.current.add(item.lineId));
77
+ const inputByLineId = new Map(unresolved.map((item) => [item.lineId, conversionInputKey(item)]));
78
+ setResolvingConversionInputs((current) => ({ ...current, ...Object.fromEntries(inputByLineId) }));
68
79
  void fetch(apiUrl("/api/catalogue/resolve-conversions"), {
69
80
  method: "POST",
70
81
  headers: { "Content-Type": "application/json", ...clientHeaders() },
@@ -95,7 +106,7 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
95
106
  const updates = {};
96
107
  for (const resolved of payload?.data?.items ?? payload?.items ?? []) {
97
108
  const conversion = resolved?.conversion;
98
- if (!conversion || typeof resolved?.line_id !== "string")
109
+ if (!conversion || typeof resolved?.line_id !== "string" || resolvedConversionInputs.current.get(resolved.line_id) !== inputByLineId.get(resolved.line_id))
99
110
  continue;
100
111
  const current = cart[resolved.line_id];
101
112
  updates[resolved.line_id] = {
@@ -117,7 +128,16 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
117
128
  if (Object.keys(updates).length > 0)
118
129
  onResolveConversions(updates);
119
130
  })
120
- .catch((error) => console.error("Catalogue conversion resolution failed", error));
131
+ .catch((error) => console.error("Catalogue conversion resolution failed", error))
132
+ .finally(() => {
133
+ setResolvingConversionInputs((current) => {
134
+ const next = { ...current };
135
+ for (const [lineId, input] of inputByLineId)
136
+ if (next[lineId] === input)
137
+ delete next[lineId];
138
+ return next;
139
+ });
140
+ });
121
141
  }, [cart, onResolveConversions]);
122
142
  function clearQtyDraft(lineId) {
123
143
  setQtyDrafts((prev) => {
@@ -156,6 +176,10 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
156
176
  // Sends the basket to the merchant as a quote request (POST /v1/quote-requests). Success only
157
177
  // shows once the API confirms the email went out; otherwise the customer is asked to retry.
158
178
  async function submitQuoteRequest() {
179
+ if (pricedItems.some(({ pricing }) => pricing.lineTotal == null)) {
180
+ setQuoteContactError("Confirm the highlighted quantities before requesting a quote.");
181
+ return;
182
+ }
159
183
  const email = reference.trim();
160
184
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
161
185
  setQuoteContactError(INVALID_EMAIL_MESSAGE);
@@ -196,7 +220,8 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
196
220
  : "Your request couldn't be sent. Please try again, or contact us if it keeps happening.");
197
221
  return;
198
222
  }
199
- track("quote_requested", { items: pricedItems.length, total: Math.round(total * 100) / 100 });
223
+ if (total != null)
224
+ track("quote_requested", { items: pricedItems.length, total: Math.round(total * 100) / 100 });
200
225
  setQuoteRequestSent(true);
201
226
  onQuoteRequested?.();
202
227
  window.scrollTo(0, 0);
@@ -219,10 +244,12 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
219
244
  wasteFactorEnabled: wasteEnabled,
220
245
  wasteFactorOverride: Math.max(0, Number.parseFloat(wastePercent) || 0) / 100,
221
246
  });
222
- const vat = subtotal * VAT_RATE;
223
- const total = subtotal + vat;
247
+ const vat = subtotal == null ? null : subtotal * VAT_RATE;
248
+ const total = subtotal == null ? null : subtotal * (1 + VAT_RATE);
249
+ const unpriced = pricedItems.filter(({ pricing }) => pricing.lineTotal == null).length;
250
+ const resolvingCount = Object.keys(resolvingConversionInputs).length;
224
251
  // Once per visit to the basket, with the figures it opened with.
225
- const openedWith = useRef({ items: pricedItems.length, total: Math.round(total * 100) / 100 });
252
+ const openedWith = useRef({ items: pricedItems.length, ...(total == null ? {} : { total: Math.round(total * 100) / 100 }) });
226
253
  useEffect(() => {
227
254
  track("basket_viewed", openedWith.current);
228
255
  }, [track]);
@@ -247,7 +274,7 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
247
274
  return (_jsxs("article", { className: "basket-card", children: [_jsxs("div", { className: "basket-card-top", children: [_jsx("img", { className: "basket-card-thumb", src: productImageSrc(item.description, item.productId, item.pdpPhotoUrl), alt: "", width: 48, height: 48 }), _jsxs("div", { className: "basket-card-info", children: [_jsx("div", { className: "bom-component-name", children: item.description }), rawDiffersFromOrder && pricing.costingMode !== "review" && (_jsxs("button", { type: "button", className: "basket-calc-toggle", onClick: () => setCalcOpen((prev) => ({
248
275
  ...prev,
249
276
  [item.lineId]: !prev[item.lineId],
250
- })), "aria-expanded": !!calcOpen[item.lineId], children: ["How we calculated this", _jsx("span", { className: `basket-calc-toggle-chevron${calcOpen[item.lineId] ? " basket-calc-toggle-chevron--open" : ""}`, children: _jsx(ChevronIcon, {}) })] }))] }), _jsx("div", { className: "basket-card-actions", children: _jsx("button", { type: "button", className: "mf-remove-small-btn basket-card-remove", onClick: () => onRemove(item.lineId), "aria-label": `Remove ${item.description}`, children: _jsx(TrashIcon, {}) }) })] }), internal && (_jsxs("div", { className: "results-detail-block basket-internal-detail", children: [_jsxs("dl", { className: "results-detail-grid", children: [_jsxs("div", { children: [_jsx("dt", { children: "Conversion type" }), _jsx("dd", { children: item.conversionType ?? "-" })] }), _jsxs("div", { children: [_jsx("dt", { children: "Conversion confidence" }), _jsx("dd", { children: item.conversionConfidence ?? "-" })] }), _jsxs("div", { children: [_jsx("dt", { children: "Sales unit" }), _jsx("dd", { children: item.salesUnitSize != null ? (_jsxs(_Fragment, { children: [item.salesUnitSize, " ", renderDisplayUnit(item.salesUnitSizeUom, item.salesUnitSize)] })) : ("-") })] }), _jsxs("div", { children: [_jsx("dt", { children: "Coverage" }), _jsx("dd", { children: item.coverageQty != null ? (_jsxs(_Fragment, { children: [item.coverageQty, " ", renderDisplayUnit(item.coverageUom, item.coverageQty)] })) : ("-") })] }), _jsxs("div", { children: [_jsx("dt", { children: "Waste factor" }), _jsx("dd", { children: item.wasteFactorDefault ?? "-" })] }), _jsxs("div", { children: [_jsx("dt", { children: "Rounding rule" }), _jsx("dd", { children: item.roundingRule ?? "-" })] })] }), item.conversionNotes && _jsx("p", { className: "results-rerank-note", children: item.conversionNotes })] })), pricing.costingMode === "review" ? (_jsxs("p", { className: "basket-calc-text basket-calc-text--review", children: [_jsx("strong", { children: "Quantity needs confirmation." }), " ", reviewMessage(pricing)] })) : rawDiffersFromOrder && calcOpen[item.lineId] ? (_jsx("p", { className: "basket-calc-text", children: orderSummary(item, pricing) })) : null, _jsxs("div", { className: "basket-card-stats", children: [_jsxs("div", { className: "basket-stat", children: [_jsx("span", { className: "basket-stat-label", children: "Requested qty" }), _jsxs("div", { className: "basket-stat-value", children: [_jsxs("div", { className: "basket-qty-field", children: [_jsx("input", { className: "bom-qty-edit", type: "number", inputMode: "decimal", min: "0", placeholder: "-", value: requestedQtyValue, disabled: requestedLocked, onChange: (e) => setQtyDrafts((prev) => ({
277
+ })), "aria-expanded": !!calcOpen[item.lineId], children: ["How we calculated this", _jsx("span", { className: `basket-calc-toggle-chevron${calcOpen[item.lineId] ? " basket-calc-toggle-chevron--open" : ""}`, children: _jsx(ChevronIcon, {}) })] }))] }), _jsx("div", { className: "basket-card-actions", children: _jsx("button", { type: "button", className: "mf-remove-small-btn basket-card-remove", onClick: () => onRemove(item.lineId), "aria-label": `Remove ${item.description}`, children: _jsx(TrashIcon, {}) }) })] }), internal && (_jsxs("div", { className: "results-detail-block basket-internal-detail", children: [_jsxs("dl", { className: "results-detail-grid", children: [_jsxs("div", { children: [_jsx("dt", { children: "Conversion type" }), _jsx("dd", { children: item.conversionType ?? "-" })] }), _jsxs("div", { children: [_jsx("dt", { children: "Conversion confidence" }), _jsx("dd", { children: item.conversionConfidence ?? "-" })] }), _jsxs("div", { children: [_jsx("dt", { children: "Sales unit" }), _jsx("dd", { children: item.salesUnitSize != null ? (_jsxs(_Fragment, { children: [item.salesUnitSize, " ", renderDisplayUnit(item.salesUnitSizeUom, item.salesUnitSize)] })) : ("-") })] }), _jsxs("div", { children: [_jsx("dt", { children: "Coverage" }), _jsx("dd", { children: item.coverageQty != null ? (_jsxs(_Fragment, { children: [item.coverageQty, " ", renderDisplayUnit(item.coverageUom, item.coverageQty)] })) : ("-") })] }), _jsxs("div", { children: [_jsx("dt", { children: "Waste factor" }), _jsx("dd", { children: item.wasteFactorDefault ?? "-" })] }), _jsxs("div", { children: [_jsx("dt", { children: "Rounding rule" }), _jsx("dd", { children: item.roundingRule ?? "-" })] })] }), item.conversionNotes && _jsx("p", { className: "results-rerank-note", children: item.conversionNotes })] })), pricing.costingMode === "review" ? (_jsxs("p", { className: "basket-calc-text basket-calc-text--review", children: [_jsx("strong", { children: resolvingConversionInputs[item.lineId] === conversionInputKey(item) ? "Calculating order quantity…" : "Quantity needs confirmation." }), " ", reviewMessage(pricing)] })) : rawDiffersFromOrder && calcOpen[item.lineId] ? (_jsx("p", { className: "basket-calc-text", children: orderSummary(item, pricing) })) : null, _jsxs("div", { className: "basket-card-stats", children: [_jsxs("div", { className: "basket-stat", children: [_jsx("span", { className: "basket-stat-label", children: "Requested qty" }), _jsxs("div", { className: "basket-stat-value", children: [_jsxs("div", { className: "basket-qty-field", children: [_jsx("input", { className: "bom-qty-edit", type: "number", inputMode: "decimal", min: "0", placeholder: "-", value: requestedQtyValue, disabled: requestedLocked, onChange: (e) => setQtyDrafts((prev) => ({
251
278
  ...prev,
252
279
  [item.lineId]: e.target.value,
253
280
  })), onBlur: (e) => commitRequestedQty(item.lineId, e.target.value), onKeyDown: (e) => {
@@ -260,11 +287,11 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
260
287
  if (e.key === "Enter")
261
288
  e.target.blur();
262
289
  }, "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));
263
- }) }), 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) => {
290
+ }) }), 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: subtotal == null ? "To confirm" : fmtCurrency(subtotal) })] }), _jsxs("div", { className: "basket-sidebar-row", children: [_jsx("span", { children: qzaBasketLayout ? "VAT (20%)" : "VAT (20%)" }), _jsx("span", { children: vat == null ? "To confirm" : 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: total == null ? "To confirm" : 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) => {
264
291
  setReference(e.target.value);
265
292
  if (qzaBasketLayout)
266
293
  setQuoteContactError("");
267
- }, placeholder: qzaBasketLayout ? "you@example.com" : "PO number or job reference", "aria-invalid": qzaBasketLayout && quoteContactError === INVALID_EMAIL_MESSAGE, "aria-describedby": qzaBasketLayout && quoteContactError ? `${uid}-basket-reference-error` : undefined }), qzaBasketLayout && quoteContactError && (_jsx("p", { id: `${uid}-basket-reference-error`, className: "basket-quote-request-error", role: "alert", children: quoteContactError })), qzaBasketLayout ? (_jsx("button", { type: "button", className: "cta-btn", onClick: () => void submitQuoteRequest(), disabled: quoteRequestSending, "aria-busy": quoteRequestSending, children: _jsx("span", { children: quoteRequestSending ? "Sending…" : "Request our best quote" }) })) : (_jsx("button", { className: "cta-btn", onClick: () => {
294
+ }, placeholder: qzaBasketLayout ? "you@example.com" : "PO number or job reference", "aria-invalid": qzaBasketLayout && quoteContactError === INVALID_EMAIL_MESSAGE, "aria-describedby": qzaBasketLayout && quoteContactError ? `${uid}-basket-reference-error` : undefined }), qzaBasketLayout && quoteContactError && (_jsx("p", { id: `${uid}-basket-reference-error`, className: "basket-quote-request-error", role: "alert", children: quoteContactError })), qzaBasketLayout ? (_jsx("button", { type: "button", className: "cta-btn", onClick: () => void submitQuoteRequest(), disabled: quoteRequestSending || unpriced > 0 || resolvingCount > 0, "aria-busy": quoteRequestSending, children: _jsx("span", { children: quoteRequestSending ? "Sending…" : "Request our best quote" }) })) : (_jsx("button", { className: "cta-btn", onClick: () => {
268
295
  track("downloaded", { type: "basket", items: items.length });
269
296
  void exportBasketExcel(items);
270
297
  }, "aria-label": internal ? "Download selection" : "Download basket", children: _jsx("span", { children: internal ? "Download Quote" : "Buy Now" }) })), _jsxs("p", { className: "basket-sidebar-disclaimer", children: [_jsx("span", { "aria-hidden": "true", children: "\u26A0\uFE0F" }), qzaBasketLayout
@@ -16,5 +16,6 @@ export declare function resolveBest(result: MatchResult | undefined, overriddenP
16
16
  export declare function candidateFromCart(item: CartItem): MatchCandidate;
17
17
  export declare function refreshedCartItem(refreshed: CartItem, existing: CartItem): CartItem;
18
18
  export declare function displayLineLabel(item: LineItem, allItems: LineItem[]): string;
19
+ export declare function formatResultsTotal(totalValue: number | null, needsPricingCount: number): string | null;
19
20
  export default function ScheduleResultsStage({ scheduleResult, onStartOver, cart, onAddToCart, onRemoveFromCart, onGoToBasket, cartSeeded, onCartSeeded, reviewGateComplete, onReviewGateComplete, internal }: ResultsStageProps): import("react").JSX.Element;
20
21
  export {};
@@ -153,6 +153,9 @@ export function displayLineLabel(item, allItems) {
153
153
  const label = typeof item.customer_display_label === "string" ? item.customer_display_label.trim() : "";
154
154
  return label || original || "(no description)";
155
155
  }
156
+ export function formatResultsTotal(totalValue, needsPricingCount) {
157
+ return needsPricingCount > 0 || totalValue == null ? null : fmtCurrency(totalValue);
158
+ }
156
159
  const LINK_GROUP_BUBBLE_STYLES = [
157
160
  { background: "#fff7ed", borderColor: "#fdba74", color: "#9a3412" },
158
161
  { background: "#eff6ff", borderColor: "#93c5fd", color: "#1d4ed8" },
@@ -379,7 +382,8 @@ export default function ScheduleResultsStage({ scheduleResult, onStartOver, cart
379
382
  };
380
383
  });
381
384
  // Match BasketStage: catalogue waste defaults are opt-in in the basket UI.
382
- let totalValue = getCartSubtotal(Object.values(cart), { wasteFactorEnabled: false, wasteFactorOverride: 0 });
385
+ const hasPendingReview = rows.some((row) => !row.unmatchable && !row.best && !skippedReviewIds.has(row.item.line_id));
386
+ let totalValue = hasPendingReview ? null : getCartSubtotal(Object.values(cart), { wasteFactorEnabled: false, wasteFactorOverride: 0 });
383
387
  let needsPricingCount = 0;
384
388
  for (const row of rows) {
385
389
  if (row.unmatchable || row.decision?.include_in_quote !== true || !row.best)
@@ -390,6 +394,7 @@ export default function ScheduleResultsStage({ scheduleResult, onStartOver, cart
390
394
  }
391
395
  if (needsPricingCount > 0)
392
396
  totalValue = null;
397
+ const totalLabel = formatResultsTotal(totalValue, needsPricingCount);
393
398
  const hasBestAlternative = (row) => !row.unmatchable && !row.best && row.decision?.technical_classification !== "no_suitable_candidate" && recommendedCandidates(row.result).length > 0;
394
399
  const previewCandidate = (row) => row.best ?? recommendedCandidates(row.result)[0];
395
400
  const reviewRequiredRows = rows.filter((row) => !row.unmatchable && !row.best);
@@ -684,7 +689,7 @@ export default function ScheduleResultsStage({ scheduleResult, onStartOver, cart
684
689
  })();
685
690
  const activeReviewCartMatches = !!activeReviewRow?.best && cart[activeReviewRow.item.line_id]?.productId === activeReviewRow.best.product_id;
686
691
  const activeReviewHasPendingUpdate = !!activeReviewRow?.best && !!cart[activeReviewRow.item.line_id] && !activeReviewCartMatches;
687
- return (_jsxs("div", { className: "wizard-step entry-stage", children: [_jsxs("div", { className: `quick-order-card${internal ? "" : " quick-order-card--customer"}`, children: [_jsx("div", { className: `quick-order-hero${internal ? "" : " quick-order-hero--customer"}`, children: internal ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "quick-order-eyebrow", children: "Construction Services" }), _jsxs("div", { className: "quick-order-hero-title-row", children: [_jsx("h1", { children: "Your Results" }), _jsxs("span", { className: "summary-chip summary-chip--scope", children: [_jsx("strong", { children: totalValue == null ? "Calculating…" : fmtCurrency(totalValue) }), " Est. total"] })] }), _jsx("p", { children: "Each line item from your schedule matched against your catalogue, ranked by confidence." })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { children: [_jsx("h1", { children: "Review your matched products" }), _jsxs("p", { children: ["Check you're happy with these items, then click ", qzaQuoteRequest ? "View Quote" : "View Basket", " to continue."] })] }), _jsxs("button", { type: "button", className: "results-total-action", onClick: onGoToBasket, "aria-label": qzaQuoteRequest ? "View quote" : "View basket", children: [_jsxs("span", { children: [_jsx("small", { children: qzaQuoteRequest ? "View Quote" : "View Basket" }), _jsx("strong", { children: totalValue == null ? "Calculating…" : fmtCurrency(totalValue) })] }), _jsx("span", { className: "arrow", children: _jsx(ForwardArrow, {}) })] })] })) }), _jsxs("div", { className: "quick-order-panel", style: { paddingTop: 16 }, children: [debugEnabled && (_jsx("div", { className: "results-summary", style: { marginBottom: 12 }, children: _jsx("button", { type: "button", className: "bom-toolbar-btn", "aria-pressed": showTimings, onClick: () => setShowTimings((value) => !value), children: showTimings ? "Hide timings" : "Show timings" }) })), debugEnabled && showTimings && _jsx(TimingBreakdown, { ...timings }), !qzaQuoteRequest && (_jsx("div", { className: "results-toolbar-row", children: !activeReviewRow && (_jsxs("div", { className: "results-summary", style: { marginBottom: 0 }, children: [needsPricingCount > 0 && (_jsxs("span", { className: "summary-chip summary-chip--neutral", children: [_jsx("strong", { children: needsPricingCount }), " need pricing"] })), _jsxs("div", { style: {
692
+ return (_jsxs("div", { className: "wizard-step entry-stage", children: [_jsxs("div", { className: `quick-order-card${internal ? "" : " quick-order-card--customer"}`, children: [_jsx("div", { className: `quick-order-hero${internal ? "" : " quick-order-hero--customer"}`, children: internal ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "quick-order-eyebrow", children: "Construction Services" }), _jsxs("div", { className: "quick-order-hero-title-row", children: [_jsx("h1", { children: "Your Results" }), _jsxs("span", { className: "summary-chip summary-chip--scope", children: [_jsx("strong", { children: totalLabel ?? "Review quantities" }), " Est. total"] })] }), _jsx("p", { children: "Each line item from your schedule matched against your catalogue, ranked by confidence." })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { children: [_jsx("h1", { children: "Review your matched products" }), _jsxs("p", { children: ["Check you're happy with these items, then click ", qzaQuoteRequest ? "View Quote" : "View Basket", " to continue."] })] }), _jsxs("button", { type: "button", className: "results-total-action", onClick: onGoToBasket, "aria-label": qzaQuoteRequest ? "View quote" : "View basket", children: [_jsxs("span", { children: [_jsx("small", { children: qzaQuoteRequest ? "View Quote" : "View Basket" }), totalLabel != null && _jsx("strong", { children: totalLabel })] }), _jsx("span", { className: "arrow", children: _jsx(ForwardArrow, {}) })] })] })) }), _jsxs("div", { className: "quick-order-panel", style: { paddingTop: 16 }, children: [debugEnabled && (_jsx("div", { className: "results-summary", style: { marginBottom: 12 }, children: _jsx("button", { type: "button", className: "bom-toolbar-btn", "aria-pressed": showTimings, onClick: () => setShowTimings((value) => !value), children: showTimings ? "Hide timings" : "Show timings" }) })), debugEnabled && showTimings && _jsx(TimingBreakdown, { ...timings }), !qzaQuoteRequest && (_jsx("div", { className: "results-toolbar-row", children: !activeReviewRow && (_jsxs("div", { className: "results-summary", style: { marginBottom: 0 }, children: [needsPricingCount > 0 && (_jsxs("span", { className: "summary-chip summary-chip--neutral", children: [_jsx("strong", { children: needsPricingCount }), " need pricing"] })), _jsxs("div", { style: {
688
693
  display: "flex",
689
694
  gap: 8,
690
695
  flexWrap: "wrap",
@@ -26,7 +26,6 @@ const CONFIG = {
26
26
  wastage: { straight: 0.05, diagonal: 0.15 },
27
27
  pictureFrame: { extraWastage: 0.08 },
28
28
  joist: {
29
- lengthM: 2.4,
30
29
  spacingOptions: [
31
30
  { id: "300", label: "300mm centres (heavy duty)", spacingM: 0.3 },
32
31
  { id: "400", label: "400mm centres (standard)", spacingM: 0.4 },
@@ -145,13 +144,15 @@ export default function DeckingEstimator({ onBack, onReview, submitLabel = "Add
145
144
  const screwBoxes = Math.ceil((area * 30 * (1 + wastageRate + pfExtra)) / 200);
146
145
  const spacing = CONFIG.joist.spacingOptions.find((option) => option.id === state.joistSpacing) ?? CONFIG.joist.spacingOptions[1];
147
146
  const joistRunTotalM = area / spacing.spacingM;
148
- const joistLengthsNeeded = Math.ceil(joistRunTotalM / CONFIG.joist.lengthM);
147
+ // Asked for in metres: the matched joist's own length turns it into lengths, so a merchant
148
+ // selling 2.4m and one selling 3.6m both get the right count.
149
+ const joistMetres = Math.ceil(joistRunTotalM);
149
150
  return {
150
151
  range,
151
152
  area,
152
153
  boardCount,
153
154
  screwBoxes,
154
- joistLengthsNeeded,
155
+ joistMetres,
155
156
  spacing,
156
157
  };
157
158
  };
@@ -159,7 +160,7 @@ export default function DeckingEstimator({ onBack, onReview, submitLabel = "Add
159
160
  const requirements = results
160
161
  ? [
161
162
  { key: `boards-${state.range ?? "meridian"}`, label: `Deck boards (${results.range.label})`, qty: results.boardCount, unit: "each", query: "timber decking board", category: "decking", calculatorWasteIncluded: true },
162
- { key: "joists", label: "Treated C24 joists", qty: results.joistLengthsNeeded, unit: "each", query: "treated joist", category: "timber", calculatorWasteIncluded: true },
163
+ { key: "joists", label: "Treated C24 joists", qty: results.joistMetres, unit: "m", query: "treated joist", category: "timber", calculatorWasteIncluded: true },
163
164
  { key: "screws", label: "Green decking screws (200/box)", qty: results.screwBoxes, unit: "box", query: "green decking screws", category: "fixings", calculatorWasteIncluded: true },
164
165
  ]
165
166
  : [];
@@ -1,6 +1,7 @@
1
1
  import type { EstimatorRequirement } from "../../lib/estimators/types";
2
+ import type { EstimatorToolId } from "./registry";
2
3
  interface EstimatorResultsProps {
3
- toolId: string;
4
+ toolId: EstimatorToolId;
4
5
  requirements: EstimatorRequirement[];
5
6
  /** Mirrors the estimator's own `calculated` flag - nothing is fetched before Calculate. */
6
7
  ready: boolean;
@@ -96,7 +96,7 @@ export default function EstimatorResults({ toolId, requirements, ready, onBack,
96
96
  (async () => {
97
97
  let next = null;
98
98
  try {
99
- const resolved = await resolveRequirements(requirements);
99
+ const resolved = await resolveRequirements(requirements, undefined, toolId);
100
100
  next = resolved.map(({ requirement, candidates }) => ({
101
101
  requirement,
102
102
  candidates,
@@ -1,6 +1,7 @@
1
+ import type { EstimatorToolId } from "./registry";
1
2
  import type { EstimatorRequirement } from "../../lib/estimators/types";
2
3
  interface EstimatorReviewStageProps {
3
- toolId: string;
4
+ toolId: EstimatorToolId;
4
5
  toolTitle: string;
5
6
  requirements: EstimatorRequirement[];
6
7
  onBack: () => void;
@@ -1,11 +1,16 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useState } from "react";
4
+ // byArea: ask for the area and let the matched product's own coverage turn it into packs, which
5
+ // is right whichever client's product it is. Floor and wall still ask for packs worked out from
6
+ // the coverage here.
7
+ // ponytail: floor/wall stay on packs because FORT's catalogue coverage for its floor roll and wall
8
+ // slab is wrong (0.06 and 0.546 m2); switch them to byArea once that data is fixed.
4
9
  const APPLICATIONS = {
5
10
  floor: { label: "Floor", product: "Knauf Acoustic Roll", thickness: "100mm", coverage: 12.36, salesUnit: "roll" },
6
11
  wall: { label: "Cavity wall", product: "Celotex CW4100", thickness: "100mm", coverage: 3.24, salesUnit: "pack" },
7
- roof: { label: "Pitched roof", product: "Kingspan K107", thickness: "100mm", coverage: 8.64, salesUnit: "pack" },
8
- loft: { label: "Loft", product: "Knauf Loft Roll 44", thickness: "100mm", coverage: 13.89, salesUnit: "roll" },
12
+ roof: { label: "Pitched roof", product: "Kingspan K107", thickness: "100mm", coverage: 8.64, salesUnit: "pack", byArea: true },
13
+ loft: { label: "Loft", product: "Knauf Loft Roll 44", thickness: "100mm", coverage: 13.89, salesUnit: "roll", byArea: true },
9
14
  };
10
15
  export default function InsulationEstimator({ onBack, onReview, submitLabel = "Add to basket" }) {
11
16
  const [application, setApplication] = useState("floor");
@@ -14,13 +19,12 @@ export default function InsulationEstimator({ onBack, onReview, submitLabel = "A
14
19
  const area = Number.parseFloat(length) * Number.parseFloat(width) || 0;
15
20
  const selected = APPLICATIONS[application];
16
21
  const packs = Math.ceil(area / selected.coverage);
17
- // Requested as area - pack coverage comes from the product, not PRODUCT_COVERAGE.
18
22
  const requirements = [
19
23
  {
20
24
  key: `insulation-${application}`,
21
25
  label: `${selected.product} ${selected.thickness}`,
22
- qty: packs,
23
- unit: selected.salesUnit,
26
+ qty: selected.byArea ? Math.round(area * 100) / 100 : packs,
27
+ unit: selected.byArea ? "m2" : selected.salesUnit,
24
28
  query: selected.product,
25
29
  category: "insulation",
26
30
  },
@@ -21,7 +21,7 @@ export default function SkirtingEstimator({ onBack, onReview, submitLabel = "Add
21
21
  const [rooms, setRooms] = useState([emptyRoom()]);
22
22
  const [openings, setOpenings] = useState("");
23
23
  const [profile, setProfile] = useState("chamfered");
24
- const boardLength = "4.2";
24
+ const boardLength = "4.4";
25
25
  const baseLengthM = mode === "total"
26
26
  ? toMetres(totalLength, unit)
27
27
  : rooms.reduce((total, room) => total + 2 * (toMetres(room.length, unit) + toMetres(room.width, unit)), 0);
@@ -34,5 +34,5 @@ export default function SkirtingEstimator({ onBack, onReview, submitLabel = "Add
34
34
  const updateRoom = (index, key, nextValue) => {
35
35
  setRooms((items) => items.map((room, roomIndex) => roomIndex === index ? { ...room, [key]: nextValue } : room));
36
36
  };
37
- return (_jsx(_Fragment, { children: _jsxs("div", { className: "truss-estimator", children: [_jsxs("div", { className: "quick-order-hero", children: [_jsx("div", { className: "quick-order-eyebrow", children: "Estimating Tools" }), _jsx("h1", { children: "Skirting Board Calculator" }), _jsx("p", { children: "Work out how many skirting board lengths you need for one room or a whole project." })] }), _jsxs("div", { className: "truss-estimator__card skirting-calculator", children: [_jsx("h2", { className: "truss-estimator__step-title", children: "Measure by room or total length" }), _jsx("p", { className: "truss-estimator__step-sub", children: "Enter a single total or work room by room. The run is converted straight into board lengths." }), _jsxs("div", { className: "skirting-calculator__section", children: [_jsx("div", { className: "truss-estimator__section-heading", children: "How would you like to measure?" }), _jsxs("div", { className: "brick-calculator__unit-toggle skirting-calculator__mode-toggle", role: "group", "aria-label": "Calculation method", children: [_jsx("button", { type: "button", className: mode === "total" ? "is-selected" : "", onClick: () => setMode("total"), children: "Total length" }), _jsx("button", { type: "button", className: mode === "rooms" ? "is-selected" : "", onClick: () => setMode("rooms"), children: "Room by room" })] })] }), _jsxs("div", { className: "skirting-calculator__section", children: [_jsx("div", { className: "truss-estimator__section-heading", children: "Measurements" }), _jsxs("div", { className: "skirting-calculator__unit-row", children: [_jsx("span", { children: "Units" }), _jsxs("div", { className: "brick-calculator__unit-toggle", role: "group", "aria-label": "Measurement units", children: [_jsx("button", { type: "button", className: unit === "m" ? "is-selected" : "", onClick: () => setUnit("m"), children: "Metres" }), _jsx("button", { type: "button", className: unit === "ft" ? "is-selected" : "", onClick: () => setUnit("ft"), children: "Feet" })] })] }), mode === "total" ? (_jsxs("label", { className: "truss-estimator__field skirting-calculator__single-field", children: [_jsxs("span", { children: ["Total skirting length (", unit, ")"] }), _jsx("input", { type: "number", min: "0", step: "0.01", value: totalLength, onChange: (event) => setTotalLength(event.target.value) })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "skirting-calculator__dimensions-header", children: [_jsx("span", { children: "Room" }), _jsxs("span", { children: ["Length (", unit, ")"] }), _jsxs("span", { children: ["Width (", unit, ")"] }), _jsx("span", {})] }), rooms.map((room, index) => (_jsxs("div", { className: "skirting-calculator__room-row", children: [_jsxs("span", { className: "skirting-calculator__room-label", children: ["Room ", index + 1] }), _jsx("input", { "aria-label": `Room ${index + 1} length in ${unit}`, type: "number", min: "0", step: "0.01", value: room.length, onChange: (event) => updateRoom(index, "length", event.target.value) }), _jsx("input", { "aria-label": `Room ${index + 1} width in ${unit}`, type: "number", min: "0", step: "0.01", value: room.width, onChange: (event) => updateRoom(index, "width", event.target.value) }), rooms.length > 1 ? _jsx("button", { type: "button", className: "paint-calculator__remove", onClick: () => setRooms((items) => items.filter((_, roomIndex) => roomIndex !== index)), children: "Remove" }) : null] }, `room-${index}`))), _jsx("button", { type: "button", className: "paint-calculator__add", onClick: () => setRooms((items) => [...items, emptyRoom()]), children: "+ Add another room" })] })), _jsxs("label", { className: "truss-estimator__field skirting-calculator__single-field", children: [_jsxs("span", { children: ["Openings and doorways to deduct (", unit, ") ", _jsx("small", { children: "optional" })] }), _jsx("input", { type: "number", min: "0", step: "0.01", value: openings, onChange: (event) => setOpenings(event.target.value) })] })] }), _jsxs("div", { className: "skirting-calculator__section", children: [_jsx("div", { className: "truss-estimator__section-heading", children: "Profile" }), _jsx("p", { className: "skirting-calculator__hint", children: "Choose the finish you want for the fixed 4.2m white-primed MDF boards." }), _jsx("div", { className: "architrave-calculator__profiles", children: PROFILES.map((option) => _jsxs("button", { type: "button", className: `truss-estimator__option-card${profile === option.id ? " is-selected" : ""}`, onClick: () => setProfile(option.id), children: [_jsx(ProfileDiagram, { profile: option.id }), _jsx("div", { className: "truss-estimator__option-title", children: option.label }), _jsx("div", { className: "truss-estimator__option-copy", children: option.blurb })] }, option.id)) })] }), _jsxs("div", { className: "skirting-calculator__section", children: [_jsx("div", { className: "truss-estimator__section-heading", children: "Board and waste" }), _jsx("div", { className: "truss-estimator__field-grid skirting-calculator__options-grid", children: _jsxs("label", { className: "truss-estimator__field", children: [_jsx("span", { children: "Board length" }), _jsx("input", { value: "4.20 m", disabled: true })] }) }), _jsx("p", { className: "skirting-calculator__hint", children: "Longer boards can reduce the number of joins, while 10% waste allows for cuts, corners and mistakes." })] }), _jsxs("div", { className: "skirting-calculator__totals", children: [_jsx("div", { className: "truss-estimator__section-heading", children: "Totals" }), _jsxs("div", { className: "skirting-calculator__result-grid", children: [_jsxs("div", { children: [_jsx("span", { children: "Net skirting" }), _jsxs("strong", { children: [netLengthM.toFixed(2), " m"] })] }), _jsxs("div", { children: [_jsx("span", { children: "Board lengths" }), _jsx("strong", { children: boardCount })] })] }), _jsx("p", { className: "skirting-calculator__note", children: "Corners and joins vary by room layout. Measure each wall where possible for the most accurate result." })] }), _jsxs("div", { className: "skirting-calculator__actions", children: [_jsx("button", { type: "button", className: "cta-btn cta-btn-secondary estimator-actions__back", onClick: onBack, children: "Back" }), _jsx("button", { type: "button", className: "cta-btn estimator-actions__calculate", onClick: () => onReview?.(requirements), disabled: netLengthM <= 0, children: submitLabel })] })] })] }) }));
37
+ return (_jsx(_Fragment, { children: _jsxs("div", { className: "truss-estimator", children: [_jsxs("div", { className: "quick-order-hero", children: [_jsx("div", { className: "quick-order-eyebrow", children: "Estimating Tools" }), _jsx("h1", { children: "Skirting Board Calculator" }), _jsx("p", { children: "Work out how many skirting board lengths you need for one room or a whole project." })] }), _jsxs("div", { className: "truss-estimator__card skirting-calculator", children: [_jsx("h2", { className: "truss-estimator__step-title", children: "Measure by room or total length" }), _jsx("p", { className: "truss-estimator__step-sub", children: "Enter a single total or work room by room. The run is converted straight into board lengths." }), _jsxs("div", { className: "skirting-calculator__section", children: [_jsx("div", { className: "truss-estimator__section-heading", children: "How would you like to measure?" }), _jsxs("div", { className: "brick-calculator__unit-toggle skirting-calculator__mode-toggle", role: "group", "aria-label": "Calculation method", children: [_jsx("button", { type: "button", className: mode === "total" ? "is-selected" : "", onClick: () => setMode("total"), children: "Total length" }), _jsx("button", { type: "button", className: mode === "rooms" ? "is-selected" : "", onClick: () => setMode("rooms"), children: "Room by room" })] })] }), _jsxs("div", { className: "skirting-calculator__section", children: [_jsx("div", { className: "truss-estimator__section-heading", children: "Measurements" }), _jsxs("div", { className: "skirting-calculator__unit-row", children: [_jsx("span", { children: "Units" }), _jsxs("div", { className: "brick-calculator__unit-toggle", role: "group", "aria-label": "Measurement units", children: [_jsx("button", { type: "button", className: unit === "m" ? "is-selected" : "", onClick: () => setUnit("m"), children: "Metres" }), _jsx("button", { type: "button", className: unit === "ft" ? "is-selected" : "", onClick: () => setUnit("ft"), children: "Feet" })] })] }), mode === "total" ? (_jsxs("label", { className: "truss-estimator__field skirting-calculator__single-field", children: [_jsxs("span", { children: ["Total skirting length (", unit, ")"] }), _jsx("input", { type: "number", min: "0", step: "0.01", value: totalLength, onChange: (event) => setTotalLength(event.target.value) })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "skirting-calculator__dimensions-header", children: [_jsx("span", { children: "Room" }), _jsxs("span", { children: ["Length (", unit, ")"] }), _jsxs("span", { children: ["Width (", unit, ")"] }), _jsx("span", {})] }), rooms.map((room, index) => (_jsxs("div", { className: "skirting-calculator__room-row", children: [_jsxs("span", { className: "skirting-calculator__room-label", children: ["Room ", index + 1] }), _jsx("input", { "aria-label": `Room ${index + 1} length in ${unit}`, type: "number", min: "0", step: "0.01", value: room.length, onChange: (event) => updateRoom(index, "length", event.target.value) }), _jsx("input", { "aria-label": `Room ${index + 1} width in ${unit}`, type: "number", min: "0", step: "0.01", value: room.width, onChange: (event) => updateRoom(index, "width", event.target.value) }), rooms.length > 1 ? _jsx("button", { type: "button", className: "paint-calculator__remove", onClick: () => setRooms((items) => items.filter((_, roomIndex) => roomIndex !== index)), children: "Remove" }) : null] }, `room-${index}`))), _jsx("button", { type: "button", className: "paint-calculator__add", onClick: () => setRooms((items) => [...items, emptyRoom()]), children: "+ Add another room" })] })), _jsxs("label", { className: "truss-estimator__field skirting-calculator__single-field", children: [_jsxs("span", { children: ["Openings and doorways to deduct (", unit, ") ", _jsx("small", { children: "optional" })] }), _jsx("input", { type: "number", min: "0", step: "0.01", value: openings, onChange: (event) => setOpenings(event.target.value) })] })] }), _jsxs("div", { className: "skirting-calculator__section", children: [_jsx("div", { className: "truss-estimator__section-heading", children: "Profile" }), _jsx("p", { className: "skirting-calculator__hint", children: "Choose the finish you want for the fixed 4.4m white-primed MDF boards." }), _jsx("div", { className: "architrave-calculator__profiles", children: PROFILES.map((option) => _jsxs("button", { type: "button", className: `truss-estimator__option-card${profile === option.id ? " is-selected" : ""}`, onClick: () => setProfile(option.id), children: [_jsx(ProfileDiagram, { profile: option.id }), _jsx("div", { className: "truss-estimator__option-title", children: option.label }), _jsx("div", { className: "truss-estimator__option-copy", children: option.blurb })] }, option.id)) })] }), _jsxs("div", { className: "skirting-calculator__section", children: [_jsx("div", { className: "truss-estimator__section-heading", children: "Board and waste" }), _jsx("div", { className: "truss-estimator__field-grid skirting-calculator__options-grid", children: _jsxs("label", { className: "truss-estimator__field", children: [_jsx("span", { children: "Board length" }), _jsx("input", { value: "4.40 m", disabled: true })] }) }), _jsx("p", { className: "skirting-calculator__hint", children: "Longer boards can reduce the number of joins, while 10% waste allows for cuts, corners and mistakes." })] }), _jsxs("div", { className: "skirting-calculator__totals", children: [_jsx("div", { className: "truss-estimator__section-heading", children: "Totals" }), _jsxs("div", { className: "skirting-calculator__result-grid", children: [_jsxs("div", { children: [_jsx("span", { children: "Net skirting" }), _jsxs("strong", { children: [netLengthM.toFixed(2), " m"] })] }), _jsxs("div", { children: [_jsx("span", { children: "Board lengths" }), _jsx("strong", { children: boardCount })] })] }), _jsx("p", { className: "skirting-calculator__note", children: "Corners and joins vary by room layout. Measure each wall where possible for the most accurate result." })] }), _jsxs("div", { className: "skirting-calculator__actions", children: [_jsx("button", { type: "button", className: "cta-btn cta-btn-secondary estimator-actions__back", onClick: onBack, children: "Back" }), _jsx("button", { type: "button", className: "cta-btn estimator-actions__calculate", onClick: () => onReview?.(requirements), disabled: netLengthM <= 0, children: submitLabel })] })] })] }) }));
38
38
  }
@@ -1,6 +1,7 @@
1
1
  export type FixedBasketLine = {
2
2
  key: string;
3
3
  productId: string;
4
+ search: string;
4
5
  qty: number;
5
6
  unit?: string;
6
7
  };
@@ -4,7 +4,7 @@ import { useState } from "react";
4
4
  import BrickWall from "../BrickWall";
5
5
  import { useCart } from "../../lib/cart/CartContext";
6
6
  import { candidateToCartItem } from "../../lib/boq/toCartItem";
7
- import { searchCatalogue } from "../../lib/estimators/resolveProducts";
7
+ import { estimateRequestUnit, searchCatalogue } from "../../lib/estimators/resolveProducts";
8
8
  const MIN_BASKET_LOADING_MS = 1500;
9
9
  export function useFixedBasket(toolTitle) {
10
10
  const { addManyToCart, clearCart, openBasket } = useCart();
@@ -16,10 +16,10 @@ export function useFixedBasket(toolTitle) {
16
16
  setBasketError(null);
17
17
  try {
18
18
  const items = await Promise.all(lines.filter((line) => line.qty > 0).map(async (line) => {
19
- const candidate = (await searchCatalogue(line.productId)).find((item) => item.product_id === line.productId);
19
+ const candidate = (await searchCatalogue(line.search)).find((item) => item.product_id === line.productId);
20
20
  if (!candidate)
21
21
  throw new Error("A selected product is no longer available in this catalogue.");
22
- return candidateToCartItem(candidate, { lineId: `${toolTitle}:${line.key}`, description: candidate.description, unit: line.unit ?? "each", qty: line.qty, calculatorWasteIncluded: wasteIncluded });
22
+ return candidateToCartItem(candidate, { lineId: `${toolTitle}:${line.key}`, description: candidate.description, unit: estimateRequestUnit(line.unit ?? "each", candidate), qty: line.qty, calculatorWasteIncluded: wasteIncluded });
23
23
  }));
24
24
  const remaining = MIN_BASKET_LOADING_MS - (Date.now() - startedAt);
25
25
  if (remaining > 0)