@metron-labs/quote-react 0.1.21 → 0.1.23
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/LICENSE +31 -31
- package/README.md +253 -222
- package/dist/app/EstimatingTools.js +13 -6
- package/dist/app/QuickQuote.js +12 -2
- package/dist/app/stages/BasketStage.js +9 -1
- package/dist/app/stages/EntryStage.js +6 -1
- package/dist/app/stages/ScheduleResultsStage.d.ts +1 -0
- package/dist/app/stages/ScheduleResultsStage.js +37 -5
- package/dist/embed/quote.js +6760 -6760
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/lib/analytics.d.ts +16 -0
- package/dist/lib/analytics.js +37 -0
- package/dist/lib/config.d.ts +9 -0
- package/dist/lib/productImage.js +19 -19
- package/dist/styles.css +6748 -6748
- package/dist/util/pricing.js +3 -4
- package/package.json +53 -53
package/dist/app/QuickQuote.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useState, useRef, useEffect, useCallback } from "react";
|
|
4
4
|
import { apiUrl, getQuoteConfig } from "../lib/config";
|
|
5
|
+
import { TrackContext, useTracker } from "../lib/analytics";
|
|
5
6
|
import { submitSchedule, pollScheduleJob } from "../lib/api/fetchSchedule";
|
|
6
7
|
import { pollJob } from "../lib/api/pollJob";
|
|
7
8
|
import EntryStage from "./stages/EntryStage";
|
|
@@ -26,6 +27,11 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
|
|
|
26
27
|
const { cart, addToCart, removeFromCart, updateCartQty, updateCartSellUnits, updateCartConversions, clearCart } = useCart();
|
|
27
28
|
const [cartSeeded, setCartSeeded] = useState(false);
|
|
28
29
|
const resolvedConversionInputs = useRef(new Map());
|
|
30
|
+
const rootRef = useRef(null);
|
|
31
|
+
const track = useTracker("quick-quote", rootRef);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
track("viewed");
|
|
34
|
+
}, [track]);
|
|
29
35
|
useEffect(() => {
|
|
30
36
|
const unresolved = Object.values(cart).filter((item) => {
|
|
31
37
|
if (item.productId === "-" || getPricingBreakdown(item).requiredSellUnits != null)
|
|
@@ -150,12 +156,16 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
|
|
|
150
156
|
if (job.status === "failed" || !job.result) {
|
|
151
157
|
throw new Error(job.error || "Failed to process schedule");
|
|
152
158
|
}
|
|
159
|
+
const lines = job.result.lineItems.length;
|
|
160
|
+
const matched = job.result.results.filter((result) => result.candidates.length > 0).length;
|
|
161
|
+
track("results_shown", { lines, matched, unmatched: lines - matched });
|
|
153
162
|
setScheduleReviewGateComplete(false);
|
|
154
163
|
setScheduleResult(job.result);
|
|
155
164
|
writeQuoteHistory("results");
|
|
156
165
|
setStage("results");
|
|
157
166
|
}
|
|
158
167
|
catch (err) {
|
|
168
|
+
track("error", { stage: "matching" });
|
|
159
169
|
setError(err?.message || "Failed to process schedule");
|
|
160
170
|
setStage("entry");
|
|
161
171
|
}
|
|
@@ -163,7 +173,7 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
|
|
|
163
173
|
schedulePollCancelRef.current?.();
|
|
164
174
|
schedulePollCancelRef.current = null;
|
|
165
175
|
}
|
|
166
|
-
}, [writeQuoteHistory]);
|
|
176
|
+
}, [writeQuoteHistory, track]);
|
|
167
177
|
// Entry-page "paste your line items" shortcut: skip the upload step entirely,
|
|
168
178
|
// wrap the pasted text as a .txt — verbatim, so the copy archived in blob storage reads
|
|
169
179
|
// exactly as pasted — and feed it straight into the same match pipeline.
|
|
@@ -181,5 +191,5 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
|
|
|
181
191
|
setEntryMode("schedule");
|
|
182
192
|
await runScheduleMatch(file);
|
|
183
193
|
}, [runScheduleMatch]);
|
|
184
|
-
return (_jsx("div", { 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, internal: internal }))] }) }));
|
|
194
|
+
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, internal: internal }))] }) }) }));
|
|
185
195
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useId, useRef, useState } from "react";
|
|
3
3
|
import { apiUrl } from "../../lib/config";
|
|
4
|
+
import { useTrack } from "../../lib/analytics";
|
|
4
5
|
import BackArrow from "../../components/BackArrow";
|
|
5
6
|
import { fmtCurrency } from "../../lib/formatters";
|
|
6
7
|
import { productImageSrc } from "../../lib/productImage";
|
|
@@ -43,6 +44,7 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
|
|
|
43
44
|
const [reference, setReference] = useState("");
|
|
44
45
|
const [quoteRequestSent, setQuoteRequestSent] = useState(false);
|
|
45
46
|
const [quoteRequestSending, setQuoteRequestSending] = useState(false);
|
|
47
|
+
const track = useTrack();
|
|
46
48
|
const [quoteContactError, setQuoteContactError] = useState("");
|
|
47
49
|
const [qtyDrafts, setQtyDrafts] = useState({});
|
|
48
50
|
const [sellUnitsDrafts, setSellUnitsDrafts] = useState({});
|
|
@@ -182,6 +184,7 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
|
|
|
182
184
|
setQuoteContactError(res.status === 429 ? "You've sent a few requests already. Please try again in a few minutes." : "Your request couldn't be sent. Please try again, or contact us if it keeps happening.");
|
|
183
185
|
return;
|
|
184
186
|
}
|
|
187
|
+
track("quote_requested", { items: pricedItems.length, total: Math.round(total * 100) / 100 });
|
|
185
188
|
setQuoteRequestSent(true);
|
|
186
189
|
window.scrollTo(0, 0);
|
|
187
190
|
}
|
|
@@ -202,6 +205,11 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
|
|
|
202
205
|
const subtotal = pricedItems.reduce((sum, entry) => sum + (entry.pricing.lineTotal ?? 0), 0);
|
|
203
206
|
const vat = subtotal * VAT_RATE;
|
|
204
207
|
const total = subtotal + vat;
|
|
208
|
+
// Once per visit to the basket, with the figures it opened with.
|
|
209
|
+
const openedWith = useRef({ items: pricedItems.length, total: Math.round(total * 100) / 100 });
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
track("basket_viewed", openedWith.current);
|
|
212
|
+
}, [track]);
|
|
205
213
|
const totalPages = Math.max(1, Math.ceil(pricedItems.length / PAGE_SIZE));
|
|
206
214
|
const currentPage = Math.min(page, totalPages);
|
|
207
215
|
const pagedItems = pricedItems.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
|
|
@@ -236,7 +244,7 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
|
|
|
236
244
|
setReference(e.target.value);
|
|
237
245
|
if (qzaBasketLayout)
|
|
238
246
|
setQuoteContactError("");
|
|
239
|
-
}, 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: () => void exportBasketExcel(items), "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
|
|
247
|
+
}, 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: () => { track("downloaded", { type: "basket", items: items.length }); void exportBasketExcel(items); }, "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
|
|
240
248
|
? "This is a quote request, not an order. We'll confirm our best price, availability and delivery before you approve."
|
|
241
249
|
: "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" })] }) })] })) })] }) }));
|
|
242
250
|
}
|
|
@@ -2,6 +2,7 @@ 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 { apiUrl } from "../../lib/config";
|
|
5
|
+
import { useTrack } from "../../lib/analytics";
|
|
5
6
|
import ForwardArrow from "../../components/ForwardArrow";
|
|
6
7
|
import CloseIcon from "../../components/CloseIcon";
|
|
7
8
|
import { clientHeaders, readJson } from "../../lib/api/boqClient";
|
|
@@ -22,6 +23,7 @@ const STEPS = {
|
|
|
22
23
|
};
|
|
23
24
|
export default function EntryStage({ onPasteSchedule, onUploadSchedule, onEntryModeChange }) {
|
|
24
25
|
const [activeTab, setActiveTab] = useState(null);
|
|
26
|
+
const track = useTrack();
|
|
25
27
|
const [pastedText, setPastedText] = useState("");
|
|
26
28
|
const [uploadFile, setUploadFile] = useState(null);
|
|
27
29
|
const [uploadError, setUploadError] = useState("");
|
|
@@ -43,6 +45,7 @@ export default function EntryStage({ onPasteSchedule, onUploadSchedule, onEntryM
|
|
|
43
45
|
return () => URL.revokeObjectURL(url);
|
|
44
46
|
}, [uploadFile]);
|
|
45
47
|
const openWorkflow = (tab) => {
|
|
48
|
+
track("method_selected", { method: tab });
|
|
46
49
|
setActiveTab(tab);
|
|
47
50
|
onEntryModeChange(null);
|
|
48
51
|
};
|
|
@@ -93,12 +96,14 @@ export default function EntryStage({ onPasteSchedule, onUploadSchedule, onEntryM
|
|
|
93
96
|
await onPasteSchedule(text, lines, false);
|
|
94
97
|
}
|
|
95
98
|
catch (error) {
|
|
99
|
+
track("error", { stage: "photo" });
|
|
96
100
|
setUploadError(error?.message || "Failed to extract line items from image");
|
|
97
101
|
}
|
|
98
102
|
};
|
|
99
103
|
const matchUpload = () => {
|
|
100
104
|
if (!uploadFile)
|
|
101
105
|
return;
|
|
106
|
+
track("list_submitted", { method: isPhotoFile(uploadFile) ? "photo" : "upload" });
|
|
102
107
|
return startMatching(() => (isPhotoFile(uploadFile) ? matchPhoto(uploadFile) : onUploadSchedule(uploadFile)));
|
|
103
108
|
};
|
|
104
109
|
const isFileDrag = (event) => event.dataTransfer.types.includes("Files");
|
|
@@ -127,6 +132,6 @@ export default function EntryStage({ onPasteSchedule, onUploadSchedule, onEntryM
|
|
|
127
132
|
applyUploadFile(event.dataTransfer.files);
|
|
128
133
|
openWorkflow("upload");
|
|
129
134
|
};
|
|
130
|
-
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: () => void startMatching(() => onPasteSchedule(pastedText)), "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) => { event.stopPropagation(); setUploadFile(null); setUploadError(""); if (fileInputRef.current)
|
|
135
|
+
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: () => { track("list_submitted", { method: "paste", lines: lineCount }); void startMatching(() => onPasteSchedule(pastedText)); }, "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) => { event.stopPropagation(); setUploadFile(null); setUploadError(""); if (fileInputRef.current)
|
|
131
136
|
fileInputRef.current.value = ""; }, 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, {}) })] })] }) }))] })] }));
|
|
132
137
|
}
|
|
@@ -13,6 +13,7 @@ interface ResultsStageProps {
|
|
|
13
13
|
internal?: boolean;
|
|
14
14
|
}
|
|
15
15
|
export declare function candidateFromCart(item: CartItem): MatchCandidate;
|
|
16
|
+
export declare function refreshedCartItem(refreshed: CartItem, existing: CartItem): CartItem;
|
|
16
17
|
export declare function displayLineLabel(item: LineItem, allItems: LineItem[]): string;
|
|
17
18
|
export default function ScheduleResultsStage({ scheduleResult, onStartOver, cart, onAddToCart, onRemoveFromCart, onGoToBasket, cartSeeded, onCartSeeded, reviewGateComplete, onReviewGateComplete, internal }: ResultsStageProps): import("react").JSX.Element;
|
|
18
19
|
export {};
|
|
@@ -5,6 +5,7 @@ import RefreshIcon from "../../components/RefreshIcon";
|
|
|
5
5
|
import ForwardArrow from "../../components/ForwardArrow";
|
|
6
6
|
import CloseIcon from "../../components/CloseIcon";
|
|
7
7
|
import { apiUrl } from "../../lib/config";
|
|
8
|
+
import { useTrack } from "../../lib/analytics";
|
|
8
9
|
import { renderDisplayUnit, formatPriceBasis } from "../components/UnitDisplay";
|
|
9
10
|
import { fmtCurrency, fmtQty } from "../../lib/formatters";
|
|
10
11
|
import { productImageSrc } from "../../lib/productImage";
|
|
@@ -88,6 +89,39 @@ export function candidateFromCart(item) {
|
|
|
88
89
|
method: "manual_cart_selection",
|
|
89
90
|
};
|
|
90
91
|
}
|
|
92
|
+
export function refreshedCartItem(refreshed, existing) {
|
|
93
|
+
const base = {
|
|
94
|
+
...refreshed,
|
|
95
|
+
qty: existing.qty,
|
|
96
|
+
sellUnitsOverride: existing.sellUnitsOverride ?? null,
|
|
97
|
+
};
|
|
98
|
+
if (existing.productId !== refreshed.productId || existing.conversionType !== "basket_line_unit")
|
|
99
|
+
return base;
|
|
100
|
+
return {
|
|
101
|
+
...base,
|
|
102
|
+
saleUnit: existing.saleUnit,
|
|
103
|
+
saleUnitQuantity: existing.saleUnitQuantity,
|
|
104
|
+
unitConversions: existing.unitConversions,
|
|
105
|
+
conversionType: existing.conversionType,
|
|
106
|
+
requestUomsSupported: existing.requestUomsSupported,
|
|
107
|
+
salesUomNormalised: existing.salesUomNormalised,
|
|
108
|
+
salesUnitSize: existing.salesUnitSize,
|
|
109
|
+
salesUnitSizeUom: existing.salesUnitSizeUom,
|
|
110
|
+
coverageQty: existing.coverageQty,
|
|
111
|
+
coverageUom: existing.coverageUom,
|
|
112
|
+
itemsPerPallet: existing.itemsPerPallet,
|
|
113
|
+
itemsPerPack: existing.itemsPerPack,
|
|
114
|
+
itemsPerBox: existing.itemsPerBox,
|
|
115
|
+
itemsPerM2: existing.itemsPerM2,
|
|
116
|
+
wasteFactorDefault: existing.wasteFactorDefault,
|
|
117
|
+
roundingRule: existing.roundingRule,
|
|
118
|
+
derivedCoverageM2: existing.derivedCoverageM2,
|
|
119
|
+
derivedLengthM: existing.derivedLengthM,
|
|
120
|
+
quoteQtyFormulaType: existing.quoteQtyFormulaType,
|
|
121
|
+
conversionConfidence: existing.conversionConfidence,
|
|
122
|
+
conversionNotes: existing.conversionNotes,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
91
125
|
function classificationLabel(value) {
|
|
92
126
|
if (!value)
|
|
93
127
|
return "not stated";
|
|
@@ -293,6 +327,7 @@ export default function ScheduleResultsStage({ scheduleResult, onStartOver, cart
|
|
|
293
327
|
const customerCollection = qzaQuoteRequest ? "quote" : "basket";
|
|
294
328
|
const { results, lineItems, parseReport, timings } = scheduleResult;
|
|
295
329
|
const [pickOverrides, setPickOverrides] = useState({});
|
|
330
|
+
const track = useTrack();
|
|
296
331
|
const [pickOverrideCandidates, setPickOverrideCandidates] = useState({});
|
|
297
332
|
const [savedSearchCandidates, setSavedSearchCandidates] = useState({});
|
|
298
333
|
const [search, setSearch] = useState("");
|
|
@@ -507,6 +542,7 @@ export default function ScheduleResultsStage({ scheduleResult, onStartOver, cart
|
|
|
507
542
|
}
|
|
508
543
|
};
|
|
509
544
|
function handleExport() {
|
|
545
|
+
track("downloaded", { type: "schedule", lines: rows.length });
|
|
510
546
|
void exportScheduleExcel(rows.map(({ item, best, qty, band, unmatchable, decision }) => ({
|
|
511
547
|
item,
|
|
512
548
|
best,
|
|
@@ -547,11 +583,7 @@ export default function ScheduleResultsStage({ scheduleResult, onStartOver, cart
|
|
|
547
583
|
if (!existing)
|
|
548
584
|
continue;
|
|
549
585
|
const refreshed = toCartItem(row);
|
|
550
|
-
onAddToCart(
|
|
551
|
-
...refreshed,
|
|
552
|
-
qty: existing.qty,
|
|
553
|
-
sellUnitsOverride: existing.sellUnitsOverride ?? null,
|
|
554
|
-
});
|
|
586
|
+
onAddToCart(refreshedCartItem(refreshed, existing));
|
|
555
587
|
}
|
|
556
588
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
557
589
|
}, [scheduleResult]);
|