@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.
- package/package.json +2 -2
- package/src/components/checkout/basket.ts +85 -0
- package/src/components/checkout/card-outcome.ts +81 -0
- package/src/components/checkout/card-view.tsx +62 -22
- package/src/components/checkout/checkout-actions.ts +341 -0
- package/src/components/checkout/checkout-flow.tsx +112 -18
- package/src/components/checkout/checkout-steps.tsx +149 -174
- package/src/components/checkout/checkout-totals.tsx +51 -0
- package/src/components/checkout/client-context.tsx +3 -0
- package/src/components/checkout/dados-step.tsx +141 -0
- package/src/components/checkout/decline.ts +48 -0
- package/src/components/checkout/en-US.ts +33 -0
- package/src/components/checkout/hosted-return.ts +190 -206
- package/src/components/checkout/hosted-store.ts +269 -0
- package/src/components/checkout/payment-status-parts.tsx +311 -0
- package/src/components/checkout/payment-status.tsx +69 -264
- package/src/components/checkout/providers/types.ts +20 -3
- package/src/components/checkout/pt-BR.ts +33 -0
- package/src/components/checkout/screens-copy.ts +14 -0
- package/src/components/checkout/screens-en-US.ts +1 -0
- package/src/components/checkout/screens-pt-BR.ts +3 -0
- package/src/components/checkout/transport.ts +21 -1
- package/src/components/checkout/types.ts +24 -0
- package/src/components/checkout/use-card-checkout.ts +31 -31
- package/src/components/checkout/use-checkout-controller.ts +51 -271
- package/src/components/checkout/use-hosted-resume.ts +326 -0
- package/src/components/checkout/view-copy.ts +32 -0
- package/src/components/checkout/wallet-pane.tsx +3 -0
- package/src/flows/create-payment-flows.tsx +7 -0
- package/src/flows/screens-hosted.tsx +19 -2
- 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
|
-
*
|
|
12
|
+
* WHETHER A PARKED CHECKOUT MAY BE RESUMED (FUT-556, FUT-1213).
|
|
5
13
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
*
|
|
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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
70
|
-
*
|
|
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(
|
|
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
|
|
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.
|
|
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.
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
*
|
|
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
|
-
* `
|
|
103
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
165
|
+
* What the rule decided, and what the caller must do about it.
|
|
116
166
|
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
*
|
|
179
|
+
* Where a resume lands, once one is happening.
|
|
141
180
|
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
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
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
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
|
-
*
|
|
157
|
-
*
|
|
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
|
-
*
|
|
163
|
-
*
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
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
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
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
|
-
|
|
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
|
|
220
|
+
* The parked order and what to do with it — the rule at the top of this file.
|
|
194
221
|
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
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
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
220
|
-
// A
|
|
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
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
284
|
-
*
|
|
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
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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";
|