@12-apps/payments-frontend 3.21.4 → 3.22.0

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.
Files changed (31) hide show
  1. package/package.json +2 -2
  2. package/src/components/checkout/basket.ts +85 -0
  3. package/src/components/checkout/card-outcome.ts +81 -0
  4. package/src/components/checkout/card-view.tsx +62 -22
  5. package/src/components/checkout/checkout-actions.ts +341 -0
  6. package/src/components/checkout/checkout-flow.tsx +112 -18
  7. package/src/components/checkout/checkout-steps.tsx +149 -174
  8. package/src/components/checkout/checkout-totals.tsx +51 -0
  9. package/src/components/checkout/client-context.tsx +3 -0
  10. package/src/components/checkout/dados-step.tsx +141 -0
  11. package/src/components/checkout/decline.ts +48 -0
  12. package/src/components/checkout/en-US.ts +33 -0
  13. package/src/components/checkout/hosted-return.ts +190 -206
  14. package/src/components/checkout/hosted-store.ts +269 -0
  15. package/src/components/checkout/payment-status-parts.tsx +311 -0
  16. package/src/components/checkout/payment-status.tsx +69 -264
  17. package/src/components/checkout/providers/types.ts +20 -3
  18. package/src/components/checkout/pt-BR.ts +33 -0
  19. package/src/components/checkout/screens-copy.ts +14 -0
  20. package/src/components/checkout/screens-en-US.ts +1 -0
  21. package/src/components/checkout/screens-pt-BR.ts +3 -0
  22. package/src/components/checkout/transport.ts +21 -1
  23. package/src/components/checkout/types.ts +24 -0
  24. package/src/components/checkout/use-card-checkout.ts +31 -31
  25. package/src/components/checkout/use-checkout-controller.ts +51 -271
  26. package/src/components/checkout/use-hosted-resume.ts +326 -0
  27. package/src/components/checkout/view-copy.ts +32 -0
  28. package/src/components/checkout/wallet-pane.tsx +3 -0
  29. package/src/flows/create-payment-flows.tsx +7 -0
  30. package/src/flows/screens-hosted.tsx +19 -2
  31. package/src/index.ts +22 -0
@@ -1,35 +1,66 @@
1
+ import type { CheckoutBasketIdentity } from "./basket";
2
+ import {
3
+ belongsHere,
4
+ forgetHostedOrder,
5
+ isStale,
6
+ readParked,
7
+ type ParkedHostedOrder,
8
+ } from "./hosted-store";
1
9
  import type { CheckoutOrder } from "./types";
2
10
 
3
11
  /**
4
- * Surviving the trip to a hosted checkout (FUT-556).
12
+ * WHETHER A PARKED CHECKOUT MAY BE RESUMED (FUT-556, FUT-1213).
5
13
  *
6
- * A redirect provider takes the buyer to ITS OWN site, so the SPA is torn down
7
- * and remounts fresh when they come back. Everything the checkout held
8
- * which order was raised, for how much is gone, and without it the return
9
- * lands on an empty payment step: no confirmation, no total, no sign that the
10
- * money they just moved arrived.
14
+ * The storage half is `./hosted-store.ts`. What is decided here is the money
15
+ * rule on top of it, and the rule exists because the first version had none:
16
+ * a parked entry was resumed by whatever checkout mounted next, unconditionally.
11
17
  *
12
- * The webhook still settles the order server-side; that is the mechanism and it
13
- * does not depend on any of this. What is rescued here is only the buyer's view
14
- * of it.
18
+ * ## What that cost
15
19
  *
16
- * `sessionStorage`, not `localStorage`: the handover is one tab's round trip,
17
- * and a pending order left in durable storage would resurface in a later,
18
- * unrelated session.
19
- */
20
-
21
- /**
22
- * Where the parked order lives, namespaced to this PACKAGE.
20
+ * A shopper goes to the provider's page, does NOT pay, and comes back to the
21
+ * store by a route that is not the checkout typing the store's address, a
22
+ * bookmark, history. They empty the basket, add a different product, and press
23
+ * "pay". The checkout opened on the CONFIRMATION step, polling the old order
24
+ * for fifteen minutes, with no amount and no reference and nothing on screen
25
+ * to say which order it was about — and then told a shopper who never paid
26
+ * "não pague de novo", while their live basket sat behind it. In stub mode it
27
+ * was worse: the old order self-confirmed and the host's paid-order handler
28
+ * closed the cart the old order pointed at, which by then held the NEW lines.
29
+ *
30
+ * THE STUB CASE IS NARROWED HERE, NOT CLOSED. Rule 3 asks the server, and a
31
+ * host's offline stub settlement answers PAID for a `stub_` charge old enough —
32
+ * so a dev/CI/demo deployment can still self-confirm the abandoned order and
33
+ * close the refilled cart behind it. What it cannot do any more is that
34
+ * silently on the buyer's screen. Production is unaffected for the reason rule
35
+ * 3 exists at all: a hosted provider cannot report paid without the reference
36
+ * only the paid redirect carries.
37
+ *
38
+ * ## The rule (product decision, 2026-09-02)
39
+ *
40
+ * "Bind the parked hand-off to the basket, and never lose a paid buyer's
41
+ * confirmation." Given the parked entry and the basket now in front of the
42
+ * checkout:
23
43
  *
24
- * It used to carry one adopter's brand as its namespace, written into every
25
- * adopter's browser. A storage key is not a private detail: it is observable
26
- * surface, asserted on by `@12-apps/payments-e2e` and visible in devtools to
27
- * anyone running the host. The sibling handover in this same folder already got
28
- * this right with a `payments:` prefix; this one did not.
44
+ * 1. nothing parked, another store's entry, or a stale one nothing changes;
45
+ * 2. the basket is THE SAME as the one the order was raised from, **or it is
46
+ * empty** the server closes a paid cart, so an empty basket is the paid
47
+ * buyer's normal state resume;
48
+ * 3. the basket is DIFFERENT ask the server ONCE what the parked order is
49
+ * worth before deciding. PAID resumes and shows the confirmation; anything
50
+ * else drops the entry and opens a normal checkout for the basket in front
51
+ * of the shopper.
29
52
  *
30
- * Exported so a host or a spec names it rather than retyping it.
53
+ * Step 3 is what keeps the confirmation, and it is answerable where the
54
+ * provider is not: a paid order is settled into the order row by the webhook,
55
+ * so `GET /status` answers PAID from the database even though InfinitePay's own
56
+ * `payment_check` cannot be asked without the `transaction_nsu` that only the
57
+ * paid redirect carries.
58
+ *
59
+ * The decision is DEFERRED until the host's cart has loaded — see
60
+ * {@link CheckoutBasketIdentity.ready}. Deciding against an unseeded cart reads
61
+ * every basket as empty, which is rule 2, which is the old behaviour with extra
62
+ * steps.
31
63
  */
32
- export const HOSTED_ORDER_STORAGE_KEY = "payments.checkout.hostedOrder";
33
64
 
34
65
  /**
35
66
  * What a hosted provider appends to the return URL. InfinitePay sends the
@@ -55,7 +86,7 @@ function isReturnTrip(): boolean {
55
86
  }
56
87
 
57
88
  /**
58
- * Whether a hand-off from THIS tab is still waiting to be resolved.
89
+ * Whether a payment raised in THIS tab is still waiting to be resolved.
59
90
  *
60
91
  * Exported because a host needs it and was otherwise forced to reimplement it.
61
92
  * `/menu/checkout` is a URL like any other, so a host may put a gate in front
@@ -66,23 +97,55 @@ function isReturnTrip(): boolean {
66
97
  * precisely the drift this package exists to stop: the copy went stale the
67
98
  * moment Stripe's 3-D Secure markers were added here.
68
99
  *
69
- * Read WITHOUT consuming. The gate asks on every render; only the flow may
70
- * take the order.
100
+ * ## It asks the SAME question the resume asks (FUT-1213)
101
+ *
102
+ * A gate that stands aside for a return that is not going to happen is a gate
103
+ * that has been talked out of its job — and at a SHUT store that is the whole
104
+ * screen. FUT-1213 names it as one of the bug's own harms: the stale entry made
105
+ * the closed-store curtain stand aside, so an abandoned shopper met
106
+ * "Confirmando seu pagamento" where they should have met "Loja fechada".
107
+ *
108
+ * So this mirrors {@link takeHostedOrder}'s rule rather than merely asking
109
+ * whether anything is parked: the entry must be THIS store's, not stale, and
110
+ * still ABOUT the basket in front of the shopper — the same basket it was
111
+ * raised from, or an empty one, which is the paid buyer's normal state because
112
+ * the server closes a paid cart. Both directions fall out of it: a buyer who
113
+ * paid reaches their confirmation at a shut store, and a buyer who abandoned
114
+ * and rebuilt a different basket meets the curtain.
115
+ *
116
+ * It stays LOCAL and NON-CONSUMING, which is what makes it usable from a gate:
117
+ * the comparison is against the parked signature, so there is no `/status`
118
+ * round trip, and only the flow may take the order.
119
+ *
120
+ * **A host that passes no basket keeps the WIDE answer** — anything parked for
121
+ * this store stands the gate aside. That is the pre-1213 behaviour, kept so an
122
+ * un-migrated host is not broken by a bump, and it is the reason a host wiring
123
+ * `cart.identity` must pass it here too: the gate and the flow behind it are
124
+ * one decision, and only a host can hand both the same basket.
125
+ *
126
+ * A cart that has not LOADED answers `true` for the same reason it answers
127
+ * `WAIT` on the resume: nothing is known yet, and the permissive answer is the
128
+ * one that cannot strand a payer. A host that freezes this answer at mount
129
+ * (`useState(() => …)`) must therefore not freeze it before its cart is ready.
71
130
  */
72
- export function hostedCheckoutReturnPending(tenantSlug?: string): boolean {
131
+ export function hostedCheckoutReturnPending(
132
+ tenantSlug?: string,
133
+ basket?: CheckoutBasketIdentity,
134
+ ): boolean {
73
135
  const parked = readParked();
74
- // THE PARKED ENTRY DECIDES whenever there is one — the same two questions the
136
+ // THE PARKED ENTRY DECIDES whenever there is one — the same questions the
75
137
  // resume asks, so a gate and the flow behind it cannot disagree about whose
76
- // return this is. Another store's hand-off is not this route's business, and
77
- // a stale one is nobody's.
138
+ // return this is.
78
139
  //
79
140
  // Asking the URL FIRST is what this used to do, and it made the two disagree
80
141
  // exactly where it costs something: a provider marker is per-TAB, so store
81
142
  // A's abandoned hand-off plus any marked URL had the gate answer "a return is
82
143
  // pending here" on store B while `takeHostedOrder` correctly refused to
83
- // resume it. A gate that stands aside for a return that is not going to
84
- // happen is a gate that has been talked out of its job.
85
- if (parked) return belongsHere(parked, tenantSlug) && !isStale(parked);
144
+ // resume it.
145
+ if (parked) {
146
+ if (!belongsHere(parked, tenantSlug) || isStale(parked)) return false;
147
+ return aboutThisBasket(parked, basket);
148
+ }
86
149
  // Nothing parked, so the provider's own marker is the only evidence left that
87
150
  // a return is in progress. It is kept, and only here, for the case that has
88
151
  // no other signal: the flow has already CONSUMED the entry, and a gate
@@ -91,203 +154,124 @@ export function hostedCheckoutReturnPending(tenantSlug?: string): boolean {
91
154
  }
92
155
 
93
156
  /**
94
- * What is actually parked: the order, WHOSE STORE it belongs to, and when.
95
- *
96
- * `CheckoutOrder` carries no tenant, and on a multi-tenant storefront every
97
- * store shares one origin — so one tab holds one slot for all of them. Without
98
- * the slug, a buyer who abandoned store A's hand-off and opened store B's
99
- * checkout resumed A's order on B's screen: a confirmation for an unrelated
100
- * order, and B's own checkout skipped.
157
+ * Where a resumed checkout opens.
101
158
  *
102
- * `parkedAt` bounds the other axis. A hand-off is a round trip of minutes; an
103
- * entry older than {@link MAX_PARKED_AGE_MS} belongs to a session the buyer has
104
- * long since abandoned, and resuming it tells them about an order they are no
105
- * longer trying to place.
159
+ * `status` is the confirmation screen with its poll; `payment` puts the buyer
160
+ * back in front of the code they were paying.
106
161
  */
107
- interface ParkedHostedOrder {
108
- order: CheckoutOrder;
109
- /** The store this hand-off belongs to; absent for an unscoped host. */
110
- tenantSlug?: string;
111
- parkedAt: number;
112
- }
162
+ export type HostedResumeStep = "status" | "payment";
113
163
 
114
164
  /**
115
- * How long a parked hand-off stays resumable.
165
+ * What the rule decided, and what the caller must do about it.
116
166
  *
117
- * Thirty minutes: a hosted payment takes minutes, and the window has to cover a
118
- * buyer who fetches their card, not one who comes back tomorrow. Beyond it the
119
- * entry is dropped on read rather than resumed.
167
+ * `ASK` is the only verdict that leaves the entry PARKED: the caller has not
168
+ * got its answer yet, and dropping the entry before the server has spoken would
169
+ * lose a paid buyer's confirmation to a failed request. The caller consumes it
170
+ * with `forgetHostedOrder` once it knows.
120
171
  */
121
- const MAX_PARKED_AGE_MS = 30 * 60_000;
122
-
123
- /** Park the raised order before handing the buyer to the provider's page. */
124
- export function rememberHostedOrder(order: CheckoutOrder, tenantSlug?: string): void {
125
- try {
126
- const parked: ParkedHostedOrder = {
127
- order,
128
- ...(tenantSlug ? { tenantSlug } : {}),
129
- parkedAt: Date.now(),
130
- };
131
- window.sessionStorage?.setItem(HOSTED_ORDER_STORAGE_KEY, JSON.stringify(parked));
132
- } catch {
133
- // Storage disabled or full. The redirect must still happen: the webhook
134
- // settles the order either way, and refusing to send the buyer to pay
135
- // would be a far worse failure than a plain return screen.
136
- }
137
- }
172
+ export type HostedResumeDecision =
173
+ | { verdict: "WAIT" }
174
+ | { verdict: "NONE" }
175
+ | { verdict: "RESUME"; order: CheckoutOrder; step: HostedResumeStep }
176
+ | { verdict: "ASK"; order: CheckoutOrder };
138
177
 
139
178
  /**
140
- * The parked order, cleared as it is read.
179
+ * Where a resume lands, once one is happening.
141
180
  *
142
- * This USED to require a marker on the URL, so that a buyer who abandoned the
143
- * provider's page and reopened checkout got a fresh order rather than resuming
144
- * one they never paid. That reasoning is inverted for the provider it matters
145
- * most for, and the inversion is a money bug rather than a UX preference.
181
+ * A HAND-OFF always lands on the confirmation: the buyer paid (or did not) on
182
+ * another site, there is nothing on our page for them to do, and the only way
183
+ * to learn which it was is to ask. Anything else a PIX code, a card charge
184
+ * raised on our own page lands back on the PAYMENT step, because the thing
185
+ * the buyer needs is still there and still valid: the server reuses the same
186
+ * charge and the same code, so nothing is charged twice and the pane's own poll
187
+ * still carries them to the confirmation the moment it settles.
146
188
  *
147
- * Pressing the provider's "Continuar" is the ONLY thing that marks the URL.
148
- * Closing the tab, hitting back, or retyping the store's address are all
149
- * commoner, and all of them landed the buyer on a live payment step for an
150
- * order that may already be paid — an invitation to pay twice. It cannot be
151
- * decided by asking first, either: InfinitePay's `payment_check` refuses to
152
- * answer without a `transaction_nsu` that only that same redirect carries, so
153
- * "poll before resuming" reads PAID as PENDING and drops them on the pay
154
- * button anyway.
189
+ * Unless the basket is EMPTY, which on this path means the server closed the
190
+ * cart because the order was paid. Showing that shopper a QR to scan would be
191
+ * showing them a code for money they have already sent.
155
192
  *
156
- * So a parked order is itself the signal. The cost is that a buyer who truly
157
- * abandoned sees one confirmation screen reporting what the store actually
158
- * knows — which is the truth — with the way back on it. The read-and-clear
159
- * bounds it: the resume happens once per hand-off, and leaving and reopening
160
- * checkout gives a fresh one.
193
+ * ## An entry with NO FLAG is a hand-off, and getting this backwards costs a
194
+ * second payment
161
195
  *
162
- * `sessionStorage` already scopes this to one tab's round trip, so nothing
163
- * here can resurface in a later, unrelated session.
164
- */
165
- /**
166
- * The key before the 2.0.0 rename, READ ONLY decoded from base64 so no
167
- * spelling of the old brand, whole or split, appears in shipped source (both
168
- * brand gates sweep this file), while the RUNTIME string stays exactly what
169
- * pre-2.0.0 bundles wrote.
196
+ * Nothing but a hand-off parked anything before FUT-1140 the two call sites
197
+ * were the redirect provider's link and the 3-D Secure challenge — so every
198
+ * entry written by a bundle that predates the flag is one. A buyer mid-hand-off
199
+ * across the deploy who was resumed onto the PAYMENT step would meet the
200
+ * hand-off screen's "Preparando o pagamento" spinner with nothing to navigate
201
+ * them (the order already exists, so nothing re-raises it) and NO poll (only
202
+ * the confirmation leg polls) with their parked entry already consumed, so a
203
+ * reload cannot recover it. The exit they take is back → checkout again, which
204
+ * raises a SECOND order against a cart the first one may already have closed.
170
205
  *
171
- * A buyer who left for the provider's page on a pre-2.0.0 bundle comes back
172
- * to a newer one with their order parked under the old name. Without this
173
- * they land on the plain return screen the order still settles, because the
174
- * webhook does that and never depended on any of this, but the confirmation
175
- * they were promised is missing for a reason they could not possibly
176
- * understand.
177
- *
178
- * DELETE when both hold, and not before:
179
- * 1. every adopter's production has served ONLY >= 2.0.0 bundles for at
180
- * least 24 hours (a hosted round trip lasts minutes; a day is
181
- * over-margin) — verified against each consumer's lockfile history, not
182
- * assumed from this package's release date; and
183
- * 2. the deletion rides its own release with this note in the body, so an
184
- * adopter still rolling back to a pre-2.0.0 bundle knows the window it
185
- * reopens.
186
- * 3.0.0 deleted this shim on the package's clock instead of the hosts' —
187
- * consumers still pinned 2.x, so their key-renaming deploy had not happened
188
- * yet — which is why it is back.
206
+ * `!== false` rather than a truthiness test, and `rememberHostedOrder` writes
207
+ * the flag both ways for the same reason: this version's own on-page charges
208
+ * must stay distinguishable from an entry that simply predates the field. It is
209
+ * the same deploy window the legacy STORAGE KEY is kept for, one field down.
189
210
  */
190
- const LEGACY_KEY = atob('ZnV0dXJlcGF5LmNoZWNrb3V0Lmhvc3RlZE9yZGVy');
211
+ function resumeStepFor(
212
+ parked: ParkedHostedOrder,
213
+ basket: CheckoutBasketIdentity | undefined,
214
+ ): HostedResumeStep {
215
+ if (parked.handoff !== false) return "status";
216
+ return basket?.signature === null ? "status" : "payment";
217
+ }
191
218
 
192
219
  /**
193
- * The raw parked payload under either key, cleared as it is read.
220
+ * The parked order and what to do with it the rule at the top of this file.
194
221
  *
195
- * Split out from {@link takeHostedOrder} so the storage handling and the
196
- * parsing stay separately readable the two halves fail for unrelated reasons
197
- * anyway (storage disabled vs. a value that is not an order).
198
- *
199
- * BOTH keys are cleared whichever one answered: this is read-and-clear, and a
200
- * legacy entry left behind would let a later return trip resume an order that
201
- * was already consumed.
222
+ * Read-and-clear except on `ASK`. The resume happens once per parked checkout,
223
+ * so leaving and reopening the checkout gives a fresh one; what changed in
224
+ * FUT-1213 is only WHICH of those reads is allowed to resume.
202
225
  */
203
- function peekParkedPayload(): string | null {
204
- try {
205
- return (
206
- window.sessionStorage?.getItem(HOSTED_ORDER_STORAGE_KEY) ??
207
- window.sessionStorage?.getItem(LEGACY_KEY) ??
208
- null
209
- );
210
- } catch {
211
- // Storage disabled or unavailable — the same "no parked order" as an empty
212
- // slot, and the webhook still settles the order regardless.
213
- return null;
214
- }
215
- }
216
-
217
- export function takeHostedOrder(tenantSlug?: string): CheckoutOrder | null {
226
+ export function takeHostedOrder(
227
+ tenantSlug?: string,
228
+ basket?: CheckoutBasketIdentity,
229
+ ): HostedResumeDecision {
230
+ // Nothing is consumed against a cart that has not loaded: an unseeded cart
231
+ // reads as empty, and empty is the verdict that resumes unconditionally.
232
+ if (basket && !basket.ready) return { verdict: "WAIT" };
218
233
  const parked = readParked();
219
- if (!parked) return null;
220
- // A hand-off from ANOTHER store is left where it is rather than consumed: it
234
+ if (!parked) return { verdict: "NONE" };
235
+ // A checkout from ANOTHER store is left where it is rather than consumed: it
221
236
  // is that store's to resume, and this buyer may well go back to it.
222
- if (!belongsHere(parked, tenantSlug)) return null;
223
- clearParked();
224
- if (isStale(parked)) return null;
225
- return parked.order;
226
- }
227
-
228
- /** Whether a parked hand-off is this store's. */
229
- function belongsHere(parked: ParkedHostedOrder, tenantSlug?: string): boolean {
230
- // An unscoped entry (a host that passes no slug, or one parked by an older
231
- // bundle) stays readable by anyone — the single-tenant case, where there is
232
- // no other store to confuse it with.
233
- if (!parked.tenantSlug || !tenantSlug) return true;
234
- return parked.tenantSlug === tenantSlug;
235
- }
236
-
237
- /** Whether it has been sitting long enough to no longer be this trip's. */
238
- function isStale(parked: ParkedHostedOrder): boolean {
239
- if (typeof parked.parkedAt !== "number") return false;
240
- return Date.now() - parked.parkedAt > MAX_PARKED_AGE_MS;
241
- }
242
-
243
- /**
244
- * The parked entry, parsed, or null. Tolerates the PRE-SCOPE shape — a bare
245
- * `CheckoutOrder` — so a buyer mid-hand-off across the deploy still comes back
246
- * to their confirmation.
247
- */
248
- function readParked(): ParkedHostedOrder | null {
249
- const raw = peekParkedPayload();
250
- if (!raw) return null;
251
- try {
252
- const parsed: unknown = JSON.parse(raw);
253
- if (isCheckoutOrder(parsed)) return { order: parsed, parkedAt: Date.now() };
254
- if (typeof parsed !== "object" || parsed === null) return null;
255
- const candidate = parsed as Partial<ParkedHostedOrder>;
256
- if (!isCheckoutOrder(candidate.order)) return null;
257
- return {
258
- order: candidate.order,
259
- ...(candidate.tenantSlug ? { tenantSlug: candidate.tenantSlug } : {}),
260
- parkedAt: typeof candidate.parkedAt === "number" ? candidate.parkedAt : Date.now(),
261
- };
262
- } catch {
263
- return null;
237
+ if (!belongsHere(parked, tenantSlug)) return { verdict: "NONE" };
238
+ if (isStale(parked)) {
239
+ forgetHostedOrder();
240
+ return { verdict: "NONE" };
264
241
  }
242
+ if (!aboutThisBasket(parked, basket)) return { verdict: "ASK", order: parked.order };
243
+ forgetHostedOrder();
244
+ return { verdict: "RESUME", order: parked.order, step: resumeStepFor(parked, basket) };
265
245
  }
266
246
 
267
247
  /**
268
- * Trust nothing that came back out of storage: it is the only input here that
269
- * did not come from this render, and a half-written or hand-edited value would
270
- * otherwise reach the status view as an order.
271
- */
272
- function isCheckoutOrder(value: unknown): value is CheckoutOrder {
273
- if (typeof value !== "object" || value === null) return false;
274
- const candidate = value as Partial<CheckoutOrder>;
275
- return typeof candidate.orderId === "string" && typeof candidate.totalLabel === "string";
276
- }
277
-
278
- /**
279
- * Drop the parked entry. Split from the read because the READ now has to
280
- * decide whose it is first — consuming another store's hand-off was the bug
281
- * this scoping exists to stop.
248
+ * Whether a parked entry is still ABOUT the basket in front of the shopper.
282
249
  *
283
- * BOTH keys, whichever answered: a legacy entry left behind would let a later
284
- * return trip resume an order that was already consumed.
250
+ * THE ONE COMPARISON, used by both the resume and the gate above it, so the
251
+ * two cannot answer differently about the same shopper — which is the property
252
+ * the slug and staleness checks already had and this one has to have for the
253
+ * same reason: a gate that stands aside for a resume that will not happen is
254
+ * worse than either behaviour on its own.
255
+ *
256
+ * Three answers are `true`, and each is a different "nothing here says
257
+ * otherwise":
258
+ *
259
+ * - the host named no basket (an un-migrated host) — nothing to compare with;
260
+ * - the entry recorded none (an older bundle parked it) — nothing to compare;
261
+ * - the cart has not LOADED — nothing to compare YET, and the permissive
262
+ * answer is the one that cannot strand a payer. The resume's own `WAIT`
263
+ * verdict is the same choice made where a caller can act on it.
264
+ *
265
+ * And then the real comparison: the SAME basket, or an EMPTY one — the server
266
+ * closes a paid cart inside the confirmation transaction, so an empty basket is
267
+ * what a buyer who paid comes back to.
285
268
  */
286
- function clearParked(): void {
287
- try {
288
- window.sessionStorage?.removeItem(HOSTED_ORDER_STORAGE_KEY);
289
- window.sessionStorage?.removeItem(LEGACY_KEY);
290
- } catch {
291
- // Storage disabled there was nothing to clear.
292
- }
269
+ function aboutThisBasket(
270
+ parked: ParkedHostedOrder,
271
+ basket: CheckoutBasketIdentity | undefined,
272
+ ): boolean {
273
+ if (!basket || parked.basket === undefined || !basket.ready) return true;
274
+ return basket.signature === null || basket.signature === parked.basket;
293
275
  }
276
+
277
+ export { HOSTED_ORDER_STORAGE_KEY, forgetHostedOrder, rememberHostedOrder } from "./hosted-store";