@metron-labs/quote-react 0.1.20 → 0.1.22

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)
@@ -99,7 +105,6 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
99
105
  const goToBasket = useCallback(() => {
100
106
  writeQuoteHistory("basket");
101
107
  setStage("basket");
102
- window.scrollTo(0, 0);
103
108
  }, [writeQuoteHistory]);
104
109
  const backToResults = useCallback(() => {
105
110
  const quoteStage = window.history.state?.boqQuoteStage;
@@ -108,6 +113,15 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
108
113
  else
109
114
  setStage(scheduleResult ? "results" : "entry");
110
115
  }, [scheduleResult]);
116
+ // Every screen change, forward or back, lands at the top. Runs after the new screen has
117
+ // rendered, so it also wins over the browser restoring the old scroll on history.back().
118
+ const scrolledStage = useRef(stage);
119
+ useEffect(() => {
120
+ if (scrolledStage.current === stage)
121
+ return;
122
+ scrolledStage.current = stage;
123
+ window.scrollTo(0, 0);
124
+ }, [stage]);
111
125
  // Reset back to the method-selection screen. The uploaded catalogue stays in
112
126
  // place — it's a session-level prerequisite, not part of an individual quote.
113
127
  const resetToEntry = () => {
@@ -142,12 +156,16 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
142
156
  if (job.status === "failed" || !job.result) {
143
157
  throw new Error(job.error || "Failed to process schedule");
144
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 });
145
162
  setScheduleReviewGateComplete(false);
146
163
  setScheduleResult(job.result);
147
164
  writeQuoteHistory("results");
148
165
  setStage("results");
149
166
  }
150
167
  catch (err) {
168
+ track("error", { stage: "matching" });
151
169
  setError(err?.message || "Failed to process schedule");
152
170
  setStage("entry");
153
171
  }
@@ -155,7 +173,7 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
155
173
  schedulePollCancelRef.current?.();
156
174
  schedulePollCancelRef.current = null;
157
175
  }
158
- }, [writeQuoteHistory]);
176
+ }, [writeQuoteHistory, track]);
159
177
  // Entry-page "paste your line items" shortcut: skip the upload step entirely,
160
178
  // wrap the pasted text as a .txt — verbatim, so the copy archived in blob storage reads
161
179
  // exactly as pasted — and feed it straight into the same match pipeline.
@@ -173,5 +191,5 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
173
191
  setEntryMode("schedule");
174
192
  await runScheduleMatch(file);
175
193
  }, [runScheduleMatch]);
176
- 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 }))] }) }) }));
177
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
  }
@@ -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";
@@ -293,6 +294,7 @@ export default function ScheduleResultsStage({ scheduleResult, onStartOver, cart
293
294
  const customerCollection = qzaQuoteRequest ? "quote" : "basket";
294
295
  const { results, lineItems, parseReport, timings } = scheduleResult;
295
296
  const [pickOverrides, setPickOverrides] = useState({});
297
+ const track = useTrack();
296
298
  const [pickOverrideCandidates, setPickOverrideCandidates] = useState({});
297
299
  const [savedSearchCandidates, setSavedSearchCandidates] = useState({});
298
300
  const [search, setSearch] = useState("");
@@ -507,6 +509,7 @@ export default function ScheduleResultsStage({ scheduleResult, onStartOver, cart
507
509
  }
508
510
  };
509
511
  function handleExport() {
512
+ track("downloaded", { type: "schedule", lines: rows.length });
510
513
  void exportScheduleExcel(rows.map(({ item, best, qty, band, unmatchable, decision }) => ({
511
514
  item,
512
515
  best,