@metron-labs/quote-react 0.1.64 → 0.1.66
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/dist/app/EstimatingTools.js +8 -4
- package/dist/app/QuickQuote.js +4 -4
- package/dist/app/stages/BasketStage.d.ts +0 -2
- package/dist/app/stages/BasketStage.js +15 -19
- package/dist/app/stages/EntryStage.d.ts +0 -2
- package/dist/app/stages/EntryStage.js +16 -9
- package/dist/app/stages/ProcessingStage.js +1 -1
- package/dist/app/stages/ScheduleResultsStage.js +9 -9
- package/dist/components/ChevronIcon.d.ts +1 -0
- package/dist/components/ChevronIcon.js +4 -0
- package/dist/components/InsulationPlaceholder.d.ts +2 -0
- package/dist/components/InsulationPlaceholder.js +6 -0
- package/dist/components/ProductPlaceholder.d.ts +2 -0
- package/dist/components/ProductPlaceholder.js +6 -0
- package/dist/components/TrashIcon.d.ts +1 -0
- package/dist/components/TrashIcon.js +4 -0
- package/dist/components/estimators/EstimatorResults.js +3 -3
- package/dist/embed/quote.js +10 -29
- package/dist/lib/ProductImage.d.ts +9 -0
- package/dist/lib/ProductImage.js +25 -0
- package/package.json +1 -1
- package/dist/lib/productImage.d.ts +0 -1
- package/dist/lib/productImage.js +0 -75
|
@@ -5,11 +5,11 @@ import { assetUrl } from "../lib/assets";
|
|
|
5
5
|
import { getQuoteConfig } from "../lib/config";
|
|
6
6
|
import { TrackContext, useTracker } from "../lib/analytics";
|
|
7
7
|
import { ESTIMATOR_TOOLS } from "../components/estimators/registry";
|
|
8
|
-
import BasketStage from "./stages/BasketStage";
|
|
9
|
-
import EstimatorReviewStage from "../components/estimators/EstimatorReviewStage";
|
|
10
8
|
import { fixedBasketLines } from "../lib/estimators/fixedProducts";
|
|
11
9
|
import { EstimatorBasketLoading, useFixedBasket } from "../components/estimators/useFixedBasket";
|
|
12
10
|
import { CartProvider, useCart } from "../lib/cart/CartContext";
|
|
11
|
+
import BasketStage from "./stages/BasketStage";
|
|
12
|
+
import EstimatorReviewStage from "../components/estimators/EstimatorReviewStage";
|
|
13
13
|
export default function EstimatingTools(props) {
|
|
14
14
|
// Held above the provider so "Add to basket", several layers down inside an estimator,
|
|
15
15
|
// can bring the basket up without every estimator having to thread a callback through.
|
|
@@ -28,7 +28,7 @@ function EstimatingToolsInner({ fullPage = false, hideHeader = false, internal =
|
|
|
28
28
|
track("viewed");
|
|
29
29
|
}, [track]);
|
|
30
30
|
// Every screen change, forward or back, lands at the top.
|
|
31
|
-
const view = showBasket ? "basket" : adding ? "adding" : review ? "review" : activeEstimatorId ?? "list";
|
|
31
|
+
const view = showBasket ? "basket" : adding ? "adding" : review ? "review" : (activeEstimatorId ?? "list");
|
|
32
32
|
const scrolledView = useRef(view);
|
|
33
33
|
useEffect(() => {
|
|
34
34
|
if (scrolledView.current === view)
|
|
@@ -51,5 +51,9 @@ function EstimatingToolsInner({ fullPage = false, hideHeader = false, internal =
|
|
|
51
51
|
setActiveEstimatorId(null);
|
|
52
52
|
setReview(null);
|
|
53
53
|
onShowBasket(false);
|
|
54
|
-
}, internal: internal })) : adding && activeEstimator ? (_jsx(EstimatorBasketLoading, { toolTitle: activeEstimator.title })) : activeEstimator ? (_jsxs(_Fragment, { children: [_jsx("div", { hidden: review !== null, children: _jsx(activeEstimator.Component, { onBack: () => setActiveEstimatorId(null), onReview: reviewOrBasket }) }), basketError && _jsx("p", { className: "results-empty-hint estimator-products__error", children: basketError }), review !== null &&
|
|
54
|
+
}, internal: internal })) : adding && activeEstimator ? (_jsx(EstimatorBasketLoading, { toolTitle: activeEstimator.title })) : activeEstimator ? (_jsxs(_Fragment, { children: [_jsx("div", { hidden: review !== null, children: _jsx(activeEstimator.Component, { onBack: () => setActiveEstimatorId(null), onReview: reviewOrBasket }) }), basketError && _jsx("p", { className: "results-empty-hint estimator-products__error", children: basketError }), review !== null && _jsx(EstimatorReviewStage, { toolId: activeEstimator.id, toolTitle: activeEstimator.title, requirements: review, onBack: () => setReview(null) })] })) : (_jsx(_Fragment, { children: _jsxs("div", { className: "quick-order-card", children: [_jsxs("div", { className: "quick-order-hero", children: [_jsx("div", { className: "quick-order-eyebrow", children: "Construction Services" }), _jsx("h1", { children: "Estimating Tools" }), _jsx("p", { children: "Trade-specific calculators for trusses, fencing, decking, masonry and painting. Dial in your specs and get an instant material estimate." })] }), _jsxs("div", { className: "quick-order-alt", children: [_jsx("div", { className: "quick-order-alt-title", children: "Choose a Calculator" }), _jsx("div", { className: "estimator-tools-grid", children: ESTIMATOR_TOOLS.map((tool) => (_jsxs("button", { type: "button", className: "estimator-tool-card", onClick: () => {
|
|
55
|
+
track("calculator_selected", { calculator: tool.id });
|
|
56
|
+
setActiveEstimatorId(tool.id);
|
|
57
|
+
setReview(null);
|
|
58
|
+
}, children: [_jsx("span", { className: "estimator-tool-card__image-wrap", children: _jsx("img", { className: "fill-img estimator-tool-card__image", src: assetUrl(tool.imageUrl), alt: "" }) }), _jsxs("span", { className: "estimator-tool-card__content", children: [_jsx("span", { className: "estimator-tool-card__title", children: tool.title }), _jsx("span", { className: "estimator-tool-card__desc", children: tool.desc })] })] }, tool.id))) })] })] }) })) }) }) }));
|
|
55
59
|
}
|
package/dist/app/QuickQuote.js
CHANGED
|
@@ -5,12 +5,12 @@ 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";
|
|
8
|
-
import EntryStage from "./stages/EntryStage";
|
|
9
8
|
import { ENTRY_DRAFT_KEY } from "./stages/EntryStage";
|
|
10
|
-
import ScheduleResultsStage from "./stages/ScheduleResultsStage";
|
|
11
|
-
import BasketStage from "./stages/BasketStage";
|
|
12
9
|
import { CartProvider, useCart } from "../lib/cart/CartContext";
|
|
13
10
|
import { installQuoteHistory, setStep, stepBack } from "../lib/quoteHistory";
|
|
11
|
+
import BasketStage from "./stages/BasketStage";
|
|
12
|
+
import EntryStage from "./stages/EntryStage";
|
|
13
|
+
import ScheduleResultsStage from "./stages/ScheduleResultsStage";
|
|
14
14
|
const SAVED_QUOTE_KEY = "metron-quick-quote";
|
|
15
15
|
// A tab left open for days shouldn't come back with old matches and old prices.
|
|
16
16
|
// ponytail: counts from the last edit, not from matching. Stamp the match time instead if
|
|
@@ -241,7 +241,7 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
|
|
|
241
241
|
setEntryMode("schedule");
|
|
242
242
|
await runScheduleMatch(file);
|
|
243
243
|
}, [runScheduleMatch]);
|
|
244
|
-
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: () => {
|
|
244
|
+
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: () => {
|
|
245
245
|
setScheduleResult(null);
|
|
246
246
|
setScheduleReviewGateComplete(false);
|
|
247
247
|
setCartSeeded(false);
|
|
@@ -6,9 +6,7 @@ interface BasketStageProps {
|
|
|
6
6
|
onResolveConversions?: (conversions: Record<string, Partial<CartItem>>) => void;
|
|
7
7
|
onRemove: (lineId: string) => void;
|
|
8
8
|
onBack: () => void;
|
|
9
|
-
/** Clears the basket and returns to the start, from the quote-request confirmation page. */
|
|
10
9
|
onStartNewQuote: () => void;
|
|
11
|
-
/** Called once the quote request is sent; the confirmation stays up whatever it clears. */
|
|
12
10
|
onQuoteRequested?: () => void;
|
|
13
11
|
internal?: boolean;
|
|
14
12
|
}
|
|
@@ -2,17 +2,18 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { useEffect, useId, useRef, useState } from "react";
|
|
3
3
|
import { apiUrl } from "../../lib/config";
|
|
4
4
|
import { useTrack } from "../../lib/analytics";
|
|
5
|
-
import BackArrow from "../../components/BackArrow";
|
|
6
5
|
import { fmtCurrency } from "../../lib/formatters";
|
|
7
|
-
import { productImageSrc } from "../../lib/productImage";
|
|
8
6
|
import { getCartSubtotal, getPricingBreakdown } from "../../util/pricing";
|
|
9
7
|
import { exportBasketExcel } from "../../util/exportScheduleExcel";
|
|
10
8
|
import { renderDisplayUnit, unitDisplayText } from "../components/UnitDisplay";
|
|
11
9
|
import { clientHeaders, getBoqClientId, readJson } from "../../lib/api/boqClient";
|
|
12
10
|
import { MAX_ORDER_QTY } from "../../lib/boq/inputLimits";
|
|
13
11
|
import { conversionInputKey } from "../../lib/cart/CartContext";
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
import BackArrow from "../../components/BackArrow";
|
|
13
|
+
import ChevronIcon from "../../components/ChevronIcon";
|
|
14
|
+
import TrashIcon from "../../components/TrashIcon";
|
|
15
|
+
import ProductImage from "../../lib/ProductImage";
|
|
16
|
+
const VAT_RATE = 0.2; //Flat UK VAT rate, matching fmtCurrency's GBP-only assumption elsewhere in this app.
|
|
16
17
|
const PAGE_SIZE = 10;
|
|
17
18
|
const INVALID_EMAIL_MESSAGE = "Enter a valid email address so we can send your quote.";
|
|
18
19
|
const orderSummary = (item, pricing) => {
|
|
@@ -32,24 +33,19 @@ const reviewMessage = (pricing) => {
|
|
|
32
33
|
return "Pack size needed to calculate the order quantity.";
|
|
33
34
|
return `This product is sold by ${unitDisplayText(pricing.sellUnit, 1)}.`;
|
|
34
35
|
};
|
|
35
|
-
function TrashIcon() {
|
|
36
|
-
return (_jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("polyline", { points: "3 6 5 6 21 6" }), _jsx("path", { d: "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" }), _jsx("line", { x1: "10", y1: "11", x2: "10", y2: "17" }), _jsx("line", { x1: "14", y1: "11", x2: "14", y2: "17" })] }));
|
|
37
|
-
}
|
|
38
|
-
function ChevronIcon() {
|
|
39
|
-
return (_jsx("svg", { width: "8", height: "5", viewBox: "0 0 8 5", fill: "none", children: _jsx("path", { d: "M1 4L4 1L7 4", stroke: "currentColor", strokeWidth: "1.4", strokeLinecap: "round", strokeLinejoin: "round" }) }));
|
|
40
|
-
}
|
|
41
36
|
export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onResolveConversions, onRemove, onBack, onStartNewQuote, onQuoteRequested, internal = false, }) {
|
|
42
37
|
// Both widgets can render a basket on the same page, so element ids are made unique.
|
|
43
38
|
const uid = useId();
|
|
39
|
+
const track = useTrack();
|
|
44
40
|
// Every customer sends the basket to the merchant (POST /v1/quote-requests). Materials Market
|
|
45
41
|
// calls it a quote request and keeps its own layout; every other client calls it a basket.
|
|
46
42
|
const qzaBasketLayout = !internal && getBoqClientId() === "QZA6-MRA4";
|
|
47
43
|
const sendsRequest = !internal;
|
|
48
44
|
const items = Object.values(cart);
|
|
45
|
+
const resolvedConversionInputs = useRef(new Map());
|
|
49
46
|
const [reference, setReference] = useState("");
|
|
50
47
|
const [quoteRequestSent, setQuoteRequestSent] = useState(false);
|
|
51
48
|
const [quoteRequestSending, setQuoteRequestSending] = useState(false);
|
|
52
|
-
const track = useTrack();
|
|
53
49
|
const [quoteContactError, setQuoteContactError] = useState("");
|
|
54
50
|
const [qtyDrafts, setQtyDrafts] = useState({});
|
|
55
51
|
const [sellUnitsDrafts, setSellUnitsDrafts] = useState({});
|
|
@@ -58,7 +54,6 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
|
|
|
58
54
|
const [wasteEnabled, setWasteEnabled] = useState(false);
|
|
59
55
|
const [wastePercent, setWastePercent] = useState("0");
|
|
60
56
|
const [resolvingConversionInputs, setResolvingConversionInputs] = useState({});
|
|
61
|
-
const resolvedConversionInputs = useRef(new Map());
|
|
62
57
|
useEffect(() => {
|
|
63
58
|
if (!onResolveConversions)
|
|
64
59
|
return;
|
|
@@ -252,12 +247,13 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
|
|
|
252
247
|
const total = subtotal == null ? null : subtotal * (1 + VAT_RATE);
|
|
253
248
|
const unpriced = pricedItems.filter(({ pricing }) => pricing.lineTotal == null).length;
|
|
254
249
|
const resolvingCount = Object.keys(resolvingConversionInputs).length;
|
|
255
|
-
const waitingForConversions = !!onResolveConversions &&
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
250
|
+
const waitingForConversions = !!onResolveConversions &&
|
|
251
|
+
items.some((item) => {
|
|
252
|
+
if (item.productId === "-" || getPricingBreakdown(item).requiredSellUnits != null)
|
|
253
|
+
return false;
|
|
254
|
+
const input = conversionInputKey(item);
|
|
255
|
+
return resolvedConversionInputs.current.get(item.lineId) !== input || resolvingConversionInputs[item.lineId] === input;
|
|
256
|
+
});
|
|
261
257
|
// Once per visit to the basket, with the figures it opened with.
|
|
262
258
|
const openedWith = useRef({ items: pricedItems.length, ...(total == null ? {} : { total: Math.round(total * 100) / 100 }) });
|
|
263
259
|
useEffect(() => {
|
|
@@ -281,7 +277,7 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
|
|
|
281
277
|
const requestedQtyValue = qtyDrafts[item.lineId] ?? String(pricing.requestedAmount);
|
|
282
278
|
const requestedLocked = pricing.requestedUnit === pricing.sellUnit || pricing.costingMode === "review";
|
|
283
279
|
const rawDiffersFromOrder = pricing.requiredSellUnits == null || pricing.requestedAmount !== pricing.requiredSellUnits || pricing.requestedUnit !== pricing.sellUnit;
|
|
284
|
-
return (_jsxs("article", { className: "basket-card", children: [_jsxs("div", { className: "basket-card-top", children: [_jsx(
|
|
280
|
+
return (_jsxs("article", { className: "basket-card", children: [_jsxs("div", { className: "basket-card-top", children: [_jsx(ProductImage, { className: "basket-card-thumb", description: item.description, photoUrl: item.pdpPhotoUrl, 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) => ({
|
|
285
281
|
...prev,
|
|
286
282
|
[item.lineId]: !prev[item.lineId],
|
|
287
283
|
})), "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) => ({
|
|
@@ -3,10 +3,8 @@ interface EntryStageProps {
|
|
|
3
3
|
onPasteSchedule: (text: string, providedLines?: PhotoExtractedLine[], archiveSource?: boolean) => Promise<void> | void;
|
|
4
4
|
onUploadSchedule: (file: File) => Promise<void> | void;
|
|
5
5
|
onEntryModeChange: (mode: EntryMode) => void;
|
|
6
|
-
/** The last submitted input, so stepping back from the matches reopens the dialog with it. */
|
|
7
6
|
draft?: EntryDraft | null;
|
|
8
7
|
onSubmitDraft?: (draft: EntryDraft) => void;
|
|
9
|
-
/** Browser back out of the dialog mid-match - drop the match in flight. */
|
|
10
8
|
onCancelMatch?: () => void;
|
|
11
9
|
}
|
|
12
10
|
type QuickOrderTab = "paste" | "upload";
|
|
@@ -2,12 +2,12 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { useEffect, useRef, useState } from "react";
|
|
3
3
|
import { assetUrl } from "../../lib/assets";
|
|
4
4
|
import { useTrack } from "../../lib/analytics";
|
|
5
|
-
import ForwardArrow from "../../components/ForwardArrow";
|
|
6
|
-
import CloseIcon from "../../components/CloseIcon";
|
|
7
5
|
import { FILE_EXTENSIONS, isPhotoFile, shrinkPhoto, validateLines, validateUploadFile } from "../../lib/boq/inputLimits";
|
|
8
6
|
import { ProcessingContent } from "./ProcessingStage";
|
|
9
7
|
import { useEscapeKey } from "../../lib/useEscapeKey";
|
|
10
8
|
import { setStep, stepBack } from "../../lib/quoteHistory";
|
|
9
|
+
import ForwardArrow from "../../components/ForwardArrow";
|
|
10
|
+
import CloseIcon from "../../components/CloseIcon";
|
|
11
11
|
const UPLOAD_ACCEPT = [...FILE_EXTENSIONS.schedule, ...FILE_EXTENSIONS.photo].join(",");
|
|
12
12
|
const STEPS = {
|
|
13
13
|
paste: [
|
|
@@ -25,18 +25,17 @@ const STEPS = {
|
|
|
25
25
|
const DIALOG_HASH = "#quote-entry";
|
|
26
26
|
export const ENTRY_DRAFT_KEY = "metron-quick-quote-entry";
|
|
27
27
|
export default function EntryStage({ onPasteSchedule, onUploadSchedule, onEntryModeChange, draft, onSubmitDraft, onCancelMatch }) {
|
|
28
|
-
const
|
|
28
|
+
const fileInputRef = useRef(null);
|
|
29
29
|
const track = useTrack();
|
|
30
|
+
const dragDepth = useRef(0);
|
|
31
|
+
const matchRun = useRef(0); // Bumped on each submit and on cancel; a match that finds it moved on was cancelled.
|
|
32
|
+
const [activeTab, setActiveTab] = useState(() => (draft && typeof window !== "undefined" && window.location.hash === DIALOG_HASH ? draft.tab : null));
|
|
30
33
|
const [pastedText, setPastedText] = useState(draft?.text ?? "");
|
|
31
34
|
const [uploadFile, setUploadFile] = useState(draft?.file ?? null);
|
|
32
35
|
const [uploadError, setUploadError] = useState("");
|
|
33
36
|
const [matching, setMatching] = useState(false);
|
|
34
37
|
const [pageDragOver, setPageDragOver] = useState(false);
|
|
35
38
|
const [photoPreviewUrl, setPhotoPreviewUrl] = useState(null);
|
|
36
|
-
const dragDepth = useRef(0);
|
|
37
|
-
// Bumped on each submit and on cancel; a match that finds it moved on was cancelled.
|
|
38
|
-
const matchRun = useRef(0);
|
|
39
|
-
const fileInputRef = useRef(null);
|
|
40
39
|
const lineCount = pastedText.split("\n").filter((line) => line.trim()).length;
|
|
41
40
|
const pasteLineProblem = validateLines(pastedText);
|
|
42
41
|
const hasWorkflowInput = activeTab === "paste" ? Boolean(pastedText) : Boolean(uploadFile);
|
|
@@ -201,6 +200,14 @@ export default function EntryStage({ onPasteSchedule, onUploadSchedule, onEntryM
|
|
|
201
200
|
applyUploadFile(event.dataTransfer.files);
|
|
202
201
|
openWorkflow("upload");
|
|
203
202
|
};
|
|
204
|
-
return (_jsxs("div", { className: "wizard-step entry-stage", onDragEnter: onPageDragEnter, onDragOver: (event) => isFileDrag(event) && event.preventDefault(), onDragLeave: onPageDragLeave, onDrop: onPageDrop, children: [pageDragOver && _jsx("div", { className: "entry-drop-overlay", "aria-hidden": "true", children: "Drop your file to upload" }), _jsxs("div", { className: "quick-order-card", children: [_jsxs("div", { className: "quick-order-alt", children: [_jsxs("div", { className: "quick-order-entry-copy", children: [_jsx("div", { className: "quick-order-alt-title", children: "How would you like to get started?" }), _jsx("p", { className: "quick-order-alt-intro", children: "Upload a file or type a list of items and quantities. We'll match them to our products and help you build a quote." })] }), _jsxs("div", { className: "entry-options entry-options--workflow entry-options--two", children: [_jsxs("button", { type: "button", className: `entry-option-card entry-option-card--paste${activeTab === "paste" ? " entry-option-card--active" : ""}`, onClick: () => openWorkflow("paste"), children: [_jsxs("span", { className: "entry-option-content", children: [_jsx("span", { className: "entry-option-title", children: "Type or paste a list" }), _jsx("span", { className: "entry-option-desc", children: "Enter your materials and quantities" })] }), _jsx("span", { className: "entry-option-media entry-option-media--paste", "aria-hidden": "true", children: _jsx("img", { className: "fill-img", src: assetUrl("/quick-order/paste-list.webp"), alt: "" }) })] }), _jsxs("button", { type: "button", className: `entry-option-card entry-option-card--upload${activeTab === "upload" ? " entry-option-card--active" : ""}`, onClick: () => openWorkflow("upload"), children: [_jsxs("span", { className: "entry-option-content", children: [_jsx("span", { className: "entry-option-title", children: "Upload a file" }), _jsx("span", { className: "entry-option-desc", children: "A photo, spreadsheet, Word document or PDF" })] }), _jsx("span", { className: "entry-option-media entry-option-media--upload", "aria-hidden": "true", children: _jsx("img", { className: "fill-img", src: assetUrl("/quick-order/photo-list.webp"), alt: "" }) })] })] })] }), activeTab && (_jsx("div", { className: "modal-backdrop quick-order-modal-backdrop", role: "dialog", "aria-modal": "true", "aria-labelledby": "quick-order-workflow-title", onClick: () => !hasWorkflowInput && closeWorkflow(), children: _jsxs("section", { className: `quick-order-workflow-modal${matching ? " quick-order-workflow-modal--matching" : ""}`, onClick: (event) => event.stopPropagation(), children: [_jsxs("header", { className: "quick-order-workflow-modal-header", children: [_jsx("h2", { id: "quick-order-workflow-title", children: activeTab === "paste" ? "Type or paste a list" : "Upload a file" }), _jsx("button", { type: "button", className: "quick-order-workflow-close", onClick: closeWorkflow, "aria-label": "Close", children: _jsx(CloseIcon, {}) })] }), _jsxs("div", { className: "quick-order-panel", children: [_jsxs("div", { className: "quick-order-body", "aria-hidden": matching, children: [_jsx("div", { className: "quick-order-body-main", children: activeTab === "paste" ? (_jsxs(_Fragment, { children: [_jsx("textarea", { className: "paste-textarea", placeholder: "Paste your line items here, one per line, e.g.\ninsulated plasterboard 50mm\nC24 timber 4.8m", value: pastedText, onChange: (event) => setPastedText(event.target.value), "aria-label": "Paste schedule line items" }), pasteLineProblem && _jsxs("div", { className: "error-box", role: "alert", children: [_jsx("strong", { children: "Error:" }), " ", pasteLineProblem] }), _jsxs("div", { className: "quick-order-hint", children: [_jsx("strong", { children: "Tip:" }), "\u00A0Paste one line per item. Include a count, e.g. \"x10\", to help us find the right stock and pricing."] }), _jsx("div", { className: "quick-order-actions", children: _jsxs("button", { type: "button", className: "cta-btn", disabled: !lineCount || Boolean(pasteLineProblem), onClick: () => {
|
|
205
|
-
|
|
203
|
+
return (_jsxs("div", { className: "wizard-step entry-stage", onDragEnter: onPageDragEnter, onDragOver: (event) => isFileDrag(event) && event.preventDefault(), onDragLeave: onPageDragLeave, onDrop: onPageDrop, children: [pageDragOver && (_jsx("div", { className: "entry-drop-overlay", "aria-hidden": "true", children: "Drop your file to upload" })), _jsxs("div", { className: "quick-order-card", children: [_jsxs("div", { className: "quick-order-alt", children: [_jsxs("div", { className: "quick-order-entry-copy", children: [_jsx("div", { className: "quick-order-alt-title", children: "How would you like to get started?" }), _jsx("p", { className: "quick-order-alt-intro", children: "Upload a file or type a list of items and quantities. We'll match them to our products and help you build a quote." })] }), _jsxs("div", { className: "entry-options entry-options--workflow entry-options--two", children: [_jsxs("button", { type: "button", className: `entry-option-card entry-option-card--paste${activeTab === "paste" ? " entry-option-card--active" : ""}`, onClick: () => openWorkflow("paste"), children: [_jsxs("span", { className: "entry-option-content", children: [_jsx("span", { className: "entry-option-title", children: "Type or paste a list" }), _jsx("span", { className: "entry-option-desc", children: "Enter your materials and quantities" })] }), _jsx("span", { className: "entry-option-media entry-option-media--paste", "aria-hidden": "true", children: _jsx("img", { className: "fill-img", src: assetUrl("/quick-order/paste-list.webp"), alt: "" }) })] }), _jsxs("button", { type: "button", className: `entry-option-card entry-option-card--upload${activeTab === "upload" ? " entry-option-card--active" : ""}`, onClick: () => openWorkflow("upload"), children: [_jsxs("span", { className: "entry-option-content", children: [_jsx("span", { className: "entry-option-title", children: "Upload a file" }), _jsx("span", { className: "entry-option-desc", children: "A photo, spreadsheet, Word document or PDF" })] }), _jsx("span", { className: "entry-option-media entry-option-media--upload", "aria-hidden": "true", children: _jsx("img", { className: "fill-img", src: assetUrl("/quick-order/photo-list.webp"), alt: "" }) })] })] })] }), activeTab && (_jsx("div", { className: "modal-backdrop quick-order-modal-backdrop", role: "dialog", "aria-modal": "true", "aria-labelledby": "quick-order-workflow-title", onClick: () => !hasWorkflowInput && closeWorkflow(), children: _jsxs("section", { className: `quick-order-workflow-modal${matching ? " quick-order-workflow-modal--matching" : ""}`, onClick: (event) => event.stopPropagation(), children: [_jsxs("header", { className: "quick-order-workflow-modal-header", children: [_jsx("h2", { id: "quick-order-workflow-title", children: activeTab === "paste" ? "Type or paste a list" : "Upload a file" }), _jsx("button", { type: "button", className: "quick-order-workflow-close", onClick: closeWorkflow, "aria-label": "Close", children: _jsx(CloseIcon, {}) })] }), _jsxs("div", { className: "quick-order-panel", children: [_jsxs("div", { className: "quick-order-body", "aria-hidden": matching, children: [_jsx("div", { className: "quick-order-body-main", children: activeTab === "paste" ? (_jsxs(_Fragment, { children: [_jsx("textarea", { className: "paste-textarea", placeholder: "Paste your line items here, one per line, e.g.\ninsulated plasterboard 50mm\nC24 timber 4.8m", value: pastedText, onChange: (event) => setPastedText(event.target.value), "aria-label": "Paste schedule line items" }), pasteLineProblem && (_jsxs("div", { className: "error-box", role: "alert", children: [_jsx("strong", { children: "Error:" }), " ", pasteLineProblem] })), _jsxs("div", { className: "quick-order-hint", children: [_jsx("strong", { children: "Tip:" }), "\u00A0Paste one line per item. Include a count, e.g. \"x10\", to help us find the right stock and pricing."] }), _jsx("div", { className: "quick-order-actions", children: _jsxs("button", { type: "button", className: "cta-btn", disabled: !lineCount || Boolean(pasteLineProblem), onClick: () => {
|
|
204
|
+
track("list_submitted", { method: "paste", lines: lineCount });
|
|
205
|
+
void startMatching(() => onPasteSchedule(pastedText));
|
|
206
|
+
}, "aria-label": "Match pasted lines", children: [_jsx("span", { children: "Match line items" }), _jsx("span", { className: "arrow", children: _jsx(ForwardArrow, {}) })] }) })] })) : (_jsxs(_Fragment, { children: [uploadError && (_jsxs("div", { className: "error-box", role: "alert", children: [_jsx("strong", { children: "Error:" }), " ", uploadError] })), _jsxs("div", { className: `upload-zone${uploadFile ? " has-file" : ""}`, onClick: () => fileInputRef.current?.click(), onDragOver: (event) => event.preventDefault(), onDrop: onPageDrop, role: "button", tabIndex: 0, onKeyDown: (event) => (event.key === "Enter" || event.key === " ") && fileInputRef.current?.click(), "aria-label": "Upload zone: click or drag a list or image file here", children: [photoPreviewUrl ? (_jsx("img", { src: photoPreviewUrl, alt: uploadFile?.name ?? "Selected photo", className: "upload-zone-photo-preview" })) : (_jsx("div", { className: "upload-zone-icon", children: _jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" }), _jsx("polyline", { points: "14,2 14,8 20,8" }), _jsx("polyline", { points: "9,15 11,17 15,13" })] }) })), uploadFile ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "upload-zone-subtext", children: uploadFile.name }), _jsx("button", { type: "button", className: "link-btn link-btn--underline", onClick: (event) => {
|
|
207
|
+
event.stopPropagation();
|
|
208
|
+
setUploadFile(null);
|
|
209
|
+
setUploadError("");
|
|
210
|
+
if (fileInputRef.current)
|
|
211
|
+
fileInputRef.current.value = "";
|
|
212
|
+
}, children: "Remove" })] })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: "upload-zone-text", children: "Drop a photo, spreadsheet, Word document or PDF" }), _jsx("div", { className: "upload-zone-subtext", children: "or click to choose a file" })] })), _jsx("input", { ref: fileInputRef, type: "file", accept: UPLOAD_ACCEPT, className: "visually-hidden", onChange: (event) => applyUploadFile(event.target.files), tabIndex: -1, "aria-hidden": "true" })] }), _jsx("div", { className: "quick-order-actions", children: _jsxs("button", { type: "button", className: "cta-btn", disabled: !uploadFile, onClick: () => void matchUpload(), "aria-label": "Match uploaded items", children: [_jsx("span", { children: "Match items" }), _jsx("span", { className: "arrow", children: _jsx(ForwardArrow, {}) })] }) })] })) }), _jsx("aside", { className: "quick-order-sidebar", children: STEPS[activeTab].map((step, index) => (_jsxs("div", { className: "quick-order-step quick-order-step--rail", children: [_jsx("span", { className: "quick-order-step-dot", children: index + 1 }), _jsxs("div", { children: [_jsx("div", { className: "quick-order-step-title", children: step.title }), _jsx("div", { className: "quick-order-step-desc", children: step.desc })] })] }, step.title))) })] }), matching && (_jsx("div", { className: "quick-order-matching-overlay", children: _jsx(ProcessingContent, {}) }))] })] }) }))] })] }));
|
|
206
213
|
}
|
|
@@ -28,8 +28,8 @@ function shuffle(arr) {
|
|
|
28
28
|
return a;
|
|
29
29
|
}
|
|
30
30
|
export function ProcessingContent() {
|
|
31
|
-
const orderRef = useRef(shuffle(BANTER_MESSAGES.map((_, i) => i)));
|
|
32
31
|
const posRef = useRef(0);
|
|
32
|
+
const orderRef = useRef(shuffle(BANTER_MESSAGES.map((_, i) => i)));
|
|
33
33
|
const [message, setMessage] = useState(BANTER_MESSAGES[orderRef.current[0]]);
|
|
34
34
|
const [visible, setVisible] = useState(true);
|
|
35
35
|
useEffect(() => {
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { Fragment, useEffect, useRef, useState } from "react";
|
|
3
|
-
import BackArrow from "../../components/BackArrow";
|
|
4
|
-
import RefreshIcon from "../../components/RefreshIcon";
|
|
5
|
-
import ForwardArrow from "../../components/ForwardArrow";
|
|
6
|
-
import CloseIcon from "../../components/CloseIcon";
|
|
7
3
|
import { apiUrl, getQuoteConfig } from "../../lib/config";
|
|
8
4
|
import { useTrack } from "../../lib/analytics";
|
|
9
5
|
import { renderDisplayUnit, formatPriceBasis } from "../components/UnitDisplay";
|
|
10
6
|
import { fmtCurrency, fmtQty } from "../../lib/formatters";
|
|
11
|
-
import { productImageSrc } from "../../lib/productImage";
|
|
12
7
|
import { candidateToCartItem } from "../../lib/boq/toCartItem";
|
|
13
8
|
import { apiHeaders, getBoqClientId, readJson } from "../../lib/api/boqClient";
|
|
14
9
|
import { exportScheduleExcel } from "../../util/exportScheduleExcel";
|
|
15
10
|
import { useEscapeKey } from "../../lib/useEscapeKey";
|
|
16
11
|
import { getCartSubtotal, getPricingBreakdown } from "../../util/pricing";
|
|
12
|
+
import BackArrow from "../../components/BackArrow";
|
|
13
|
+
import RefreshIcon from "../../components/RefreshIcon";
|
|
14
|
+
import ForwardArrow from "../../components/ForwardArrow";
|
|
15
|
+
import CloseIcon from "../../components/CloseIcon";
|
|
16
|
+
import ProductImage from "../../lib/ProductImage";
|
|
17
17
|
const NO_SELECTION = "__none__";
|
|
18
18
|
const PAGE_SIZE = 24;
|
|
19
19
|
const SORT_OPTIONS = [
|
|
@@ -702,7 +702,7 @@ export default function ScheduleResultsStage({ scheduleResult, onStartOver, cart
|
|
|
702
702
|
display: "flex",
|
|
703
703
|
gap: 8,
|
|
704
704
|
flexWrap: "wrap",
|
|
705
|
-
}, children: [debugEnabled && (_jsx("button", { type: "button", className: "bom-toolbar-btn", onClick: () => setDetailMode((mode) => (mode === "simple" ? "detailed" : "simple")), children: detailMode === "detailed" ? "Hide detailed info" : "Show detailed info" })), _jsx("div", { className: "results-review-searchbar", children: _jsx("input", { type: "text", className: "results-search-input", placeholder: "Search products in catalogue...", value: reviewSearch, onChange: (e) => setReviewSearch(e.target.value), "aria-label": "Search database candidates" }) }), _jsx("div", { className: "results-candidate-actions", children: _jsx("button", { type: "button", className: "bom-toolbar-btn", onClick: () => clearPickedProduct(activeReviewRow.item.line_id), children: "Clear selected product" }) })] })] }), _jsxs("div", { className: "results-review-page__hero", children: [_jsx("div", { className: "results-review-page__image-wrap", children: _jsx(
|
|
705
|
+
}, children: [debugEnabled && (_jsx("button", { type: "button", className: "bom-toolbar-btn", onClick: () => setDetailMode((mode) => (mode === "simple" ? "detailed" : "simple")), children: detailMode === "detailed" ? "Hide detailed info" : "Show detailed info" })), _jsx("div", { className: "results-review-searchbar", children: _jsx("input", { type: "text", className: "results-search-input", placeholder: "Search products in catalogue...", value: reviewSearch, onChange: (e) => setReviewSearch(e.target.value), "aria-label": "Search database candidates" }) }), _jsx("div", { className: "results-candidate-actions", children: _jsx("button", { type: "button", className: "bom-toolbar-btn", onClick: () => clearPickedProduct(activeReviewRow.item.line_id), children: "Clear selected product" }) })] })] }), _jsxs("div", { className: "results-review-page__hero", children: [_jsx("div", { className: "results-review-page__image-wrap", children: _jsx(ProductImage, { className: "fill-img results-review-page__image", description: previewCandidate(activeReviewRow)?.description || activeReviewRow.item.description_original, photoUrl: previewCandidate(activeReviewRow)?.pdp_photo_url }) }), _jsxs("div", { className: "results-review-page__summary", children: [_jsx("h2", { children: displayLineLabel(activeReviewRow.item, lineItems) }), _jsxs("p", { className: "results-review-page__meta", children: [fmtQty(activeReviewRow.qty), " ", renderDisplayUnit(activeReviewRow.item.unit, activeReviewRow.qty)] }), activeReviewRow.best ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "results-review-page__product", children: activeReviewRow.best.description }), activeReviewRow.best.price != null ? (_jsx("div", { className: "results-row-meta", children: fmtPrice(activeReviewRow.best.price, activeReviewRow.best.price_basis) })) : null] })) : previewCandidate(activeReviewRow) ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "results-review-page__product", children: previewCandidate(activeReviewRow)?.description }), previewCandidate(activeReviewRow)?.price != null ? (_jsx("div", { className: "results-row-meta", children: fmtPrice(previewCandidate(activeReviewRow)?.price ?? null, previewCandidate(activeReviewRow)?.price_basis ?? null) })) : null, _jsx("div", { className: "results-row-meta", children: "Best reviewed alternative - not yet selected" })] })) : (_jsx("div", { className: "results-empty-hint", children: "No product currently selected for this line item." })), hasVisibleLinkedGroup(activeReviewRow.item) ? (_jsxs("div", { style: {
|
|
706
706
|
display: "flex",
|
|
707
707
|
alignItems: "center",
|
|
708
708
|
gap: 8,
|
|
@@ -735,10 +735,10 @@ export default function ScheduleResultsStage({ scheduleResult, onStartOver, cart
|
|
|
735
735
|
? `Select the option you want to add to your ${customerCollection}.`
|
|
736
736
|
: "Search the catalogue to choose a product." })] }) }), !reviewQuery && reviewDisplayCandidates.length > 0 ? (_jsx("div", { className: "customer-review-option-grid", children: reviewOptionCandidates.map((candidate) => {
|
|
737
737
|
const selected = candidate.product_id === activeReviewRow.best?.product_id;
|
|
738
|
-
return (_jsxs("button", { type: "button", className: `customer-review-option${selected ? " is-selected" : ""}`, onClick: () => handlePickProduct(activeReviewRow.item.line_id, candidate), "aria-pressed": selected, children: [_jsx(
|
|
738
|
+
return (_jsxs("button", { type: "button", className: `customer-review-option${selected ? " is-selected" : ""}`, onClick: () => handlePickProduct(activeReviewRow.item.line_id, candidate), "aria-pressed": selected, children: [_jsx(ProductImage, { className: "customer-review-option__image", description: candidate.description, photoUrl: candidate.pdp_photo_url, width: 64, height: 64 }), _jsx("span", { className: "customer-review-option__copy", children: _jsx("strong", { children: candidate.description }) }), _jsx("span", { className: "customer-review-option__price", children: candidate.price != null ? `£${fmtPrice(candidate.price, candidate.price_basis)}` : "-" }), _jsx("span", { className: "customer-review-option__state", children: selected ? "Selected" : "Choose" })] }, candidate.product_id));
|
|
739
739
|
}) })) : null, reviewDisplayCandidates.length > 0 && (_jsx("div", { className: "customer-review-divider", children: _jsx("span", { children: "or" }) })), _jsxs("label", { className: "customer-review-search", children: [_jsx("span", { children: "Search our full catalogue" }), _jsx("input", { type: "text", className: "results-search-input", placeholder: "Search products...", value: reviewSearch, onChange: (e) => setReviewSearch(e.target.value), "aria-label": "Search catalogue" })] }), reviewQuery && catalogueSearchLoading ? _jsx("div", { className: "results-empty-card", children: "Searching catalogue\u2026" }) : null, reviewQuery && !catalogueSearchLoading && catalogueSearchError ? _jsx("div", { className: "results-empty-card", children: catalogueSearchError }) : null, reviewQuery && !catalogueSearchLoading && !catalogueSearchError && reviewDisplayCandidates.length === 0 ? (_jsx("div", { className: "results-empty-card", children: "No products match this search." })) : null, reviewQuery && !catalogueSearchLoading && !catalogueSearchError && reviewDisplayCandidates.length > 0 ? (_jsx("div", { className: "customer-review-option-grid", children: reviewOptionCandidates.map((candidate) => {
|
|
740
740
|
const selected = candidate.product_id === activeReviewRow.best?.product_id;
|
|
741
|
-
return (_jsxs("button", { type: "button", className: `customer-review-option${selected ? " is-selected" : ""}`, onClick: () => handlePickProduct(activeReviewRow.item.line_id, candidate), "aria-pressed": selected, children: [_jsx(
|
|
741
|
+
return (_jsxs("button", { type: "button", className: `customer-review-option${selected ? " is-selected" : ""}`, onClick: () => handlePickProduct(activeReviewRow.item.line_id, candidate), "aria-pressed": selected, children: [_jsx(ProductImage, { className: "customer-review-option__image", description: candidate.description, photoUrl: candidate.pdp_photo_url, width: 64, height: 64 }), _jsx("span", { className: "customer-review-option__copy", children: _jsx("strong", { children: candidate.description }) }), _jsx("span", { className: "customer-review-option__price", children: candidate.price != null ? `£${fmtPrice(candidate.price, candidate.price_basis)}` : "-" }), _jsx("span", { className: "customer-review-option__state", children: selected ? "Selected" : "Choose" })] }, candidate.product_id));
|
|
742
742
|
}) })) : null] }), activeReviewRow.best ? (activeReviewHasPendingUpdate ? (_jsxs("div", { style: { display: "grid", gap: 8 }, children: [_jsx("button", { type: "button", className: "cta-btn customer-review-add", onClick: () => {
|
|
743
743
|
addReviewedProduct();
|
|
744
744
|
}, children: _jsx("span", { children: "Update product" }) }), _jsx("button", { type: "button", className: "cta-btn cta-btn-secondary customer-review-add", onClick: () => onRemoveFromCart(activeReviewRow.item.line_id), children: _jsx("span", { children: `Remove from ${customerCollection}` }) })] })) : (_jsx("button", { type: "button", className: `cta-btn customer-review-add${activeReviewCartMatches ? " cta-btn-secondary" : ""}`, onClick: () => {
|
|
@@ -759,7 +759,7 @@ export default function ScheduleResultsStage({ scheduleResult, onStartOver, cart
|
|
|
759
759
|
fontSize: 12,
|
|
760
760
|
fontWeight: 700,
|
|
761
761
|
lineHeight: 1,
|
|
762
|
-
}, children: "Partially matched" })) : null] })) : null, _jsx(
|
|
762
|
+
}, children: "Partially matched" })) : null] })) : null, _jsx(ProductImage, { className: "fill-img results-tile-card__image", description: preview?.description || item.description_original, photoUrl: preview?.pdp_photo_url })] }), _jsxs("div", { className: "results-tile-card__body", children: [preview ? (_jsx(_Fragment, { children: _jsx("div", { className: "results-tile-card__heading", children: _jsx("div", { className: "results-tile-card__title", children: preview.description }) }) })) : (_jsx("div", { className: "results-empty-hint", children: "No product currently selected for this line item." })), _jsx("div", { className: "results-tile-card__match", children: preview ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "results-tile-card__match-label", children: isPartial ? "↳ partially matched from" : "↳ matched from" }), _jsx("div", { className: "results-tile-card__match-name", children: displayLineLabel(item, lineItems) }), hasVisibleLinkedGroup(item) ? _jsx("div", { className: "results-row-meta", children: linkedGroupSummary(item) }) : null] })) : (_jsx("span", { className: "results-empty-hint", children: "No matched product selected." })) })] }), _jsx("span", { className: "results-tile-card__hover-prompt", "aria-hidden": "true", children: "Click to view options" })] }), _jsxs("div", { className: "results-tile-card__actions", children: [internal && (_jsx("button", { type: "button", className: "bom-toolbar-btn", onClick: () => openReview(item.line_id), children: "Review" })), _jsx("button", { type: "button", className: `bom-toolbar-btn${cart[item.line_id] ? " bom-toolbar-btn--added" : " bom-toolbar-btn--accent"}`, onClick: () => {
|
|
763
763
|
if (cart[item.line_id])
|
|
764
764
|
onRemoveFromCart(item.line_id);
|
|
765
765
|
else {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function ChevronIcon(): import("react").JSX.Element;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
export default function ChevronIcon() {
|
|
3
|
+
return (_jsx("svg", { width: "8", height: "5", viewBox: "0 0 8 5", fill: "none", children: _jsx("path", { d: "M1 4L4 1L7 4", stroke: "currentColor", strokeWidth: "1.4", strokeLinecap: "round", strokeLinejoin: "round" }) }));
|
|
4
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Stands in for an insulation product with no photo. slice crops it the way object-fit: cover
|
|
3
|
+
// crops the photos beside it.
|
|
4
|
+
export default function InsulationPlaceholder(props) {
|
|
5
|
+
return (_jsxs("svg", { viewBox: "0 0 640 420", preserveAspectRatio: "xMidYMid slice", xmlns: "http://www.w3.org/2000/svg", "aria-hidden": "true", ...props, children: [_jsx("rect", { width: "640", height: "420", fill: "#fbf1cf" }), _jsx("rect", { x: "88", y: "78", width: "432", height: "264", rx: "16", fill: "#f0c43f" }), _jsx("rect", { x: "120", y: "110", width: "368", height: "200", rx: "10", fill: "#ffd95e" }), _jsxs("g", { stroke: "#e2b12e", strokeWidth: "8", opacity: "0.75", children: [_jsx("line", { x1: "140", y1: "140", x2: "468", y2: "140" }), _jsx("line", { x1: "140", y1: "190", x2: "468", y2: "190" }), _jsx("line", { x1: "140", y1: "240", x2: "468", y2: "240" })] })] }));
|
|
6
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Stands in for any product with no photo. slice crops it the way object-fit: cover crops the
|
|
3
|
+
// photos beside it.
|
|
4
|
+
export default function ProductPlaceholder(props) {
|
|
5
|
+
return (_jsxs("svg", { viewBox: "0 0 640 420", preserveAspectRatio: "xMidYMid slice", xmlns: "http://www.w3.org/2000/svg", "aria-hidden": "true", ...props, children: [_jsx("rect", { width: "640", height: "420", fill: "#edf1f5" }), _jsx("rect", { x: "132", y: "108", width: "376", height: "204", rx: "18", fill: "#d5dde6" }), _jsx("rect", { x: "168", y: "144", width: "304", height: "24", rx: "12", fill: "#9aa6b2" }), _jsx("rect", { x: "168", y: "188", width: "250", height: "24", rx: "12", fill: "#aab5c0" }), _jsx("rect", { x: "168", y: "232", width: "220", height: "24", rx: "12", fill: "#aab5c0" })] }));
|
|
6
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function TrashIcon(): import("react").JSX.Element;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
export default function TrashIcon() {
|
|
3
|
+
return (_jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("polyline", { points: "3 6 5 6 21 6" }), _jsx("path", { d: "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" }), _jsx("line", { x1: "10", y1: "11", x2: "10", y2: "17" }), _jsx("line", { x1: "14", y1: "11", x2: "14", y2: "17" })] }));
|
|
4
|
+
}
|
|
@@ -9,7 +9,7 @@ import { useOptionalCart } from "../../lib/cart/CartContext";
|
|
|
9
9
|
import { candidateToCartItem } from "../../lib/boq/toCartItem";
|
|
10
10
|
import { estimateRequestUnit, fetchCatalogueStatus, resolveRequirements, searchCatalogue } from "../../lib/estimators/resolveProducts";
|
|
11
11
|
import { fmtCurrency, fmtQty } from "../../lib/formatters";
|
|
12
|
-
import
|
|
12
|
+
import ProductImage from "../../lib/ProductImage";
|
|
13
13
|
import { getPricingBreakdown } from "../../util/pricing";
|
|
14
14
|
const SORT_OPTIONS = [
|
|
15
15
|
{ dir: "asc", label: "Description (A-Z)" },
|
|
@@ -278,7 +278,7 @@ export default function EstimatorResults({ toolId, requirements, ready, onBack,
|
|
|
278
278
|
setSortConfig(active ? null : option.dir);
|
|
279
279
|
sortMenuRef.current?.removeAttribute("open");
|
|
280
280
|
}, children: [_jsx("span", { className: "sort-menu-item__check", children: active ? "✓" : "" }), option.label] }, option.dir));
|
|
281
|
-
}) })] })] }) }))] })), reviewRow && (_jsxs("div", { className: "results-review-page", children: [_jsxs("div", { className: "results-review-page__top", children: [_jsxs("button", { type: "button", className: "bom-toolbar-btn bom-toolbar-btn--accent", onClick: closeReview, children: [_jsx(BackArrow, {}), "Back to results"] }), _jsxs("div", { style: { display: "flex", gap: 8, flexWrap: "wrap" }, children: [_jsx("div", { className: "results-review-searchbar", children: _jsx("input", { type: "text", className: "results-search-input", placeholder: "Search products in catalogue...", value: reviewSearch, onChange: (event) => setReviewSearch(event.target.value), "aria-label": `Search a product for ${reviewRow.requirement.label}` }) }), reviewRow.chosen && (_jsx("div", { className: "results-candidate-actions", children: _jsx("button", { type: "button", className: "bom-toolbar-btn", onClick: () => clearChosen(reviewRow.requirement.key), children: "Clear selected product" }) }))] })] }), _jsxs("div", { className: "results-review-page__hero", children: [_jsx("div", { className: "results-review-page__image-wrap", children: _jsx(
|
|
281
|
+
}) })] })] }) }))] })), reviewRow && (_jsxs("div", { className: "results-review-page", children: [_jsxs("div", { className: "results-review-page__top", children: [_jsxs("button", { type: "button", className: "bom-toolbar-btn bom-toolbar-btn--accent", onClick: closeReview, children: [_jsx(BackArrow, {}), "Back to results"] }), _jsxs("div", { style: { display: "flex", gap: 8, flexWrap: "wrap" }, children: [_jsx("div", { className: "results-review-searchbar", children: _jsx("input", { type: "text", className: "results-search-input", placeholder: "Search products in catalogue...", value: reviewSearch, onChange: (event) => setReviewSearch(event.target.value), "aria-label": `Search a product for ${reviewRow.requirement.label}` }) }), reviewRow.chosen && (_jsx("div", { className: "results-candidate-actions", children: _jsx("button", { type: "button", className: "bom-toolbar-btn", onClick: () => clearChosen(reviewRow.requirement.key), children: "Clear selected product" }) }))] })] }), _jsxs("div", { className: "results-review-page__hero", children: [_jsx("div", { className: "results-review-page__image-wrap", children: _jsx(ProductImage, { className: "fill-img results-review-page__image", description: reviewRow.chosen?.description || reviewRow.requirement.label, photoUrl: reviewRow.chosen?.pdp_photo_url }) }), _jsxs("div", { className: "results-review-page__summary", children: [_jsx("h2", { children: reviewRow.requirement.label }), _jsxs("p", { className: "results-review-page__meta", children: [fmtQty(reviewRow.requirement.qty), " ", renderDisplayUnit(reviewRow.requirement.unit, reviewRow.requirement.qty)] }), reviewRow.chosen ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "results-review-page__product", children: reviewRow.chosen.description }), _jsxs("div", { className: "results-row-meta", children: [reviewRow.chosen.product_id, reviewRow.chosen.price != null ? ` · ${fmtPrice(reviewRow.chosen.price, reviewRow.chosen.price_basis)}` : ""] })] })) : (_jsx("div", { className: "results-empty-hint", children: "No product currently selected for this line." })), _jsx("div", { className: "results-review-page__actions", children: _jsx("button", { type: "button", className: `bom-toolbar-btn${cart?.cart[lineIdFor(reviewRow.requirement.key)] ? " bom-toolbar-btn--added" : " bom-toolbar-btn--accent"}`, onClick: () => toggleInCart(reviewRow), disabled: !reviewRow.chosen || !cart, children: cart?.cart[lineIdFor(reviewRow.requirement.key)] ? "✓ Added" : "+ Add to basket" }) })] })] }), reviewCandidates.length === 0 ? (_jsx("p", { className: "results-empty-hint", children: "No matching products." })) : (_jsxs("table", { className: "score-breakdown-table", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { children: "Product" }), _jsx("th", { children: "Dims (mm)" }), _jsx("th", { children: "Price" }), _jsx("th", { "aria-label": "Actions" })] }) }), _jsx("tbody", { children: reviewCandidates.map((candidate) => {
|
|
282
282
|
const isCurrent = candidate.product_id === reviewRow.chosen?.product_id;
|
|
283
283
|
return (_jsxs("tr", { className: isCurrent ? "score-breakdown-row--best" : "", children: [_jsxs("td", { children: [_jsx("div", { children: candidate.description }), _jsx("div", { className: "results-row-meta", children: candidate.product_id })] }), _jsx("td", { children: _jsx("span", { className: "unit-badge", children: fmtMeasurements(candidate.measurements_mm) }) }), _jsx("td", { children: candidate.price != null ? fmtPrice(candidate.price, candidate.price_basis) : "-" }), _jsx("td", { children: _jsxs("label", { className: "switch-toggle", title: isCurrent ? "Currently selected" : "Use this candidate", children: [_jsx("input", { type: "checkbox", checked: isCurrent, onChange: () => {
|
|
284
284
|
if (isCurrent)
|
|
@@ -290,7 +290,7 @@ export default function EstimatorResults({ toolId, requirements, ready, onBack,
|
|
|
290
290
|
const { requirement, chosen } = row;
|
|
291
291
|
const breakdown = chosen ? getPricingBreakdown(toCartLine(row), PRICING_OPTIONS) : null;
|
|
292
292
|
const inCart = cart ? Boolean(cart.cart[lineIdFor(requirement.key)]) : false;
|
|
293
|
-
return (_jsxs("article", { className: "results-tile-card", children: [_jsxs("button", { type: "button", className: "results-tile-card__surface", onClick: () => openReview(row), "aria-label": `Review match for ${requirement.label}`, children: [_jsx("div", { className: "results-tile-card__media", children: _jsx(
|
|
293
|
+
return (_jsxs("article", { className: "results-tile-card", children: [_jsxs("button", { type: "button", className: "results-tile-card__surface", onClick: () => openReview(row), "aria-label": `Review match for ${requirement.label}`, children: [_jsx("div", { className: "results-tile-card__media", children: _jsx(ProductImage, { className: "fill-img results-tile-card__image", description: chosen?.description || requirement.label, photoUrl: chosen?.pdp_photo_url }) }), _jsxs("div", { className: "results-tile-card__body", children: [chosen ? (_jsxs("div", { className: "results-tile-card__heading", children: [_jsx("div", { className: "results-tile-card__title", children: chosen.description }), _jsxs("div", { className: "results-tile-card__match-meta", children: [_jsx("span", { children: chosen.product_id }), chosen.price != null ? _jsx("span", { children: fmtPrice(chosen.price, chosen.price_basis) }) : null] })] })) : (_jsx("div", { className: "results-empty-hint", children: "No product matched for this line." })), _jsxs("div", { className: "results-tile-card__match", children: [_jsx("div", { className: "results-tile-card__match-label", children: "\u21B3 calculated as" }), _jsx("div", { className: "results-tile-card__match-name", children: requirement.label }), _jsxs("div", { className: "results-tile-card__meta", children: [fmtQty(requirement.qty), " ", renderDisplayUnit(requirement.unit, requirement.qty)] }), breakdown?.requiredSellUnits != null ? (_jsxs("div", { className: "results-tile-card__meta estimator-products__order", children: ["Order ", breakdown.requiredSellUnits, " ", breakdown.sellUnit, breakdown.lineTotal != null ? ` · ${fmtCurrency(breakdown.lineTotal)}` : ""] })) : chosen ? (_jsx("div", { className: "results-row-meta", children: "Quantity needs review" })) : null] })] })] }), _jsxs("div", { className: "results-tile-card__actions", children: [_jsx("button", { type: "button", className: "bom-toolbar-btn", onClick: () => openReview(row), children: "Review" }), _jsx("button", { type: "button", className: `bom-toolbar-btn${inCart ? " bom-toolbar-btn--added" : " bom-toolbar-btn--accent"}`, onClick: () => toggleInCart(row), disabled: !chosen || !cart, children: inCart ? "✓ Added" : "+ Add" })] })] }, requirement.key));
|
|
294
294
|
}) })), !reviewRow && !loading && !error && viewMode === "list" && matchedVisibleRows.length > 0 && (_jsx("div", { className: "bom-table-wrap", children: _jsxs("table", { className: "bom-table results-table", "aria-label": "Matched materials", children: [_jsxs("colgroup", { children: [_jsx("col", { style: { width: "56px" } }), _jsx("col", { className: "col-item" }), _jsx("col", { style: { width: "90px" } }), _jsx("col", { style: { width: "80px" } }), _jsx("col", { style: { width: "220px" } }), _jsx("col", { style: { width: "110px" } })] }), _jsx("thead", { children: _jsxs("tr", { className: "bom-col-header", children: [_jsx("th", { "aria-label": "Expand" }), _jsx("th", { style: { textAlign: "left" }, children: "Description" }), _jsx("th", { style: { textAlign: "center" }, children: "Qty" }), _jsx("th", { style: { textAlign: "center" }, children: "Unit" }), _jsx("th", { style: { textAlign: "center" }, children: "Product Matched" }), _jsx("th", { style: { textAlign: "center" }, children: "Basket" })] }) }), _jsx("tbody", { children: matchedVisibleRows.map((row) => {
|
|
295
295
|
const { requirement, chosen } = row;
|
|
296
296
|
const breakdown = chosen ? getPricingBreakdown(toCartLine(row), PRICING_OPTIONS) : null;
|