@metron-labs/quote-react 0.1.56 → 0.1.58

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,7 +10,12 @@ 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";
15
+ // A tab left open for days shouldn't come back with old matches and old prices.
16
+ // ponytail: counts from the last edit, not from matching. Stamp the match time instead if
17
+ // prices move faster than people edit.
18
+ const SAVED_QUOTE_TTL_MS = 24 * 60 * 60 * 1000;
14
19
  // Its own provider, separate from the estimating tools' - a quote in progress and a set
15
20
  // of calculator results are different baskets.
16
21
  export default function QuickQuote(props) {
@@ -48,12 +53,17 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
48
53
  // a server-rendered host doesn't hit a hydration mismatch.
49
54
  const [restored, setRestored] = useState(false);
50
55
  useEffect(() => {
56
+ installQuoteHistory();
51
57
  const hash = window.location.hash;
52
58
  let saved = null;
53
59
  try {
54
60
  saved = JSON.parse(window.sessionStorage.getItem(SAVED_QUOTE_KEY) ?? "null");
55
61
  }
56
62
  catch { }
63
+ // Older than the TTL (or saved before there was a stamp): start fresh. The save effect
64
+ // below then removes it, as scheduleResult stays null.
65
+ if (saved && !(Date.now() - (saved.savedAt ?? 0) < SAVED_QUOTE_TTL_MS))
66
+ saved = null;
57
67
  if (saved?.scheduleResult) {
58
68
  setEntryMode("schedule");
59
69
  setScheduleResult(saved.scheduleResult);
@@ -68,7 +78,7 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
68
78
  else if (hash === "#matched-products" || hash === "#quote-request") {
69
79
  const url = new URL(window.location.href);
70
80
  url.hash = "";
71
- window.history.replaceState(window.history.state, "", url);
81
+ setStep("replaceState", window.history.state, url);
72
82
  }
73
83
  setRestored(true);
74
84
  }, []);
@@ -81,7 +91,7 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
81
91
  if (!scheduleResult)
82
92
  window.sessionStorage.removeItem(SAVED_QUOTE_KEY);
83
93
  else
84
- window.sessionStorage.setItem(SAVED_QUOTE_KEY, JSON.stringify({ scheduleResult, reviewGateComplete: scheduleReviewGateComplete, cartSeeded, cart }));
94
+ window.sessionStorage.setItem(SAVED_QUOTE_KEY, JSON.stringify({ savedAt: Date.now(), scheduleResult, reviewGateComplete: scheduleReviewGateComplete, cartSeeded, cart }));
85
95
  }
86
96
  catch { }
87
97
  }, [restored, scheduleResult, scheduleReviewGateComplete, cartSeeded, cart]);
@@ -96,11 +106,12 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
96
106
  // New results while the URL still carries one of our steps (start over, or the host
97
107
  // re-rendered the page under us) reuse that entry, or back would land on a stale copy.
98
108
  const stale = next === "results" && (window.location.hash === "#matched-products" || window.location.hash === "#quote-request");
99
- window.history[stale ? "replaceState" : "pushState"]({ boqQuoteStage: next }, "", url);
109
+ setStep(stale ? "replaceState" : "pushState", { boqQuoteStage: next }, url);
100
110
  }, []);
101
111
  const leavingBasket = useRef(false);
102
112
  useEffect(() => {
103
- const onPopState = (event) => {
113
+ // Host routers (Turbo, Salesfire) are kept out of these by lib/quoteHistory.
114
+ const onPopState = () => {
104
115
  leavingBasket.current = false;
105
116
  const hash = window.location.hash;
106
117
  if (hash === "#quote-request")
@@ -112,24 +123,6 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
112
123
  }
113
124
  else
114
125
  setStage("entry");
115
- // Turbo's popstate listener (registered before ours, so it can't be pre-empted) stamps its
116
- // restoration id over any entry without one - ours, or the host's own entry for this page.
117
- // On the next back/forward it then treats that entry as its page visit and re-renders the
118
- // page, wiping the quote. Once every listener has run, undo the stamp.
119
- const original = event.state;
120
- const href = window.location.href;
121
- setTimeout(() => {
122
- if (window.location.href !== href)
123
- return;
124
- // Our own steps always get our state back, even if a stamp slipped through earlier -
125
- // otherwise it sticks and every later visit to that step is a Turbo page visit.
126
- if (hash === "#quote-request" || hash === "#matched-products" || hash === "#quote-entry") {
127
- window.history.replaceState({ boqQuoteStage: hash === "#quote-request" ? "basket" : hash === "#quote-entry" ? "entry" : "results" }, "", href);
128
- return;
129
- }
130
- if (!original?.turbo && window.history.state?.turbo)
131
- window.history.replaceState(original, "", href);
132
- });
133
126
  };
134
127
  window.addEventListener("popstate", onPopState);
135
128
  return () => window.removeEventListener("popstate", onPopState);
@@ -147,7 +140,7 @@ function QuickQuoteInner({ fullPage = false, hideHeader = false, internal = fals
147
140
  if (leavingBasket.current)
148
141
  return;
149
142
  leavingBasket.current = true;
150
- window.history.back();
143
+ stepBack();
151
144
  return;
152
145
  }
153
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);