@metron-labs/quote-react 0.1.44 → 0.1.45

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.
@@ -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
- schedulePollCancelRef.current?.();
278
- schedulePollCancelRef.current = null;
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
  }
@@ -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
- export default function EntryStage({ onPasteSchedule, onUploadSchedule, onEntryModeChange }: EntryStageProps): import("react").JSX.Element;
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
- export default function EntryStage({ onPasteSchedule, onUploadSchedule, onEntryModeChange }) {
26
- const [activeTab, setActiveTab] = useState(null);
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
- setMatching(false);
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
  }