@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.
@@ -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]);