@metron-labs/quote-react 0.1.44 → 0.1.46
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/QuickQuote.js +33 -5
- package/dist/app/stages/BasketStage.d.ts +1 -1
- package/dist/app/stages/BasketStage.js +9 -4
- package/dist/app/stages/EntryStage.d.ts +13 -1
- package/dist/app/stages/EntryStage.js +89 -5
- package/dist/components/estimators/BrickBlockEstimator.js +9 -3
- package/dist/components/estimators/ConcreteAggregateEstimator.js +4 -2
- package/dist/components/estimators/PavingEstimator.js +26 -10
- package/dist/components/estimators/PlasterboardEstimator.js +8 -3
- package/dist/components/estimators/registry.js +3 -3
- package/dist/embed/quote.js +12 -12
- package/package.json +1 -1
package/dist/app/QuickQuote.js
CHANGED
|
@@ -6,6 +6,7 @@ import { TrackContext, useTracker } from "../lib/analytics";
|
|
|
6
6
|
import { submitSchedule, pollScheduleJob } from "../lib/api/fetchSchedule";
|
|
7
7
|
import { pollJob } from "../lib/api/pollJob";
|
|
8
8
|
import EntryStage from "./stages/EntryStage";
|
|
9
|
+
import { ENTRY_DRAFT_KEY } from "./stages/EntryStage";
|
|
9
10
|
import ScheduleResultsStage from "./stages/ScheduleResultsStage";
|
|
10
11
|
import BasketStage from "./stages/BasketStage";
|
|
11
12
|
import { CartProvider, useCart } from "../lib/cart/CartContext";
|
|
@@ -22,11 +23,22 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
|
|
|
22
23
|
const [entryMode, setEntryMode] = useState(null);
|
|
23
24
|
const [error, setError] = useState("");
|
|
24
25
|
const schedulePollCancelRef = useRef(null);
|
|
26
|
+
const matchRunRef = useRef(0);
|
|
25
27
|
const [processingStep, setProcessingStep] = useState(0);
|
|
26
28
|
const [scheduleResult, setScheduleResult] = useState(null);
|
|
27
29
|
const [scheduleReviewGateComplete, setScheduleReviewGateComplete] = useState(false);
|
|
28
30
|
const { cart, addToCart, addManyToCart, removeFromCart, updateCartQty, updateCartSellUnits, updateCartConversions, clearCart } = useCart();
|
|
29
31
|
const [cartSeeded, setCartSeeded] = useState(false);
|
|
32
|
+
// The file is memory only - a refresh loses it (a File can't go in sessionStorage). The tab
|
|
33
|
+
// and text survive one: EntryStage keeps them under ENTRY_DRAFT_KEY.
|
|
34
|
+
const [entryDraft, setEntryDraft] = useState(null);
|
|
35
|
+
const clearEntryDraft = () => {
|
|
36
|
+
setEntryDraft(null);
|
|
37
|
+
try {
|
|
38
|
+
window.sessionStorage.removeItem(ENTRY_DRAFT_KEY);
|
|
39
|
+
}
|
|
40
|
+
catch { }
|
|
41
|
+
};
|
|
30
42
|
const resolvedConversionInputs = useRef(new Map());
|
|
31
43
|
const rootRef = useRef(null);
|
|
32
44
|
const track = useTracker("quick-quote", rootRef);
|
|
@@ -184,8 +196,8 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
|
|
|
184
196
|
return;
|
|
185
197
|
// Our own steps always get our state back, even if a stamp slipped through earlier -
|
|
186
198
|
// otherwise it sticks and every later visit to that step is a Turbo page visit.
|
|
187
|
-
if (hash === "#quote-request" || hash === "#matched-products") {
|
|
188
|
-
window.history.replaceState({ boqQuoteStage: hash === "#quote-request" ? "basket" : "results" }, "", href);
|
|
199
|
+
if (hash === "#quote-request" || hash === "#matched-products" || hash === "#quote-entry") {
|
|
200
|
+
window.history.replaceState({ boqQuoteStage: hash === "#quote-request" ? "basket" : hash === "#quote-entry" ? "entry" : "results" }, "", href);
|
|
189
201
|
return;
|
|
190
202
|
}
|
|
191
203
|
if (!original?.turbo && window.history.state?.turbo)
|
|
@@ -233,12 +245,14 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
|
|
|
233
245
|
setScheduleReviewGateComplete(false);
|
|
234
246
|
clearCart();
|
|
235
247
|
setCartSeeded(false);
|
|
248
|
+
clearEntryDraft();
|
|
236
249
|
};
|
|
237
250
|
// Schedule-upload path: match a schedule file's line items against the
|
|
238
251
|
// catalogue on the backend. Takes the file directly, so
|
|
239
252
|
// callers that have only just obtained it — e.g. the entry-page paste box —
|
|
240
253
|
// don't hit a stale-closure read.
|
|
241
254
|
const runScheduleMatch = useCallback(async (file, providedLines, archiveSource = true) => {
|
|
255
|
+
const run = ++matchRunRef.current;
|
|
242
256
|
setError("");
|
|
243
257
|
setProcessingStep(0);
|
|
244
258
|
let step = 0;
|
|
@@ -248,6 +262,8 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
|
|
|
248
262
|
};
|
|
249
263
|
try {
|
|
250
264
|
const { jobId } = await submitSchedule(file, providedLines, archiveSource);
|
|
265
|
+
if (run !== matchRunRef.current)
|
|
266
|
+
return;
|
|
251
267
|
// Poll rather than hold one request open: the pipeline can take several minutes,
|
|
252
268
|
// well past Azure Static Web Apps' 45s API timeout, but each individual poll is fast.
|
|
253
269
|
const { promise, cancel } = pollJob(() => pollScheduleJob(jobId), 5000, advanceFakeStep, "schedule");
|
|
@@ -269,15 +285,26 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
|
|
|
269
285
|
setStage("results");
|
|
270
286
|
}
|
|
271
287
|
catch (err) {
|
|
288
|
+
if (run !== matchRunRef.current)
|
|
289
|
+
return;
|
|
272
290
|
track("error", { stage: "matching" });
|
|
273
291
|
setError(err?.message || "Failed to process schedule");
|
|
274
292
|
setStage("entry");
|
|
275
293
|
}
|
|
276
294
|
finally {
|
|
277
|
-
|
|
278
|
-
|
|
295
|
+
if (run === matchRunRef.current) {
|
|
296
|
+
schedulePollCancelRef.current?.();
|
|
297
|
+
schedulePollCancelRef.current = null;
|
|
298
|
+
}
|
|
279
299
|
}
|
|
280
300
|
}, [writeQuoteHistory, track, clearCart]);
|
|
301
|
+
// Browser back out of the dialog mid-match: drop the match, or its results would turn up
|
|
302
|
+
// later on a screen the user has already left. The job still finishes server-side, unread.
|
|
303
|
+
const cancelMatch = useCallback(() => {
|
|
304
|
+
matchRunRef.current++;
|
|
305
|
+
schedulePollCancelRef.current?.();
|
|
306
|
+
schedulePollCancelRef.current = null;
|
|
307
|
+
}, []);
|
|
281
308
|
// Entry-page "paste your line items" shortcut: skip the upload step entirely,
|
|
282
309
|
// wrap the pasted text as a .txt — verbatim, so the copy archived in blob storage reads
|
|
283
310
|
// exactly as pasted — and feed it straight into the same match pipeline.
|
|
@@ -294,10 +321,11 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
|
|
|
294
321
|
setEntryMode("schedule");
|
|
295
322
|
await runScheduleMatch(file);
|
|
296
323
|
}, [runScheduleMatch]);
|
|
297
|
-
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 }), 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: () => {
|
|
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: () => {
|
|
298
325
|
setScheduleResult(null);
|
|
299
326
|
setScheduleReviewGateComplete(false);
|
|
300
327
|
setCartSeeded(false);
|
|
301
328
|
clearCart();
|
|
329
|
+
clearEntryDraft();
|
|
302
330
|
}, internal: internal }))] }) }) }));
|
|
303
331
|
}
|
|
@@ -12,5 +12,5 @@ interface BasketStageProps {
|
|
|
12
12
|
onQuoteRequested?: () => void;
|
|
13
13
|
internal?: boolean;
|
|
14
14
|
}
|
|
15
|
-
export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onResolveConversions, onRemove, onBack, onStartNewQuote, onQuoteRequested, internal }: BasketStageProps): import("react").JSX.Element;
|
|
15
|
+
export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onResolveConversions, onRemove, onBack, onStartNewQuote, onQuoteRequested, internal, }: BasketStageProps): import("react").JSX.Element;
|
|
16
16
|
export {};
|
|
@@ -37,7 +37,7 @@ function TrashIcon() {
|
|
|
37
37
|
function ChevronIcon() {
|
|
38
38
|
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" }) }));
|
|
39
39
|
}
|
|
40
|
-
export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onResolveConversions, onRemove, onBack, onStartNewQuote, onQuoteRequested, internal = false }) {
|
|
40
|
+
export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onResolveConversions, onRemove, onBack, onStartNewQuote, onQuoteRequested, internal = false, }) {
|
|
41
41
|
// Both widgets can render a basket on the same page, so element ids are made unique.
|
|
42
42
|
const uid = useId();
|
|
43
43
|
const qzaBasketLayout = !internal && getBoqClientId() === "QZA6-MRA4";
|
|
@@ -191,7 +191,9 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
|
|
|
191
191
|
// The API's own message is written for developers (e.g. "items.0: Unrecognized key(s)"),
|
|
192
192
|
// so the customer gets a plain one and the console keeps the detail.
|
|
193
193
|
console.error("Quote request failed", res.status, payload?.message);
|
|
194
|
-
setQuoteContactError(res.status === 429
|
|
194
|
+
setQuoteContactError(res.status === 429
|
|
195
|
+
? "You've sent a few requests already. Please try again in a few minutes."
|
|
196
|
+
: "Your request couldn't be sent. Please try again, or contact us if it keeps happening.");
|
|
195
197
|
return;
|
|
196
198
|
}
|
|
197
199
|
track("quote_requested", { items: pricedItems.length, total: Math.round(total * 100) / 100 });
|
|
@@ -237,7 +239,7 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
|
|
|
237
239
|
return (_jsx("div", { className: `wizard-step entry-stage${qzaBasketLayout ? " basket-layout--qza6-mra4" : ""}`, children: _jsxs("div", { className: `quick-order-card${internal ? "" : " quick-order-card--customer"}`, children: [_jsxs("div", { className: `quick-order-hero${internal ? "" : " quick-order-hero--customer"}`, children: [internal && _jsx("div", { className: "quick-order-eyebrow", children: "Construction Services" }), _jsxs("div", { className: "basket-hero-copy", children: [qzaBasketLayout && (_jsxs("button", { type: "button", className: "cta-btn cta-btn-secondary basket-hero-back", onClick: onBack, children: [_jsx(BackArrow, {}), _jsx("span", { children: "Back to results" })] })), _jsxs("div", { className: "quick-order-hero-title-row", children: [_jsx("h1", { children: internal ? "Your Selection" : qzaBasketLayout ? "Request a quote" : "Your Basket" }), internal && (_jsxs("span", { className: "summary-chip summary-chip--scope", children: [_jsx("strong", { children: items.length }), " ", items.length === 1 ? "item" : "items"] }))] }), _jsx("p", { children: internal
|
|
238
240
|
? "Review the items you've added before exporting them for review."
|
|
239
241
|
: qzaBasketLayout
|
|
240
|
-
? "Check your items
|
|
242
|
+
? "Check your items and quantities below, and we'll do our best to beat list prices."
|
|
241
243
|
: "Review your quantities and order summary before you buy." })] })] }), _jsx("div", { className: "quick-order-panel", style: { paddingTop: 16 }, children: items.length === 0 ? (_jsxs("div", { className: "quick-order-history-empty", children: [_jsx("div", { className: "basket-empty-icon", "aria-hidden": "true", children: "\uD83D\uDCCB" }), _jsx("p", { children: "Your selection is empty." }), _jsx("p", { className: "basket-empty-hint", children: "Go back to your results and add quoted items." }), _jsx("div", { className: "btn-row", style: { marginTop: 20, justifyContent: "center" }, children: _jsx("button", { className: "cta-btn", style: { width: "auto", maxWidth: 280 }, onClick: onBack, "aria-label": "Back to results", children: _jsx("span", { children: "Back to Results" }) }) })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "basket-layout", children: [_jsxs("div", { className: "basket-main", children: [_jsx("div", { className: "basket-list", "aria-label": internal ? "Selection items" : qzaBasketLayout ? "Quote request items" : "Basket items", children: pagedItems.map(({ item, pricing }) => {
|
|
242
244
|
const requestedQtyValue = qtyDrafts[item.lineId] ?? String(pricing.requestedAmount);
|
|
243
245
|
const requestedLocked = pricing.requestedUnit === pricing.sellUnit || pricing.costingMode === "review";
|
|
@@ -262,7 +264,10 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
|
|
|
262
264
|
setReference(e.target.value);
|
|
263
265
|
if (qzaBasketLayout)
|
|
264
266
|
setQuoteContactError("");
|
|
265
|
-
}, 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: () => {
|
|
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: () => {
|
|
268
|
+
track("downloaded", { type: "basket", items: items.length });
|
|
269
|
+
void exportBasketExcel(items);
|
|
270
|
+
}, "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
|
|
266
271
|
? "This is a quote request, not an order. We'll confirm our best price, availability and delivery before you approve."
|
|
267
272
|
: "Prices and availability are estimates and should be confirmed before procurement."] })] })] }), _jsx("div", { className: "btn-row results-actions", children: _jsxs("button", { className: "cta-btn cta-btn-secondary", style: { flex: "0 1 30%", gap: 10, justifyContent: "center" }, onClick: onBack, "aria-label": "Back to results", children: [_jsx(BackArrow, {}), _jsx("span", { children: "Back to results" })] }) })] })) })] }) }));
|
|
268
273
|
}
|
|
@@ -3,6 +3,18 @@ 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
|
+
draft?: EntryDraft | null;
|
|
8
|
+
onSubmitDraft?: (draft: EntryDraft) => void;
|
|
9
|
+
/** Browser back out of the dialog mid-match - drop the match in flight. */
|
|
10
|
+
onCancelMatch?: () => void;
|
|
6
11
|
}
|
|
7
|
-
|
|
12
|
+
type QuickOrderTab = "paste" | "upload";
|
|
13
|
+
export interface EntryDraft {
|
|
14
|
+
tab: QuickOrderTab;
|
|
15
|
+
text: string;
|
|
16
|
+
file: File | null;
|
|
17
|
+
}
|
|
18
|
+
export declare const ENTRY_DRAFT_KEY = "metron-quick-quote-entry";
|
|
19
|
+
export default function EntryStage({ onPasteSchedule, onUploadSchedule, onEntryModeChange, draft, onSubmitDraft, onCancelMatch }: EntryStageProps): import("react").JSX.Element;
|
|
8
20
|
export {};
|
|
@@ -22,16 +22,21 @@ const STEPS = {
|
|
|
22
22
|
{ title: "Review Quote", desc: "Review the matched materials for your quote." },
|
|
23
23
|
],
|
|
24
24
|
};
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
// The open dialog is its own history step, so browser back closes it rather than leaving the page.
|
|
26
|
+
const DIALOG_HASH = "#quote-entry";
|
|
27
|
+
export const ENTRY_DRAFT_KEY = "metron-quick-quote-entry";
|
|
28
|
+
export default function EntryStage({ onPasteSchedule, onUploadSchedule, onEntryModeChange, draft, onSubmitDraft, onCancelMatch }) {
|
|
29
|
+
const [activeTab, setActiveTab] = useState(() => (draft && typeof window !== "undefined" && window.location.hash === DIALOG_HASH ? draft.tab : null));
|
|
27
30
|
const track = useTrack();
|
|
28
|
-
const [pastedText, setPastedText] = useState("");
|
|
29
|
-
const [uploadFile, setUploadFile] = useState(null);
|
|
31
|
+
const [pastedText, setPastedText] = useState(draft?.text ?? "");
|
|
32
|
+
const [uploadFile, setUploadFile] = useState(draft?.file ?? null);
|
|
30
33
|
const [uploadError, setUploadError] = useState("");
|
|
31
34
|
const [matching, setMatching] = useState(false);
|
|
32
35
|
const [pageDragOver, setPageDragOver] = useState(false);
|
|
33
36
|
const [photoPreviewUrl, setPhotoPreviewUrl] = useState(null);
|
|
34
37
|
const dragDepth = useRef(0);
|
|
38
|
+
// Bumped on each submit and on cancel; a match that finds it moved on was cancelled.
|
|
39
|
+
const matchRun = useRef(0);
|
|
35
40
|
const fileInputRef = useRef(null);
|
|
36
41
|
const lineCount = pastedText.split("\n").filter((line) => line.trim()).length;
|
|
37
42
|
const pasteLineProblem = validateLines(pastedText);
|
|
@@ -45,14 +50,77 @@ export default function EntryStage({ onPasteSchedule, onUploadSchedule, onEntryM
|
|
|
45
50
|
setPhotoPreviewUrl(url);
|
|
46
51
|
return () => URL.revokeObjectURL(url);
|
|
47
52
|
}, [uploadFile]);
|
|
53
|
+
// Back closes the dialog, forward reopens it on the tab it was showing.
|
|
54
|
+
const lastTab = useRef(activeTab ?? draft?.tab ?? null);
|
|
55
|
+
if (activeTab)
|
|
56
|
+
lastTab.current = activeTab;
|
|
57
|
+
// A refresh on the dialog's step reopens it with its tab and text; the file can't come back.
|
|
58
|
+
// After mount, not in the initial state, so a server-rendered host doesn't hit a hydration mismatch.
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (draft || window.location.hash !== DIALOG_HASH)
|
|
61
|
+
return;
|
|
62
|
+
let saved = null;
|
|
63
|
+
try {
|
|
64
|
+
saved = JSON.parse(window.sessionStorage.getItem(ENTRY_DRAFT_KEY) ?? "null");
|
|
65
|
+
}
|
|
66
|
+
catch { }
|
|
67
|
+
if (!saved?.tab) {
|
|
68
|
+
// Nothing to reopen (a copied link in a new tab, or storage cleared): drop the hash, or
|
|
69
|
+
// the dialog would reuse this step and closing it would go back off the page.
|
|
70
|
+
const url = new URL(window.location.href);
|
|
71
|
+
url.hash = "";
|
|
72
|
+
window.history.replaceState(window.history.state, "", url);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
lastTab.current = saved.tab;
|
|
76
|
+
setActiveTab(saved.tab);
|
|
77
|
+
setPastedText(saved.text ?? "");
|
|
78
|
+
}, []);
|
|
79
|
+
// Only while the dialog is open: the closed screen's empty state would overwrite the
|
|
80
|
+
// saved draft before the effect above reads it on a StrictMode remount.
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (!activeTab)
|
|
83
|
+
return;
|
|
84
|
+
try {
|
|
85
|
+
window.sessionStorage.setItem(ENTRY_DRAFT_KEY, JSON.stringify({ tab: activeTab, text: pastedText }));
|
|
86
|
+
}
|
|
87
|
+
catch { }
|
|
88
|
+
}, [activeTab, pastedText]);
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
const onPopState = () => {
|
|
91
|
+
if (window.location.hash === DIALOG_HASH)
|
|
92
|
+
return setActiveTab(lastTab.current);
|
|
93
|
+
setActiveTab(null);
|
|
94
|
+
// Backing out of the dialog cancels a match in progress (a no-op when there isn't one);
|
|
95
|
+
// forward reopens the dialog with the input, ready to submit again.
|
|
96
|
+
matchRun.current++;
|
|
97
|
+
setMatching(false);
|
|
98
|
+
onCancelMatch?.();
|
|
99
|
+
};
|
|
100
|
+
window.addEventListener("popstate", onPopState);
|
|
101
|
+
return () => window.removeEventListener("popstate", onPopState);
|
|
102
|
+
}, []);
|
|
48
103
|
const openWorkflow = (tab) => {
|
|
49
104
|
track("method_selected", { method: tab });
|
|
50
105
|
setActiveTab(tab);
|
|
51
106
|
onEntryModeChange(null);
|
|
107
|
+
const hash = window.location.hash;
|
|
108
|
+
if (hash === DIALOG_HASH)
|
|
109
|
+
return;
|
|
110
|
+
const url = new URL(window.location.href);
|
|
111
|
+
url.hash = DIALOG_HASH;
|
|
112
|
+
// Our own state only, as in QuickQuote's writeQuoteHistory. A results/basket hash left
|
|
113
|
+
// behind by "start over" is reused, or back would land on that stale step.
|
|
114
|
+
const stale = hash === "#matched-products" || hash === "#quote-request";
|
|
115
|
+
window.history[stale ? "replaceState" : "pushState"]({ boqQuoteStage: "entry" }, "", url);
|
|
52
116
|
};
|
|
53
117
|
const closeWorkflow = () => {
|
|
54
118
|
setActiveTab(null);
|
|
55
119
|
onEntryModeChange(null);
|
|
120
|
+
// Drop the dialog's step too. Closing hides the dialog straight away, so a second
|
|
121
|
+
// Escape/click can't step back again past the page.
|
|
122
|
+
if (window.location.hash === DIALOG_HASH)
|
|
123
|
+
window.history.back();
|
|
56
124
|
};
|
|
57
125
|
// Like the close button, but not mid-match, where the modal is showing progress.
|
|
58
126
|
useEscapeKey(Boolean(activeTab) && !matching, closeWorkflow);
|
|
@@ -70,15 +138,26 @@ export default function EntryStage({ onPasteSchedule, onUploadSchedule, onEntryM
|
|
|
70
138
|
setUploadFile(file);
|
|
71
139
|
};
|
|
72
140
|
const startMatching = async (run) => {
|
|
141
|
+
// Keep only what was submitted: stepping back to an image's dialog shouldn't still
|
|
142
|
+
// hold an earlier pasted list, or the other way round.
|
|
143
|
+
if (activeTab === "paste")
|
|
144
|
+
setUploadFile(null);
|
|
145
|
+
if (activeTab === "upload")
|
|
146
|
+
setPastedText("");
|
|
147
|
+
if (activeTab)
|
|
148
|
+
onSubmitDraft?.({ tab: activeTab, text: activeTab === "paste" ? pastedText : "", file: activeTab === "upload" ? uploadFile : null });
|
|
149
|
+
const id = ++matchRun.current;
|
|
73
150
|
setMatching(true);
|
|
74
151
|
try {
|
|
75
152
|
await run();
|
|
76
153
|
}
|
|
77
154
|
finally {
|
|
78
|
-
|
|
155
|
+
if (id === matchRun.current)
|
|
156
|
+
setMatching(false);
|
|
79
157
|
}
|
|
80
158
|
};
|
|
81
159
|
const matchPhoto = async (file) => {
|
|
160
|
+
const id = matchRun.current;
|
|
82
161
|
try {
|
|
83
162
|
const photo = await shrinkPhoto(file);
|
|
84
163
|
const problem = validateUploadFile(photo);
|
|
@@ -96,9 +175,14 @@ export default function EntryStage({ onPasteSchedule, onUploadSchedule, onEntryM
|
|
|
96
175
|
const lines = Array.isArray(payload?.lines)
|
|
97
176
|
? payload.lines.filter((line) => Boolean(line && typeof line === "object" && typeof line.text === "string"))
|
|
98
177
|
: undefined;
|
|
178
|
+
// Cancelled while the photo was being read - don't start matching it.
|
|
179
|
+
if (id !== matchRun.current)
|
|
180
|
+
return;
|
|
99
181
|
await onPasteSchedule(text, lines, false);
|
|
100
182
|
}
|
|
101
183
|
catch (error) {
|
|
184
|
+
if (id !== matchRun.current)
|
|
185
|
+
return;
|
|
102
186
|
track("error", { stage: "photo" });
|
|
103
187
|
setUploadError(error?.message || "Failed to extract line items from image");
|
|
104
188
|
}
|
|
@@ -26,7 +26,9 @@ export default function BrickBlockEstimator({ onBack, onReview, submitLabel = "A
|
|
|
26
26
|
const requirements = [
|
|
27
27
|
{
|
|
28
28
|
key: masonryType === "brick" ? `brick-${brickOption}` : "block",
|
|
29
|
-
label: masonryType === "brick"
|
|
29
|
+
label: masonryType === "brick"
|
|
30
|
+
? { common: "Common concrete bricks", "engineering-red": "Red engineering bricks", "engineering-blue": "Blue engineering bricks" }[brickOption]
|
|
31
|
+
: "Blocks",
|
|
30
32
|
qty: units,
|
|
31
33
|
unit: "each",
|
|
32
34
|
query: masonryType === "brick" ? "facing brick" : "concrete block",
|
|
@@ -42,8 +44,12 @@ export default function BrickBlockEstimator({ onBack, onReview, submitLabel = "A
|
|
|
42
44
|
setWidth((toNumber(width) * factor).toFixed(2));
|
|
43
45
|
setUnit(nextUnit);
|
|
44
46
|
};
|
|
45
|
-
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: "Brick
|
|
47
|
+
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: "Brick and Block Calculator" }), _jsx("p", { children: "Estimate masonry units, sand and cement for a wall." })] }), _jsxs("div", { className: "truss-estimator__card brick-calculator", children: [_jsx("h2", { className: "truss-estimator__step-title", children: "Wall area and masonry type" }), _jsx("p", { className: "truss-estimator__step-sub", children: "Pick brick or block and enter the wall dimensions for unit counts plus ready-mixed mortar." }), _jsxs("div", { className: "brick-calculator__section", children: [_jsx("div", { className: "truss-estimator__section-heading", children: "Masonry type" }), _jsx("div", { className: "truss-estimator__option-grid truss-estimator__option-grid--two", children: [
|
|
46
48
|
["brick", "Brick"],
|
|
47
49
|
["block", "Block"],
|
|
48
|
-
].map(([id, label]) => (_jsxs("button", { type: "button", className: `truss-estimator__option-card${masonryType === id ? " is-selected" : ""}`, onClick: () => setMasonryType(id), children: [_jsx("div", { className: "truss-estimator__option-title", children: label }), _jsxs("div", { className: "truss-estimator__option-copy", children: ["Approx. ", id === "brick" ? BRICK_COVERAGE : BLOCK_COVERAGE, " per m\u00B2"] })] }, id))) })] }), masonryType === "brick" && _jsxs("div", { className: "brick-calculator__section", children: [_jsx("div", { className: "truss-estimator__section-heading", children: "Brick type" }), _jsx("div", { className: "truss-estimator__option-grid truss-estimator__option-grid--three", children: [
|
|
50
|
+
].map(([id, label]) => (_jsxs("button", { type: "button", className: `truss-estimator__option-card${masonryType === id ? " is-selected" : ""}`, onClick: () => setMasonryType(id), children: [_jsx("div", { className: "truss-estimator__option-title", children: label }), _jsxs("div", { className: "truss-estimator__option-copy", children: ["Approx. ", id === "brick" ? BRICK_COVERAGE : BLOCK_COVERAGE, " per m\u00B2"] })] }, id))) })] }), masonryType === "brick" && (_jsxs("div", { className: "brick-calculator__section", children: [_jsx("div", { className: "truss-estimator__section-heading", children: "Brick type" }), _jsx("div", { className: "truss-estimator__option-grid truss-estimator__option-grid--three", children: [
|
|
51
|
+
["common", "Common concrete", "General-purpose grey concrete brick"],
|
|
52
|
+
["engineering-red", "Engineering red", "Class B perforated, 65mm"],
|
|
53
|
+
["engineering-blue", "Engineering blue", "Class B solid, 65mm"],
|
|
54
|
+
].map(([id, label, blurb]) => (_jsxs("button", { type: "button", className: `truss-estimator__option-card${brickOption === id ? " is-selected" : ""}`, onClick: () => setBrickOption(id), children: [_jsx("div", { className: "truss-estimator__option-title", children: label }), _jsx("div", { className: "truss-estimator__option-copy", children: blurb })] }, id))) })] })), _jsxs("div", { className: "brick-calculator__section", children: [_jsx("div", { className: "truss-estimator__section-heading", children: "Wall dimensions" }), _jsxs("div", { className: "brick-calculator__unit-toggle", role: "group", "aria-label": "Measurement units", children: [_jsx("button", { type: "button", className: unit === "metric" ? "is-selected" : "", onClick: () => changeUnit("metric"), children: "Metric" }), _jsx("button", { type: "button", className: unit === "imperial" ? "is-selected" : "", onClick: () => changeUnit("imperial"), children: "Imperial" })] }), _jsxs("div", { className: "truss-estimator__field-grid", children: [_jsxs("label", { className: "truss-estimator__field", children: [_jsxs("span", { children: ["Height (", unit === "metric" ? "m" : "ft", ")"] }), _jsx("input", { type: "number", min: "0", step: "0.01", value: height, onChange: (event) => setHeight(event.target.value) })] }), _jsxs("label", { className: "truss-estimator__field", children: [_jsxs("span", { children: ["Width (", unit === "metric" ? "m" : "ft", ")"] }), _jsx("input", { type: "number", min: "0", step: "0.01", value: width, onChange: (event) => setWidth(event.target.value) })] })] })] }), _jsx("div", { className: "brick-calculator__options", children: _jsxs("label", { className: "brick-calculator__checkbox", children: [_jsx("input", { type: "checkbox", checked: addMortar, onChange: (event) => setAddMortar(event.target.checked) }), _jsx("span", { children: "Include ready-mixed mortar" })] }) }), _jsxs("div", { className: "brick-calculator__totals", children: [_jsx("div", { className: "truss-estimator__section-heading", children: "Totals" }), _jsxs("div", { className: "brick-calculator__result-grid", children: [_jsxs("div", { children: [_jsx("span", { children: "Wall area" }), _jsxs("strong", { children: [formatNumber(displayedArea), " ", unit === "metric" ? "m²" : "ft²"] })] }), _jsxs("div", { children: [_jsx("span", { children: masonryType === "brick" ? "Bricks" : "Blocks" }), _jsx("strong", { children: units.toLocaleString("en-GB") })] }), addMortar ? (_jsxs("div", { children: [_jsx("span", { children: "Ready-mixed mortar" }), _jsxs("strong", { children: [mortarBags, " x 20kg bags"] })] })) : null] }), _jsx("p", { className: "brick-calculator__note", children: "Estimate based on standard nominal sizes and 10mm joints. Mortar bags are approximate; confirm the required mortar specification before ordering." })] }), _jsxs("div", { className: "brick-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: areaM2 <= 0, children: submitLabel })] })] })] }) }));
|
|
49
55
|
}
|
|
@@ -93,7 +93,9 @@ export default function ConcreteAggregateEstimator({ onBack, onReview, submitLab
|
|
|
93
93
|
}
|
|
94
94
|
};
|
|
95
95
|
if (adding) {
|
|
96
|
-
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: "Your Materials" }), _jsx("p", { children: "Calculating quantities from the Concrete
|
|
96
|
+
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: "Your Materials" }), _jsx("p", { children: "Calculating quantities from the Concrete and Aggregate Calculator." })] }), _jsx("div", { className: "truss-estimator__card", children: _jsxs("div", { className: "processing-container processing-variant-banter", role: "status", "aria-label": "Loading", style: { flex: 1, justifyContent: "center" }, children: [_jsx(BrickWall, {}), _jsx("p", { className: "banter-message is-visible", children: "Preparing your materials\u2026" })] }) })] }) }));
|
|
97
97
|
}
|
|
98
|
-
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: "Concrete
|
|
98
|
+
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: "Concrete and Aggregate Calculator" }), _jsx("p", { children: "Calculate fixed site-mix concrete ingredients or aggregate bulk bags." })] }), _jsxs("div", { className: "truss-estimator__card materials-calculator", children: [_jsx("h2", { className: "truss-estimator__step-title", children: "Choose what you need" }), _jsx("p", { className: "truss-estimator__step-sub", children: "Enter the area and depth. The calculator adds the fixed Materials Market products directly to your basket." }), _jsxs("div", { className: "materials-calculator__section", children: [_jsx("div", { className: "truss-estimator__section-heading", children: "Material type" }), _jsxs("div", { className: "truss-estimator__option-grid truss-estimator__option-grid--two", children: [_jsxs("button", { type: "button", className: `truss-estimator__option-card${group === "concrete" ? " is-selected" : ""}`, onClick: () => setGroup("concrete"), children: [_jsx("div", { className: "truss-estimator__option-title", children: "Site-mixed concrete" }), _jsx("div", { className: "truss-estimator__option-copy", children: "Cement, sharp sand and 20mm aggregate" })] }), _jsxs("button", { type: "button", className: `truss-estimator__option-card${group === "aggregate" ? " is-selected" : ""}`, onClick: () => setGroup("aggregate"), children: [_jsx("div", { className: "truss-estimator__option-title", children: "Aggregates" }), _jsx("div", { className: "truss-estimator__option-copy", children: "MOT Type 1, sharp sand or 20mm gravel" })] })] })] }), group === "aggregate" && (_jsxs("div", { className: "materials-calculator__section", children: [_jsx("div", { className: "truss-estimator__section-heading", children: "Aggregate type" }), _jsx("div", { className: "truss-estimator__option-grid truss-estimator__option-grid--two", children: Object.keys(aggregateDetails).map((id) => (_jsxs("button", { type: "button", className: `truss-estimator__option-card${aggregate === id ? " is-selected" : ""}`, onClick: () => setAggregate(id), children: [_jsx("div", { className: "truss-estimator__option-title", children: aggregateDetails[id].label }), _jsx("div", { className: "truss-estimator__option-copy", children: aggregateDetails[id].blurb })] }, id))) })] })), _jsxs("div", { className: "materials-calculator__section", children: [_jsx("div", { className: "truss-estimator__section-heading", children: "Area and depth" }), _jsxs("div", { className: "materials-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: "Metric" }), _jsx("button", { type: "button", className: unit === "ft" ? "is-selected" : "", onClick: () => setUnit("ft"), children: "Imperial" })] })] }), _jsxs("div", { className: "truss-estimator__field-grid", children: [_jsxs("label", { className: "truss-estimator__field", children: [_jsxs("span", { children: ["Length (", unit, ")"] }), _jsx("input", { type: "number", min: "0", step: "0.01", value: length, onChange: (event) => setLength(event.target.value) })] }), _jsxs("label", { className: "truss-estimator__field", children: [_jsxs("span", { children: ["Width (", unit, ")"] }), _jsx("input", { type: "number", min: "0", step: "0.01", value: width, onChange: (event) => setWidth(event.target.value) })] })] }), _jsxs("label", { className: "truss-estimator__field materials-calculator__depth", children: [_jsxs("span", { children: ["Depth (", unit === "m" ? "cm" : "inches", ")"] }), _jsx("input", { type: "number", min: "0", step: "0.1", value: depth, onChange: (event) => setDepth(event.target.value) })] })] }), _jsxs("div", { className: "materials-calculator__totals", children: [_jsx("div", { className: "truss-estimator__section-heading", children: "Materials required" }), _jsx("div", { className: "materials-calculator__result-grid", children: group === "concrete" ? (_jsxs(_Fragment, { children: [_jsxs("div", { children: [_jsx("span", { children: "Cement" }), _jsxs("strong", { children: [cementKg.toFixed(0), " kg"] })] }), _jsxs("div", { children: [_jsx("span", { children: "Sharp sand" }), _jsxs("strong", { children: [sandVolumeM3.toFixed(2), " m\u00B3"] })] }), _jsxs("div", { children: [_jsx("span", { children: "20mm aggregate" }), _jsxs("strong", { children: [concreteAggregateVolumeM3.toFixed(2), " m\u00B3"] })] })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { children: [_jsx("span", { children: selectedAggregate.label }), _jsxs("strong", { children: [volumeM3.toFixed(2), " m\u00B3"] })] }), _jsxs("div", { children: [_jsx("span", { children: "Approx. weight" }), _jsxs("strong", { children: [(volumeM3 * selectedAggregate.density).toFixed(2), " t"] })] }), _jsx("div", {})] })) }), _jsx("p", { className: "materials-calculator__note", children: group === "concrete"
|
|
99
|
+
? "Based on a 1:2:4 mix. Confirm the mix design for structural work."
|
|
100
|
+
: "Figures are estimates. Confirm the specification and supplier quantities before ordering." })] }), basketError && _jsx("p", { className: "results-empty-hint estimator-products__error", children: basketError }), _jsxs("div", { className: "materials-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: submit, disabled: volumeM3 <= 0 || adding, children: adding ? "Adding products…" : submitLabel })] })] })] }) }));
|
|
99
101
|
}
|
|
@@ -3,8 +3,14 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
3
3
|
import { useState } from "react";
|
|
4
4
|
const toNumber = (value) => Number.parseFloat(value) || 0;
|
|
5
5
|
const FINISHES = {
|
|
6
|
-
paving: [
|
|
7
|
-
|
|
6
|
+
paving: [
|
|
7
|
+
{ id: "council", label: "Council paving slab (600 × 600mm)" },
|
|
8
|
+
{ id: "raj", label: "Raj blend sandstone (600 × 600mm)" },
|
|
9
|
+
],
|
|
10
|
+
block: [
|
|
11
|
+
{ id: "brindle", label: "Brindle block paving (60mm)" },
|
|
12
|
+
{ id: "charcoal", label: "Charcoal block paving (60mm)" },
|
|
13
|
+
],
|
|
8
14
|
gravel: [{ id: "pea", label: "20mm pea shingle" }],
|
|
9
15
|
};
|
|
10
16
|
export default function PavingEstimator({ onBack, onReview, submitLabel = "Add to basket" }) {
|
|
@@ -26,17 +32,27 @@ export default function PavingEstimator({ onBack, onReview, submitLabel = "Add t
|
|
|
26
32
|
const motBags = Math.ceil((subBaseVolume * 1700) / 800);
|
|
27
33
|
const sandBags = Math.ceil((beddingVolume * 1600) / 800);
|
|
28
34
|
const gravelBags = Math.ceil((gravelVolume * 1600) / 800);
|
|
29
|
-
const surfaceOrder = surface === "paving"
|
|
30
|
-
? { qty: slabs, unit: "each" }
|
|
31
|
-
: surface === "block"
|
|
32
|
-
? { qty: pavingArea, unit: "m2" }
|
|
33
|
-
: { qty: gravelBags, unit: "bag" };
|
|
35
|
+
const surfaceOrder = surface === "paving" ? { qty: slabs, unit: "each" } : surface === "block" ? { qty: pavingArea, unit: "m2" } : { qty: gravelBags, unit: "bag" };
|
|
34
36
|
const surfaceQuery = { paving: "paving slab", block: "block paver", gravel: "decorative gravel" }[surface];
|
|
35
37
|
const requirements = [
|
|
36
|
-
{
|
|
38
|
+
{
|
|
39
|
+
key: `surface-${finish}`,
|
|
40
|
+
label: FINISHES[surface].find((item) => item.id === finish)?.label ?? "Surface material",
|
|
41
|
+
qty: surfaceOrder.qty,
|
|
42
|
+
unit: surfaceOrder.unit,
|
|
43
|
+
query: surfaceQuery,
|
|
44
|
+
category: "landscaping",
|
|
45
|
+
},
|
|
37
46
|
...(includeSubBase ? [{ key: "sub-base", label: "MOT Type 1 sub-base", qty: motBags, unit: "bag", query: "MOT Type 1 sub base", category: "aggregate" }] : []),
|
|
38
47
|
...(includeBedding ? [{ key: "bedding", label: "Sharp sand bedding", qty: sandBags, unit: "bag", query: "sharp sand", category: "aggregate" }] : []),
|
|
39
48
|
];
|
|
40
|
-
const options = [
|
|
41
|
-
|
|
49
|
+
const options = [
|
|
50
|
+
["paving", "Paving slabs", "For patios and paths"],
|
|
51
|
+
["block", "Block paving", "For driveways and paths"],
|
|
52
|
+
["gravel", "Decorative gravel", "For paths and landscaping"],
|
|
53
|
+
];
|
|
54
|
+
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: "Paving and Driveway Calculator" }), _jsx("p", { children: "Estimate paving, block paving, sub-base and bedding materials for outdoor areas." })] }), _jsxs("div", { className: "truss-estimator__card materials-calculator", children: [_jsx("h2", { className: "truss-estimator__step-title", children: "Surface and ground build-up" }), _jsx("p", { className: "truss-estimator__step-sub", children: "Choose the surface material and enter the area. Sub-base and bedding sand are estimated alongside the surface units." }), _jsxs("div", { className: "materials-calculator__section", children: [_jsx("div", { className: "truss-estimator__section-heading", children: "Surface material" }), _jsx("div", { className: "truss-estimator__option-grid truss-estimator__option-grid--three", children: options.map(([id, label, blurb]) => (_jsxs("button", { type: "button", className: `truss-estimator__option-card${surface === id ? " is-selected" : ""}`, onClick: () => {
|
|
55
|
+
setSurface(id);
|
|
56
|
+
setFinish(FINISHES[id][0].id);
|
|
57
|
+
}, children: [_jsx("div", { className: "truss-estimator__option-title", children: label }), _jsx("div", { className: "truss-estimator__option-copy", children: blurb })] }, id))) }), _jsxs("label", { className: "truss-estimator__field materials-calculator__depth", children: [_jsx("span", { children: "Finish" }), _jsx("select", { value: finish, onChange: (event) => setFinish(event.target.value), children: FINISHES[surface].map((option) => (_jsx("option", { value: option.id, children: option.label }, option.id))) })] })] }), _jsxs("div", { className: "materials-calculator__section", children: [_jsx("div", { className: "truss-estimator__section-heading", children: "Area and groundwork" }), _jsxs("div", { className: "truss-estimator__field-grid", children: [_jsxs("label", { className: "truss-estimator__field", children: [_jsx("span", { children: "Length (m)" }), _jsx("input", { type: "number", min: "0", step: "0.01", value: length, onChange: (event) => setLength(event.target.value) })] }), _jsxs("label", { className: "truss-estimator__field", children: [_jsx("span", { children: "Width (m)" }), _jsx("input", { type: "number", min: "0", step: "0.01", value: width, onChange: (event) => setWidth(event.target.value) })] })] }), surface === "gravel" && (_jsxs("label", { className: "truss-estimator__field materials-calculator__depth", children: [_jsx("span", { children: "Gravel depth (mm)" }), _jsx("input", { type: "number", min: "0", step: "10", value: gravelDepth, onChange: (event) => setGravelDepth(event.target.value) })] })), includeSubBase && (_jsxs("label", { className: "truss-estimator__field materials-calculator__depth", children: [_jsx("span", { children: "Sub-base depth (mm)" }), _jsx("input", { type: "number", min: "0", step: "10", value: subBaseDepth, onChange: (event) => setSubBaseDepth(event.target.value) })] })), _jsxs("label", { className: "brick-calculator__checkbox materials-calculator__waste", children: [_jsx("input", { type: "checkbox", checked: includeSubBase, onChange: (event) => setIncludeSubBase(event.target.checked) }), _jsx("span", { children: "Include MOT Type 1 sub-base" })] }), _jsxs("label", { className: "brick-calculator__checkbox materials-calculator__waste", children: [_jsx("input", { type: "checkbox", checked: includeBedding, onChange: (event) => setIncludeBedding(event.target.checked) }), _jsx("span", { children: "Include sharp sand bedding" })] })] }), _jsxs("div", { className: "materials-calculator__totals", children: [_jsx("div", { className: "truss-estimator__section-heading", children: "Totals" }), _jsxs("div", { className: "materials-calculator__result-grid", children: [_jsxs("div", { children: [_jsx("span", { children: "Surface area" }), _jsxs("strong", { children: [area.toFixed(2), " m\u00B2"] })] }), _jsxs("div", { children: [_jsx("span", { children: surface === "paving" ? "Paving slabs" : surface === "block" ? "Block paving" : "Gravel" }), _jsx("strong", { children: surface === "paving" ? `${slabs} slabs` : surface === "block" ? `${pavingArea.toFixed(2)} m²` : `${gravelBags} bulk bags` })] }), includeSubBase && (_jsxs("div", { children: [_jsx("span", { children: "MOT Type 1" }), _jsxs("strong", { children: [motBags, " bulk bags"] })] })), includeBedding && (_jsxs("div", { children: [_jsx("span", { children: "Sharp sand" }), _jsxs("strong", { children: [sandBags, " bulk bags"] })] }))] }), _jsx("p", { className: "materials-calculator__note", children: "Slab and block quantities use typical sizes. Confirm pack coverage and laying pattern before ordering." })] }), _jsxs("div", { className: "materials-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: area <= 0, children: submitLabel })] })] })] }) }));
|
|
42
58
|
}
|
|
@@ -26,11 +26,16 @@ export default function PlasterboardEstimator({ onBack, onReview, submitLabel =
|
|
|
26
26
|
const boardLabel = BOARD_TYPES.find((option) => option.id === boardType)?.label ?? "Standard";
|
|
27
27
|
const requirements = [
|
|
28
28
|
{ key: "board", label: `${boardLabel} plasterboard`, qty: boardCount, unit: "sheet", query: `${boardLabel} plasterboard`, category: "plasterboard" },
|
|
29
|
-
...(fixingMethod === "screws"
|
|
29
|
+
...(fixingMethod === "screws"
|
|
30
|
+
? [{ key: "screws", label: "Drywall screws", qty: screwBoxes, unit: "box", query: "drywall screws", category: "fixings" }]
|
|
31
|
+
: [{ key: "adhesive", label: "Board adhesive", qty: adhesiveBags, unit: "bag", query: "drywall board adhesive", category: "plasterboard" }]),
|
|
30
32
|
{ key: "tape", label: "Joint tape", qty: tapeRolls, unit: "roll", query: "plasterboard joint tape", category: "plasterboard" },
|
|
31
33
|
];
|
|
32
34
|
const updateSurface = (index, key, nextValue) => {
|
|
33
|
-
setSurfaces((items) => items.map((surface, surfaceIndex) => surfaceIndex === index ? { ...surface, [key]: nextValue } : surface));
|
|
35
|
+
setSurfaces((items) => items.map((surface, surfaceIndex) => (surfaceIndex === index ? { ...surface, [key]: nextValue } : surface)));
|
|
34
36
|
};
|
|
35
|
-
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: "Plasterboard
|
|
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: "Plasterboard and Drylining Calculator" }), _jsx("p", { children: "Estimate boards, fixings and jointing materials for walls and ceilings." })] }), _jsxs("div", { className: "truss-estimator__card materials-calculator", children: [_jsx("h2", { className: "truss-estimator__step-title", children: "Boards, fixings and jointing" }), _jsx("p", { className: "truss-estimator__step-sub", children: "Enter the surfaces you are lining and the board specification. Screws, tape and adhesive are estimated with the boards." }), _jsxs("div", { className: "materials-calculator__section", children: [_jsx("div", { className: "truss-estimator__section-heading", children: "Board type" }), _jsx("div", { className: "truss-estimator__option-grid truss-estimator__option-grid--two", children: BOARD_TYPES.map((option) => (_jsxs("button", { type: "button", className: `truss-estimator__option-card${boardType === option.id ? " is-selected" : ""}`, onClick: () => setBoardType(option.id), children: [_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: "materials-calculator__section", children: [_jsx("div", { className: "truss-estimator__section-heading", children: "Fixing method" }), _jsx("div", { className: "truss-estimator__option-grid truss-estimator__option-grid--two", children: [
|
|
38
|
+
["screws", "Drywall screws", "For timber or metal framing"],
|
|
39
|
+
["adhesive", "Board adhesive", "For masonry dot-and-dab"],
|
|
40
|
+
].map(([id, label, blurb]) => (_jsxs("button", { type: "button", className: `truss-estimator__option-card${fixingMethod === id ? " is-selected" : ""}`, onClick: () => setFixingMethod(id), children: [_jsx("div", { className: "truss-estimator__option-title", children: label }), _jsx("div", { className: "truss-estimator__option-copy", children: blurb })] }, id))) })] }), _jsxs("div", { className: "materials-calculator__section", children: [_jsx("div", { className: "truss-estimator__section-heading", children: "Wall and ceiling areas" }), _jsx("p", { className: "materials-calculator__hint", children: "Add each surface separately so different wall sizes are easy to measure." }), _jsxs("div", { className: "materials-calculator__dimensions-header", children: [_jsx("span", { children: "Surface" }), _jsx("span", { children: "Length (m)" }), _jsx("span", { children: "Height (m)" }), _jsx("span", {})] }), surfaces.map((surface, index) => (_jsxs("div", { className: "materials-calculator__surface-row", children: [_jsxs("span", { children: ["Surface ", index + 1] }), _jsx("input", { "aria-label": `Surface ${index + 1} length`, type: "number", min: "0", step: "0.01", value: surface.length, onChange: (event) => updateSurface(index, "length", event.target.value) }), _jsx("input", { "aria-label": `Surface ${index + 1} height`, type: "number", min: "0", step: "0.01", value: surface.height, onChange: (event) => updateSurface(index, "height", event.target.value) }), surfaces.length > 1 ? (_jsx("button", { type: "button", className: "paint-calculator__remove", onClick: () => setSurfaces((items) => items.filter((_, surfaceIndex) => surfaceIndex !== index)), children: "Remove" })) : null] }, `surface-${index}`))), _jsx("button", { type: "button", className: "paint-calculator__add", onClick: () => setSurfaces((items) => [...items, emptySurface()]), children: "+ Add another surface" }), _jsxs("label", { className: "truss-estimator__field materials-calculator__openings", children: [_jsxs("span", { children: ["Openings to deduct (m\u00B2) ", _jsx("small", { children: "optional" })] }), _jsx("input", { type: "number", min: "0", step: "0.01", value: openings, onChange: (event) => setOpenings(event.target.value) })] })] }), _jsxs("div", { className: "materials-calculator__totals", children: [_jsx("div", { className: "truss-estimator__section-heading", children: "Totals" }), _jsxs("div", { className: "materials-calculator__result-grid", children: [_jsxs("div", { children: [_jsx("span", { children: "Board area" }), _jsxs("strong", { children: [netAreaM2.toFixed(2), " m\u00B2"] })] }), _jsxs("div", { children: [_jsx("span", { children: "2.4 x 1.2 m boards" }), _jsx("strong", { children: boardCount })] }), _jsxs("div", { children: [_jsx("span", { children: fixingMethod === "screws" ? "Drywall screws" : "Board adhesive" }), _jsx("strong", { children: fixingMethod === "screws" ? `${screwCount} (${screwBoxes} boxes)` : `${adhesiveBags} bags` })] }), _jsxs("div", { children: [_jsx("span", { children: "Joint tape" }), _jsxs("strong", { children: [jointTapeM, " m (", tapeRolls, " rolls)"] })] })] }), _jsx("p", { className: "materials-calculator__note", children: "Figures are estimates. Confirm board specification, fixing patterns and fire or acoustic requirements before ordering." })] }), _jsxs("div", { className: "materials-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: netAreaM2 <= 0, children: submitLabel })] })] })] }) }));
|
|
36
41
|
}
|
|
@@ -39,7 +39,7 @@ export const ESTIMATOR_TOOLS = [
|
|
|
39
39
|
{ id: "decking", title: "Decking Calculator", desc: "Estimate boards, substructure and fixing quantities.", imageUrl: "/tool-images/decking.webp", Component: DeckingEstimator },
|
|
40
40
|
{
|
|
41
41
|
id: "brick-block",
|
|
42
|
-
title: "Brick
|
|
42
|
+
title: "Brick and Block Calculator",
|
|
43
43
|
desc: "Estimate masonry units and ready-mixed mortar for a wall.",
|
|
44
44
|
imageUrl: "/tool-images/brick-block.webp",
|
|
45
45
|
Component: BrickBlockEstimator,
|
|
@@ -61,7 +61,7 @@ export const ESTIMATOR_TOOLS = [
|
|
|
61
61
|
},
|
|
62
62
|
{
|
|
63
63
|
id: "concrete-aggregate",
|
|
64
|
-
title: "Concrete
|
|
64
|
+
title: "Concrete and Aggregate Calculator",
|
|
65
65
|
desc: "Estimate concrete, MOT, sand and gravel quantities.",
|
|
66
66
|
imageUrl: "/tool-images/concrete-aggregate.webp",
|
|
67
67
|
Component: ConcreteAggregateEstimator,
|
|
@@ -89,7 +89,7 @@ export const ESTIMATOR_TOOLS = [
|
|
|
89
89
|
},
|
|
90
90
|
{
|
|
91
91
|
id: "paving",
|
|
92
|
-
title: "Paving
|
|
92
|
+
title: "Paving and Driveway Calculator",
|
|
93
93
|
desc: "Estimate paving, block paving, MOT and sharp sand.",
|
|
94
94
|
imageUrl: "/tool-images/paving.webp",
|
|
95
95
|
Component: PavingEstimator,
|