@metron-labs/quote-react 0.1.57 → 0.1.59

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.
@@ -10,6 +10,7 @@ import { ENTRY_DRAFT_KEY } from "./stages/EntryStage";
10
10
  import ScheduleResultsStage from "./stages/ScheduleResultsStage";
11
11
  import BasketStage from "./stages/BasketStage";
12
12
  import { CartProvider, useCart } from "../lib/cart/CartContext";
13
+ import { installQuoteHistory, setStep, stepBack } from "../lib/quoteHistory";
13
14
  const SAVED_QUOTE_KEY = "metron-quick-quote";
14
15
  // A tab left open for days shouldn't come back with old matches and old prices.
15
16
  // ponytail: counts from the last edit, not from matching. Stamp the match time instead if
@@ -52,6 +53,7 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
52
53
  // a server-rendered host doesn't hit a hydration mismatch.
53
54
  const [restored, setRestored] = useState(false);
54
55
  useEffect(() => {
56
+ installQuoteHistory();
55
57
  const hash = window.location.hash;
56
58
  let saved = null;
57
59
  try {
@@ -76,7 +78,7 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
76
78
  else if (hash === "#matched-products" || hash === "#quote-request") {
77
79
  const url = new URL(window.location.href);
78
80
  url.hash = "";
79
- window.history.replaceState(window.history.state, "", url);
81
+ setStep("replaceState", window.history.state, url);
80
82
  }
81
83
  setRestored(true);
82
84
  }, []);
@@ -104,11 +106,12 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
104
106
  // New results while the URL still carries one of our steps (start over, or the host
105
107
  // re-rendered the page under us) reuse that entry, or back would land on a stale copy.
106
108
  const stale = next === "results" && (window.location.hash === "#matched-products" || window.location.hash === "#quote-request");
107
- window.history[stale ? "replaceState" : "pushState"]({ boqQuoteStage: next }, "", url);
109
+ setStep(stale ? "replaceState" : "pushState", { boqQuoteStage: next }, url);
108
110
  }, []);
109
111
  const leavingBasket = useRef(false);
110
112
  useEffect(() => {
111
- const onPopState = (event) => {
113
+ // Host routers (Turbo, Salesfire) are kept out of these by lib/quoteHistory.
114
+ const onPopState = () => {
112
115
  leavingBasket.current = false;
113
116
  const hash = window.location.hash;
114
117
  if (hash === "#quote-request")
@@ -120,24 +123,6 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
120
123
  }
121
124
  else
122
125
  setStage("entry");
123
- // Turbo's popstate listener (registered before ours, so it can't be pre-empted) stamps its
124
- // restoration id over any entry without one - ours, or the host's own entry for this page.
125
- // On the next back/forward it then treats that entry as its page visit and re-renders the
126
- // page, wiping the quote. Once every listener has run, undo the stamp.
127
- const original = event.state;
128
- const href = window.location.href;
129
- setTimeout(() => {
130
- if (window.location.href !== href)
131
- return;
132
- // Our own steps always get our state back, even if a stamp slipped through earlier -
133
- // otherwise it sticks and every later visit to that step is a Turbo page visit.
134
- if (hash === "#quote-request" || hash === "#matched-products" || hash === "#quote-entry") {
135
- window.history.replaceState({ boqQuoteStage: hash === "#quote-request" ? "basket" : hash === "#quote-entry" ? "entry" : "results" }, "", href);
136
- return;
137
- }
138
- if (!original?.turbo && window.history.state?.turbo)
139
- window.history.replaceState(original, "", href);
140
- });
141
126
  };
142
127
  window.addEventListener("popstate", onPopState);
143
128
  return () => window.removeEventListener("popstate", onPopState);
@@ -155,7 +140,7 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
155
140
  if (leavingBasket.current)
156
141
  return;
157
142
  leavingBasket.current = true;
158
- window.history.back();
143
+ stepBack();
159
144
  return;
160
145
  }
161
146
  setStage(scheduleResult ? "results" : "entry");
@@ -7,6 +7,7 @@ import CloseIcon from "../../components/CloseIcon";
7
7
  import { FILE_EXTENSIONS, isPhotoFile, shrinkPhoto, validateLines, validateUploadFile } from "../../lib/boq/inputLimits";
8
8
  import { ProcessingContent } from "./ProcessingStage";
9
9
  import { useEscapeKey } from "../../lib/useEscapeKey";
10
+ import { setStep, stepBack } from "../../lib/quoteHistory";
10
11
  const UPLOAD_ACCEPT = [...FILE_EXTENSIONS.schedule, ...FILE_EXTENSIONS.photo].join(",");
11
12
  const STEPS = {
12
13
  paste: [
@@ -67,7 +68,7 @@ export default function EntryStage({ onPasteSchedule, onUploadSchedule, onEntryM
67
68
  // the dialog would reuse this step and closing it would go back off the page.
68
69
  const url = new URL(window.location.href);
69
70
  url.hash = "";
70
- window.history.replaceState(window.history.state, "", url);
71
+ setStep("replaceState", window.history.state, url);
71
72
  return;
72
73
  }
73
74
  lastTab.current = saved.tab;
@@ -110,7 +111,7 @@ export default function EntryStage({ onPasteSchedule, onUploadSchedule, onEntryM
110
111
  // Our own state only, as in QuickQuote's writeQuoteHistory. A results/basket hash left
111
112
  // behind by "start over" is reused, or back would land on that stale step.
112
113
  const stale = hash === "#matched-products" || hash === "#quote-request";
113
- window.history[stale ? "replaceState" : "pushState"]({ boqQuoteStage: "entry" }, "", url);
114
+ setStep(stale ? "replaceState" : "pushState", { boqQuoteStage: "entry" }, url);
114
115
  };
115
116
  const closeWorkflow = () => {
116
117
  setActiveTab(null);
@@ -118,7 +119,7 @@ export default function EntryStage({ onPasteSchedule, onUploadSchedule, onEntryM
118
119
  // Drop the dialog's step too. Closing hides the dialog straight away, so a second
119
120
  // Escape/click can't step back again past the page.
120
121
  if (window.location.hash === DIALOG_HASH)
121
- window.history.back();
122
+ stepBack();
122
123
  };
123
124
  // Like the close button, but not mid-match, where the modal is showing progress.
124
125
  useEscapeKey(Boolean(activeTab) && !matching, closeWorkflow);
@@ -598,17 +598,8 @@ export default function ScheduleResultsStage({ scheduleResult, onStartOver, cart
598
598
  onCartSeeded();
599
599
  // eslint-disable-next-line react-hooks/exhaustive-deps
600
600
  }, [cartSeeded]);
601
- // The cart is authoritative once a row has been added. Rebuilding an existing row from
602
- // scheduleResult can overwrite contextual quantities resolved in the basket when the
603
- // user navigates back here. Only seed rows that are not already present.
604
- useEffect(() => {
605
- for (const row of rows) {
606
- const existing = cart[row.item.line_id];
607
- if (!existing && !row.unmatchable && row.best)
608
- onAddToCart(toCartItem(row));
609
- }
610
- // eslint-disable-next-line react-hooks/exhaustive-deps
611
- }, [scheduleResult]);
601
+ // After seeding, the cart is authoritative: coming back here never re-adds a line the user
602
+ // removed in the basket, nor rebuilds one and overwrites quantities resolved there.
612
603
  useEffect(() => {
613
604
  if (!selectedReviewId) {
614
605
  setCatalogueSearchResults([]);