@12-apps/payments-frontend 3.21.4 → 3.23.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 +387 -0
- package/src/components/checkout/checkout-flow.tsx +126 -24
- 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/confirmation-wait.ts +97 -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 +41 -0
- package/src/components/checkout/failure-codes.ts +82 -0
- package/src/components/checkout/hosted-return.ts +190 -206
- package/src/components/checkout/hosted-store.ts +291 -0
- package/src/components/checkout/payment-error-panel.tsx +9 -3
- package/src/components/checkout/payment-status-parts.tsx +311 -0
- package/src/components/checkout/payment-status.tsx +69 -264
- package/src/components/checkout/pix-view.tsx +97 -8
- package/src/components/checkout/poll-loop.ts +5 -3
- package/src/components/checkout/providers/types.ts +20 -3
- package/src/components/checkout/pt-BR.ts +42 -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 +35 -0
- package/src/components/checkout/use-card-checkout.ts +34 -33
- package/src/components/checkout/use-checkout-controller.ts +68 -274
- package/src/components/checkout/use-hosted-resume.ts +326 -0
- package/src/components/checkout/use-payment-polling.ts +58 -7
- package/src/components/checkout/use-wallet-charge.ts +24 -1
- package/src/components/checkout/view-copy.ts +70 -0
- package/src/components/checkout/wallet-pane.tsx +9 -1
- package/src/flows/catalog-exit.ts +33 -0
- package/src/flows/create-payment-flows.tsx +19 -1
- package/src/flows/pipeline/actions.tsx +104 -0
- package/src/flows/pipeline/admission.ts +55 -0
- package/src/flows/pipeline/context.ts +140 -0
- package/src/flows/pipeline/derive-step.ts +234 -0
- package/src/flows/pipeline/engine-actions.ts +257 -0
- package/src/flows/pipeline/engine-chrome.tsx +123 -0
- package/src/flows/pipeline/engine-state.ts +107 -0
- package/src/flows/pipeline/engine.tsx +377 -0
- package/src/flows/pipeline/methods.ts +71 -0
- package/src/flows/pipeline/refusal-routing.ts +106 -0
- package/src/flows/pipeline/slices.ts +110 -0
- package/src/flows/pipeline/stable-plugins.ts +72 -0
- package/src/flows/pipeline/steps/buyer-steps.tsx +297 -0
- package/src/flows/pipeline/steps/index.ts +54 -0
- package/src/flows/pipeline/steps/pay-steps.tsx +182 -0
- package/src/flows/pipeline/steps/status-step.tsx +41 -0
- package/src/flows/pipeline/types.ts +232 -0
- package/src/flows/public.ts +78 -0
- package/src/flows/screens-hosted.tsx +55 -5
- package/src/flows/screens-pay.tsx +6 -1
- package/src/flows/types.ts +25 -2
- package/src/index.ts +29 -19
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import type { CheckoutBasketIdentity } from "./basket";
|
|
4
|
+
import { useCheckoutClientApi } from "./client-context";
|
|
5
|
+
import {
|
|
6
|
+
forgetHostedOrder,
|
|
7
|
+
takeHostedOrder,
|
|
8
|
+
type HostedResumeStep,
|
|
9
|
+
} from "./hosted-return";
|
|
10
|
+
import type { CheckoutOrder, OrderStatus } from "./types";
|
|
11
|
+
import { usePaymentPolling } from "./use-payment-polling";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A LAYOUT effect where there is a DOM, an ordinary one where there is not.
|
|
15
|
+
*
|
|
16
|
+
* The resume decision cannot be made during render — it waits for the host's
|
|
17
|
+
* cart to load — so it lands from an effect, and an ordinary effect runs AFTER
|
|
18
|
+
* paint: a buyer coming back from a payment would see one frame of the Dados
|
|
19
|
+
* or Pagamento step before their confirmation replaced it. A layout effect
|
|
20
|
+
* commits before the browser paints, so the flow simply opens where it belongs.
|
|
21
|
+
*
|
|
22
|
+
* The branch is by ENVIRONMENT rather than by render pass, which is what makes
|
|
23
|
+
* it a constant and not a conditional hook. It costs nothing on a server render
|
|
24
|
+
* either: everything this hook decides from is `sessionStorage`, which does not
|
|
25
|
+
* exist there — and `useLayoutEffect` on the server is a warning React prints
|
|
26
|
+
* for exactly the case where the effect would matter and cannot run.
|
|
27
|
+
*/
|
|
28
|
+
export const useResumeEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The leg of checkout that RESUMES a payment this tab already raised (FUT-556,
|
|
32
|
+
* FUT-1140, FUT-1213, FUT-1146).
|
|
33
|
+
*
|
|
34
|
+
* Three tickets share this hook because they are three halves of one mechanism,
|
|
35
|
+
* and stacking them would have produced three: FUT-1213 decides WHETHER a
|
|
36
|
+
* parked checkout may be resumed, FUT-1140 makes the parked entry cover every
|
|
37
|
+
* raised order rather than only a hand-off, and FUT-1146 gives the buyer the
|
|
38
|
+
* one way out that a hosted charge's own protocol cannot offer.
|
|
39
|
+
*
|
|
40
|
+
* ## How long it keeps asking, and how often
|
|
41
|
+
*
|
|
42
|
+
* TWO RATES, because one rate cannot serve this wait. The interval decides two
|
|
43
|
+
* things that pull opposite ways: how fast a buyer WHO PAID is told so, and
|
|
44
|
+
* what an abandoned checkout costs for the rest of the window. Every poll is a
|
|
45
|
+
* provider round trip, so a slow rate is cheap and leaves a paying buyer
|
|
46
|
+
* watching a spinner seconds longer than they need to — and the person on this
|
|
47
|
+
* screen has almost always paid. A single number picks one of them to lose;
|
|
48
|
+
* this shipped at a flat 5 s and picked the wrong one.
|
|
49
|
+
*
|
|
50
|
+
* So: 2.5 s for the first two minutes, which is where essentially every real
|
|
51
|
+
* webhook lands, then 10 s for the remaining thirteen.
|
|
52
|
+
*
|
|
53
|
+
* Fifteen minutes because by then a webhook that was ever coming has come. The
|
|
54
|
+
* BOUND is that wall-clock window, not a poll count (FUT-1144): a poll that
|
|
55
|
+
* FAILS increments nothing, so a connection that never came back left this
|
|
56
|
+
* screen asking with no end at all. A clock cannot be stopped by the failure it
|
|
57
|
+
* is measuring.
|
|
58
|
+
*/
|
|
59
|
+
const HOSTED_RESUME_FAST_MS = 2_500;
|
|
60
|
+
const HOSTED_RESUME_SLOW_MS = 10_000;
|
|
61
|
+
/** Two minutes at the fast rate, before the wait is worth economising on. */
|
|
62
|
+
const HOSTED_RESUME_FAST_POLLS = (2 * 60_000) / HOSTED_RESUME_FAST_MS;
|
|
63
|
+
/** Thirteen more at the slow one — 15 minutes all told. */
|
|
64
|
+
const HOSTED_RESUME_WINDOW_MS = 15 * 60_000;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* How long the buyer waits before being offered a way out (FUT-1146).
|
|
68
|
+
*
|
|
69
|
+
* A cancelled or refused hosted payment has NO terminal state to arrive at.
|
|
70
|
+
* The provider's `payment_check` publishes `success` and `paid` and nothing
|
|
71
|
+
* else — no status, no cancel, no decline — the webhook verifier refuses an
|
|
72
|
+
* unpaid delivery outright, and every server-side writer of FAILED is
|
|
73
|
+
* unreachable from a hosted cancel. So the screen waits fifteen minutes and
|
|
74
|
+
* then tells someone who never paid not to pay again. The only signal that
|
|
75
|
+
* exists is the BUYER's, and this is how long we wait before asking for it.
|
|
76
|
+
*
|
|
77
|
+
* Thirty seconds: a webhook that was ever coming lands within seconds of the
|
|
78
|
+
* payment (the fast rate above is sized on exactly that), so a wait still going
|
|
79
|
+
* at thirty is already unusual — while a button offered instantly would sit
|
|
80
|
+
* under a spinner during the two seconds in which most confirmations arrive,
|
|
81
|
+
* inviting a shopper to abandon a payment that is landing. The release itself
|
|
82
|
+
* is guarded server-side regardless: a payable the provider reports PAID is
|
|
83
|
+
* answered PAID and released by nothing.
|
|
84
|
+
*/
|
|
85
|
+
const RELEASE_OFFER_AFTER_MS = 30_000;
|
|
86
|
+
|
|
87
|
+
/** What the resumed leg contributes to the controller's surface. */
|
|
88
|
+
export interface HostedResume {
|
|
89
|
+
/** The rehydrated order, once the decision has been made. */
|
|
90
|
+
order: CheckoutOrder | null;
|
|
91
|
+
/** Where the flow should open for it — see `hosted-return.ts`. */
|
|
92
|
+
step: HostedResumeStep | null;
|
|
93
|
+
status: OrderStatus | null;
|
|
94
|
+
timedOut: boolean;
|
|
95
|
+
error: string | null;
|
|
96
|
+
checkAgain: () => void;
|
|
97
|
+
/**
|
|
98
|
+
* The buyer's own "I did not pay" (FUT-1146), or `undefined` while it must
|
|
99
|
+
* not be offered — nothing is being resumed, the wait has settled, or the
|
|
100
|
+
* grace period above has not elapsed.
|
|
101
|
+
*/
|
|
102
|
+
release: (() => void) | undefined;
|
|
103
|
+
/** A release is in flight; the action must not be pressable twice. */
|
|
104
|
+
releasing: boolean;
|
|
105
|
+
/** The order was released — the caller opens a fresh checkout. */
|
|
106
|
+
released: boolean;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* How long the decision waits for the host's cart before deciding without it.
|
|
111
|
+
*
|
|
112
|
+
* The deferral (FUT-1213) assumes the cart eventually answers. A cart FETCH can
|
|
113
|
+
* fail — on exactly the flaky connection a buyer has coming back from their
|
|
114
|
+
* bank app — and an unbounded wait there is the same shape of bug this ticket
|
|
115
|
+
* is about, pointed the other way: the flow renders Dados forever and a paid
|
|
116
|
+
* buyer's confirmation never lands.
|
|
117
|
+
*
|
|
118
|
+
* So the wait is bounded, and past the bound the decision is made WITHOUT a
|
|
119
|
+
* basket, which is the pre-1213 answer: resume. That is the permissive
|
|
120
|
+
* direction, deliberately, and it is the same one `hostedCheckoutReturnPending`
|
|
121
|
+
* takes for an unloaded cart — a shopper whose cart never loaded cannot check
|
|
122
|
+
* out with it either way, so the only outcome still worth protecting is the
|
|
123
|
+
* confirmation of a payment that already happened.
|
|
124
|
+
*
|
|
125
|
+
* Eight seconds: long enough that no ordinary cart fetch reaches it, short
|
|
126
|
+
* enough that a buyer coming back from a payment is not left looking at a form.
|
|
127
|
+
*/
|
|
128
|
+
const BASKET_WAIT_MS = 8_000;
|
|
129
|
+
|
|
130
|
+
/** The decision, made ONCE, as soon as the basket is loaded enough to make it. */
|
|
131
|
+
function useResumeDecision(
|
|
132
|
+
tenantSlug: string | undefined,
|
|
133
|
+
basket: CheckoutBasketIdentity | undefined,
|
|
134
|
+
): {
|
|
135
|
+
resumed: { order: CheckoutOrder; step: HostedResumeStep } | null;
|
|
136
|
+
setResumed: (next: { order: CheckoutOrder; step: HostedResumeStep } | null) => void;
|
|
137
|
+
asking: CheckoutOrder | null;
|
|
138
|
+
setAsking: (next: CheckoutOrder | null) => void;
|
|
139
|
+
} {
|
|
140
|
+
const [resumed, setResumed] = useState<{ order: CheckoutOrder; step: HostedResumeStep } | null>(
|
|
141
|
+
null,
|
|
142
|
+
);
|
|
143
|
+
const [asking, setAsking] = useState<CheckoutOrder | null>(null);
|
|
144
|
+
// Once, whatever React does with this effect. The decision CONSUMES the
|
|
145
|
+
// parked entry, so a second run would find nothing and un-resume a buyer
|
|
146
|
+
// mid-confirmation — which is what StrictMode's double-invoke does for free.
|
|
147
|
+
const decided = useRef(false);
|
|
148
|
+
const waitedLongEnough = useBasketDeadline();
|
|
149
|
+
|
|
150
|
+
useResumeEffect(() => {
|
|
151
|
+
if (decided.current) return;
|
|
152
|
+
// Past the deadline the basket is treated as unnamed rather than as
|
|
153
|
+
// pending — see BASKET_WAIT_MS. A cart that never answered cannot be
|
|
154
|
+
// compared with, and waiting forever is the worse of the two failures.
|
|
155
|
+
const decision = takeHostedOrder(tenantSlug, waitedLongEnough ? undefined : basket);
|
|
156
|
+
// WAIT is the host's cart still loading. Nothing was read and nothing was
|
|
157
|
+
// consumed; the next render with a loaded basket decides for real.
|
|
158
|
+
if (decision.verdict === "WAIT") return;
|
|
159
|
+
decided.current = true;
|
|
160
|
+
if (decision.verdict === "RESUME") setResumed({ order: decision.order, step: decision.step });
|
|
161
|
+
if (decision.verdict === "ASK") setAsking(decision.order);
|
|
162
|
+
}, [tenantSlug, basket, waitedLongEnough]);
|
|
163
|
+
|
|
164
|
+
return { resumed, setResumed, asking, setAsking };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Rule 3: a DIFFERENT basket stands, so ask the server once what the parked
|
|
169
|
+
* order is worth.
|
|
170
|
+
*
|
|
171
|
+
* PAID resumes on the confirmation — that order is settled in the host's own
|
|
172
|
+
* row by the webhook, and it is the one thing a shopper must never lose.
|
|
173
|
+
* Anything else drops the entry and leaves the checkout to the basket in front
|
|
174
|
+
* of them.
|
|
175
|
+
*
|
|
176
|
+
* A FAILED ASK leaves the entry parked, deliberately. "We could not reach the
|
|
177
|
+
* server" is not "the order is not paid", and the shopper still gets their
|
|
178
|
+
* normal checkout either way — so the cheap outcome is that a later mount asks
|
|
179
|
+
* again and can still find the confirmation, rather than a dropped entry that
|
|
180
|
+
* can never be recovered.
|
|
181
|
+
*/
|
|
182
|
+
function useAskBeforeResuming(
|
|
183
|
+
asking: CheckoutOrder | null,
|
|
184
|
+
onResume: (next: { order: CheckoutOrder; step: HostedResumeStep }) => void,
|
|
185
|
+
onDrop: () => void,
|
|
186
|
+
): void {
|
|
187
|
+
const client = useCheckoutClientApi();
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
if (!asking) return undefined;
|
|
190
|
+
let live = true;
|
|
191
|
+
void client.getStatus(asking.orderId).then((answer) => {
|
|
192
|
+
if (!live) return;
|
|
193
|
+
if (!answer.ok) {
|
|
194
|
+
onDrop();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
forgetHostedOrder();
|
|
198
|
+
if (answer.data === "PAID") onResume({ order: asking, step: "status" });
|
|
199
|
+
else onDrop();
|
|
200
|
+
});
|
|
201
|
+
return () => {
|
|
202
|
+
live = false;
|
|
203
|
+
};
|
|
204
|
+
}, [asking, client, onDrop, onResume]);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Whether the wait for the host's cart has run out — see {@link BASKET_WAIT_MS}. */
|
|
208
|
+
function useBasketDeadline(): boolean {
|
|
209
|
+
const [elapsed, setElapsed] = useState(false);
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
const timer = setTimeout(() => setElapsed(true), BASKET_WAIT_MS);
|
|
212
|
+
return () => clearTimeout(timer);
|
|
213
|
+
}, []);
|
|
214
|
+
return elapsed;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Whether the "I did not pay" way out may be offered yet — see the constant. */
|
|
218
|
+
function useReleaseOffered(waiting: boolean): boolean {
|
|
219
|
+
const [elapsed, setElapsed] = useState(false);
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
if (!waiting) return undefined;
|
|
222
|
+
const timer = setTimeout(() => setElapsed(true), RELEASE_OFFER_AFTER_MS);
|
|
223
|
+
return () => clearTimeout(timer);
|
|
224
|
+
}, [waiting]);
|
|
225
|
+
return waiting && elapsed;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The release itself: ask the server to let the order go, unless it is paid. */
|
|
229
|
+
function useRelease(
|
|
230
|
+
order: CheckoutOrder | null,
|
|
231
|
+
onSettled: (status: OrderStatus) => void,
|
|
232
|
+
onReleased: () => void,
|
|
233
|
+
): { run: () => void; releasing: boolean } {
|
|
234
|
+
const client = useCheckoutClientApi();
|
|
235
|
+
const [releasing, setReleasing] = useState(false);
|
|
236
|
+
const run = useCallback(() => {
|
|
237
|
+
if (!order || releasing) return;
|
|
238
|
+
setReleasing(true);
|
|
239
|
+
void client.releaseCheckout({ orderId: order.orderId }).then((answer) => {
|
|
240
|
+
setReleasing(false);
|
|
241
|
+
// The one answer that overrules the buyer: they say they did not pay and
|
|
242
|
+
// the provider says they did. They keep their confirmation.
|
|
243
|
+
if (answer.ok && answer.data === "PAID") {
|
|
244
|
+
onSettled("PAID");
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
// Everything else — released, or a request that never got out — returns
|
|
248
|
+
// them to a usable checkout. A server that could not be reached has not
|
|
249
|
+
// taken their money either, and leaving them on a dead wait to be sure
|
|
250
|
+
// is the failure this ticket exists to remove.
|
|
251
|
+
forgetHostedOrder();
|
|
252
|
+
onReleased();
|
|
253
|
+
});
|
|
254
|
+
}, [client, order, releasing, onSettled, onReleased]);
|
|
255
|
+
return { run, releasing };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Still waiting on an answer for the order we resumed — nothing more. */
|
|
259
|
+
function stillWaiting(polling: boolean, released: boolean, settled: OrderStatus | null): boolean {
|
|
260
|
+
if (!polling || released) return false;
|
|
261
|
+
return settled === null || settled === "AWAITING_PAYMENT";
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** The wait's own poll, run for the CONFIRMATION leg and nothing else. */
|
|
265
|
+
function useResumePoll(
|
|
266
|
+
order: CheckoutOrder | null,
|
|
267
|
+
polling: boolean,
|
|
268
|
+
): { status: OrderStatus | null; timedOut: boolean; error: string | null; checkAgain: () => void } {
|
|
269
|
+
// A resume that lands back on the payment step is handed to the PIX or card
|
|
270
|
+
// pane, which runs its own wait — two polls for one order would race each
|
|
271
|
+
// other to the same answer.
|
|
272
|
+
return usePaymentPolling(order === null ? null : order.orderId, {
|
|
273
|
+
enabled: polling,
|
|
274
|
+
intervalMs: HOSTED_RESUME_FAST_MS,
|
|
275
|
+
slowAfterPolls: HOSTED_RESUME_FAST_POLLS,
|
|
276
|
+
slowIntervalMs: HOSTED_RESUME_SLOW_MS,
|
|
277
|
+
maxWaitMs: HOSTED_RESUME_WINDOW_MS,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function useHostedResume(
|
|
282
|
+
tenantSlug?: string,
|
|
283
|
+
basket?: CheckoutBasketIdentity,
|
|
284
|
+
): HostedResume {
|
|
285
|
+
const { resumed, setResumed, asking, setAsking } = useResumeDecision(tenantSlug, basket);
|
|
286
|
+
const [override, setOverride] = useState<OrderStatus | null>(null);
|
|
287
|
+
const [released, setReleased] = useState(false);
|
|
288
|
+
|
|
289
|
+
const drop = useCallback(() => setAsking(null), [setAsking]);
|
|
290
|
+
const resume = useCallback(
|
|
291
|
+
(next: { order: CheckoutOrder; step: HostedResumeStep }) => {
|
|
292
|
+
setAsking(null);
|
|
293
|
+
setResumed(next);
|
|
294
|
+
},
|
|
295
|
+
[setAsking, setResumed],
|
|
296
|
+
);
|
|
297
|
+
useAskBeforeResuming(asking, resume, drop);
|
|
298
|
+
|
|
299
|
+
const order = resumed === null ? null : resumed.order;
|
|
300
|
+
const step = resumed === null ? null : resumed.step;
|
|
301
|
+
const polling = step === "status";
|
|
302
|
+
const wait = useResumePoll(order, polling);
|
|
303
|
+
|
|
304
|
+
const settled = override === null ? wait.status : override;
|
|
305
|
+
const offered = useReleaseOffered(stillWaiting(polling, released, settled));
|
|
306
|
+
const onReleased = useCallback(() => {
|
|
307
|
+
setResumed(null);
|
|
308
|
+
setReleased(true);
|
|
309
|
+
}, [setResumed]);
|
|
310
|
+
const { run, releasing } = useRelease(order, setOverride, onReleased);
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
order,
|
|
314
|
+
step,
|
|
315
|
+
// A RELEASED order reports nothing. The poll's last answer is still sitting
|
|
316
|
+
// in its own state, and a controller that read it would carry "we are
|
|
317
|
+
// confirming your payment" into the fresh checkout the buyer just asked for.
|
|
318
|
+
status: released ? null : settled,
|
|
319
|
+
timedOut: wait.timedOut,
|
|
320
|
+
error: wait.error,
|
|
321
|
+
checkAgain: wait.checkAgain,
|
|
322
|
+
release: offered ? run : undefined,
|
|
323
|
+
releasing,
|
|
324
|
+
released,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
1
|
+
import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react";
|
|
2
2
|
|
|
3
3
|
import { useCheckoutClientApi } from "./client-context";
|
|
4
4
|
import { useCheckoutCopy } from "./copy-context";
|
|
5
|
-
import { createPollLoop, type PollLoop, type PollingOptions } from "./poll-loop";
|
|
5
|
+
import { createPollLoop, type PollLoop, type PollingOptions, type PollSink } from "./poll-loop";
|
|
6
6
|
import type { OrderStatus } from "./types";
|
|
7
7
|
|
|
8
8
|
/** What a consumer reads off the wait, and the one action it can take. */
|
|
@@ -24,6 +24,50 @@ interface PaymentPollingState {
|
|
|
24
24
|
checkAgain: () => void;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* What one wait has learned, and WHICH order it learned it about (FUT-1170).
|
|
29
|
+
*
|
|
30
|
+
* The three facts used to be three `useState`s with no order id attached, so a
|
|
31
|
+
* consumer re-pointed at a new charge read the PREVIOUS charge's answer until
|
|
32
|
+
* the new loop's first poll returned. That is not a cosmetic flash: a terminal
|
|
33
|
+
* status is acted on, and `PixView` acts on it by handing the flow to the
|
|
34
|
+
* confirmation screen — so a fresh, unpaid PIX code was reported settled by the
|
|
35
|
+
* code it had just replaced. Regenerating an expired charge is the path that
|
|
36
|
+
* does exactly this, and it is the one FUT-1170 was reported from.
|
|
37
|
+
*
|
|
38
|
+
* Stamping the answer and comparing at READ time (rather than clearing the
|
|
39
|
+
* state in an effect) is what makes the guard hold in the render that changes
|
|
40
|
+
* the id, with no intermediate frame in which the stale answer is still live.
|
|
41
|
+
*/
|
|
42
|
+
interface PollAnswer {
|
|
43
|
+
orderId: string | null;
|
|
44
|
+
status: OrderStatus | null;
|
|
45
|
+
error: string | null;
|
|
46
|
+
timedOut: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const NO_ANSWER: PollAnswer = { orderId: null, status: null, error: null, timedOut: false };
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Where a wait for ONE order writes what it learns.
|
|
53
|
+
*
|
|
54
|
+
* Every write is stamped with the order the loop was started for, and a write
|
|
55
|
+
* arriving against a DIFFERENT stamp starts from `NO_ANSWER` rather than
|
|
56
|
+
* merging into the previous order's facts.
|
|
57
|
+
*/
|
|
58
|
+
function sinkFor(
|
|
59
|
+
orderId: string,
|
|
60
|
+
setAnswer: Dispatch<SetStateAction<PollAnswer>>,
|
|
61
|
+
): PollSink {
|
|
62
|
+
const write = (patch: Partial<PollAnswer>): void =>
|
|
63
|
+
setAnswer((prev) => ({ ...(prev.orderId === orderId ? prev : NO_ANSWER), ...patch, orderId }));
|
|
64
|
+
return {
|
|
65
|
+
setStatus: (status) => write({ status }),
|
|
66
|
+
setError: (error) => write({ error }),
|
|
67
|
+
setTimedOut: (timedOut) => write({ timedOut }),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
27
71
|
|
|
28
72
|
/**
|
|
29
73
|
* Ask again the moment the shopper could plausibly have an answer for us.
|
|
@@ -77,9 +121,7 @@ export function usePaymentPolling(
|
|
|
77
121
|
askTimeoutMs,
|
|
78
122
|
}: PollingOptions = {},
|
|
79
123
|
): PaymentPollingState {
|
|
80
|
-
const [
|
|
81
|
-
const [error, setError] = useState<string | null>(null);
|
|
82
|
-
const [timedOut, setTimedOut] = useState(false);
|
|
124
|
+
const [answer, setAnswer] = useState<PollAnswer>(NO_ANSWER);
|
|
83
125
|
// Whichever mount this tree is bound to (FUT-741). Stable per provider, so it
|
|
84
126
|
// belongs in the deps below rather than being read out of a ref: a checkout
|
|
85
127
|
// re-pointed at another mount must re-poll against THAT one.
|
|
@@ -106,7 +148,7 @@ export function usePaymentPolling(
|
|
|
106
148
|
askTimeoutMs,
|
|
107
149
|
askTimeoutError: transportCopy.offline,
|
|
108
150
|
},
|
|
109
|
-
|
|
151
|
+
sinkFor(orderId, setAnswer),
|
|
110
152
|
);
|
|
111
153
|
loop.current = running;
|
|
112
154
|
running.restart();
|
|
@@ -133,6 +175,15 @@ export function usePaymentPolling(
|
|
|
133
175
|
loop.current?.restart();
|
|
134
176
|
}, []);
|
|
135
177
|
|
|
136
|
-
|
|
178
|
+
// The answer is this order's, or there is no answer yet. Read here rather
|
|
179
|
+
// than reset in an effect: an effect runs after paint, so the render that
|
|
180
|
+
// re-points the hook would still hand a consumer the old charge's status.
|
|
181
|
+
const current = answer.orderId === orderId ? answer : NO_ANSWER;
|
|
182
|
+
return {
|
|
183
|
+
status: current.status,
|
|
184
|
+
error: current.error,
|
|
185
|
+
timedOut: current.timedOut,
|
|
186
|
+
checkAgain,
|
|
187
|
+
};
|
|
137
188
|
}
|
|
138
189
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { useEffect, useState } from "react";
|
|
2
2
|
|
|
3
|
+
import { parkedBasket } from "./basket";
|
|
4
|
+
import type { ChallengeScope } from "./card-outcome";
|
|
3
5
|
import { useCheckoutClientApi } from "./client-context";
|
|
4
6
|
import { UNRESOLVED_CODE } from "./failure-codes";
|
|
5
7
|
import { rememberHostedOrder } from "./hosted-return";
|
|
@@ -62,12 +64,33 @@ export interface WalletCharge {
|
|
|
62
64
|
*/
|
|
63
65
|
const WALLET_AWAITING_WAIT_MS = 90_000;
|
|
64
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Park the order before the tab leaves, WITH the store and the basket
|
|
69
|
+
* (FUT-1240).
|
|
70
|
+
*
|
|
71
|
+
* Both absences are read as "no opinion" by the resume — `belongsHere` passes
|
|
72
|
+
* a slug-less entry at any store and the basket rule passes a basket-less one
|
|
73
|
+
* against any basket — so a wallet's 3-D Secure hand-off that named neither
|
|
74
|
+
* resumed over whatever checkout mounted next. The card path has carried these
|
|
75
|
+
* since FUT-1213; this call site is the one that did not.
|
|
76
|
+
*/
|
|
77
|
+
function parkForHandover(order: CheckoutOrder, scope: ChallengeScope): void {
|
|
78
|
+
const basket = parkedBasket(scope.basket);
|
|
79
|
+
rememberHostedOrder(order, {
|
|
80
|
+
...(scope.tenantSlug === undefined ? {} : { tenantSlug: scope.tenantSlug }),
|
|
81
|
+
...(basket === undefined ? {} : { basket }),
|
|
82
|
+
handoff: true,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
65
86
|
/** The wallet charge state machine. See the module comment. */
|
|
66
87
|
export function useWalletCharge(
|
|
67
88
|
order: CheckoutOrder,
|
|
68
89
|
buyer: BuyerInfo,
|
|
69
90
|
onResolved: (status: OrderStatus) => void,
|
|
70
91
|
pollIntervalMs = 2500,
|
|
92
|
+
/** WHOSE store and WHICH basket — see {@link parkForHandover} (FUT-1240). */
|
|
93
|
+
scope: ChallengeScope = {},
|
|
71
94
|
): WalletCharge {
|
|
72
95
|
const [phase, setPhase] = useState<WalletPhase>("idle");
|
|
73
96
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -116,7 +139,7 @@ export function useWalletCharge(
|
|
|
116
139
|
// FUT-698): park the order and hand the buyer over, exactly as the card
|
|
117
140
|
// path does. `phase` stays as-is — the tab is navigating away.
|
|
118
141
|
if (charged.data.hostedCheckoutUrl) {
|
|
119
|
-
|
|
142
|
+
parkForHandover(order, scope);
|
|
120
143
|
navigate(charged.data.hostedCheckoutUrl);
|
|
121
144
|
return true;
|
|
122
145
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CheckoutCopy } from "./copy-context";
|
|
2
|
+
import type { CheckoutDeclineReason } from "./decline";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Every string the legacy checkout views render — required props, with NO
|
|
@@ -71,8 +72,26 @@ export interface StatusOutcomeCopy {
|
|
|
71
72
|
export interface PaymentStatusCopy {
|
|
72
73
|
paid: StatusOutcomeCopy;
|
|
73
74
|
awaiting: StatusOutcomeCopy;
|
|
75
|
+
/**
|
|
76
|
+
* A refusal with nothing more specific to say. Still the whole of what a
|
|
77
|
+
* buyer reads when the server sent no `declineReason`, or sent one this
|
|
78
|
+
* bundle has never heard of — see {@link PaymentStatusCopy.declined}.
|
|
79
|
+
*/
|
|
74
80
|
failed: StatusOutcomeCopy;
|
|
75
81
|
expired: StatusOutcomeCopy;
|
|
82
|
+
/**
|
|
83
|
+
* What a REFUSED CARD says, per normalized reason (FUT-1145).
|
|
84
|
+
*
|
|
85
|
+
* The server has classified declines since FUT-340 and then discarded the
|
|
86
|
+
* classification on the wire, so an expired card, a card reported stolen, no
|
|
87
|
+
* funds, and "attempts exhausted — do not retry" all reached the buyer as one
|
|
88
|
+
* sentence offering a retry that could not work. Each of those asks something
|
|
89
|
+
* different of the person holding the phone, and only they can act on it.
|
|
90
|
+
*
|
|
91
|
+
* A reason with no entry — a newer server, a host mid-migration — falls back
|
|
92
|
+
* to {@link PaymentStatusCopy.failed}, which is exactly today's screen.
|
|
93
|
+
*/
|
|
94
|
+
declined: Partial<Record<CheckoutDeclineReason, StatusOutcomeCopy>>;
|
|
76
95
|
awaitingTimedOut: StatusOutcomeCopy;
|
|
77
96
|
/**
|
|
78
97
|
* The wait cannot reach the payment right now (FUT-1144) — and is STILL
|
|
@@ -86,6 +105,19 @@ export interface PaymentStatusCopy {
|
|
|
86
105
|
awaitingUnreachable: StatusOutcomeCopy;
|
|
87
106
|
retryAction: string;
|
|
88
107
|
regenerateAction: string;
|
|
108
|
+
/**
|
|
109
|
+
* "I did not pay" — the buyer's own way out of a wait with no terminal state
|
|
110
|
+
* (FUT-1146).
|
|
111
|
+
*
|
|
112
|
+
* A cancelled or refused payment on a provider's own page produces NO signal
|
|
113
|
+
* anywhere: the provider's check publishes `success` and `paid` and nothing
|
|
114
|
+
* else, an unpaid webhook delivery fails verification before it is parsed,
|
|
115
|
+
* and no server-side writer of FAILED is reachable from it. So the screen
|
|
116
|
+
* waited fifteen minutes and then told someone who had never paid not to pay
|
|
117
|
+
* again. The only signal that exists is this one, and it is safe to act on
|
|
118
|
+
* because the server re-asks the provider before letting anything go.
|
|
119
|
+
*/
|
|
120
|
+
notPaidAction: string;
|
|
89
121
|
/**
|
|
90
122
|
* Ask now, rather than waiting for the next automatic poll — and, once the
|
|
91
123
|
* wait has run out, the only thing that starts it again.
|
|
@@ -98,12 +130,50 @@ export interface PaymentStatusCopy {
|
|
|
98
130
|
receiptEmailLabel: string;
|
|
99
131
|
}
|
|
100
132
|
|
|
133
|
+
/**
|
|
134
|
+
* The pipeline engine's own two sentences (FUT-1240).
|
|
135
|
+
*
|
|
136
|
+
* REQUIRED, both of them, and stated here rather than defaulted for the reason
|
|
137
|
+
* every other string in this file is: a default in the origin host's language
|
|
138
|
+
* reads as finished right up until a shopper sees it. These two are the whole
|
|
139
|
+
* of what the ENGINE renders on its own account — everything else on screen
|
|
140
|
+
* belongs to a step, a gate or a settlement method.
|
|
141
|
+
*/
|
|
142
|
+
export interface CheckoutPipelineCopy {
|
|
143
|
+
/**
|
|
144
|
+
* What is on screen while the checkout has nothing to show yet: a gate still
|
|
145
|
+
* deciding, the store's protocol still in flight, or no step applying.
|
|
146
|
+
*
|
|
147
|
+
* It replaces a blank frame. A shopper who taps "pagar" and gets an empty
|
|
148
|
+
* page taps again.
|
|
149
|
+
*/
|
|
150
|
+
loading: string;
|
|
151
|
+
/**
|
|
152
|
+
* PER SETTLEMENT METHOD, what the shopper reads between choosing it and the
|
|
153
|
+
* surface that takes the money arriving — the hand-off interstitial's own
|
|
154
|
+
* line. Keyed by `SettlementMethodDescriptor.id`.
|
|
155
|
+
*
|
|
156
|
+
* Per method because the sentences are not interchangeable: a Pix hand-off
|
|
157
|
+
* and a card challenge send the shopper to different places for different
|
|
158
|
+
* reasons, and one shared "aguarde" describes neither. A method with no
|
|
159
|
+
* entry falls back to {@link CheckoutPipelineCopy.loading}, which is what a
|
|
160
|
+
* host registering a new charged method gets until it writes the sentence.
|
|
161
|
+
*/
|
|
162
|
+
awaitingHandover: Readonly<Record<string, string>>;
|
|
163
|
+
}
|
|
164
|
+
|
|
101
165
|
/** What the legacy `CheckoutFlow` itself renders and must be handed. */
|
|
102
166
|
export interface CheckoutViewCopy {
|
|
103
167
|
steps: CheckoutStepperCopy;
|
|
104
168
|
dados: DadosStepCopy;
|
|
105
169
|
emptyCart: EmptyCartCopy;
|
|
106
170
|
status: PaymentStatusCopy;
|
|
171
|
+
/**
|
|
172
|
+
* The engine's own two sentences (FUT-1240). Carried here, beside the
|
|
173
|
+
* stepper labels, so a host still answers copy exactly once — the pipeline
|
|
174
|
+
* is another way of rendering this same checkout, not a second surface.
|
|
175
|
+
*/
|
|
176
|
+
pipeline: CheckoutPipelineCopy;
|
|
107
177
|
/**
|
|
108
178
|
* The words the screens BELOW these read — the card fields, the wallet
|
|
109
179
|
* panes, the buyer-details inputs (FUT-760).
|
|
@@ -149,7 +149,13 @@ export function WalletCardPane(props: WalletPaneProps): JSX.Element {
|
|
|
149
149
|
const { Alert } = useCheckoutComponents();
|
|
150
150
|
const copy = useCheckoutCopy().screens.settling;
|
|
151
151
|
const { order, buyer, config, tenantSlug, onResolved, pollIntervalMs } = props;
|
|
152
|
-
const
|
|
152
|
+
const { freshInstrument, basket } = props;
|
|
153
|
+
// The same store and basket the card path already parks with (FUT-1213,
|
|
154
|
+
// FUT-1240): a wallet's 3-D Secure hand-off is a hand-off like any other.
|
|
155
|
+
const wallet = useWalletCharge(order, buyer, onResolved, pollIntervalMs, {
|
|
156
|
+
...(tenantSlug === undefined ? {} : { tenantSlug }),
|
|
157
|
+
...(basket === undefined ? {} : { basket }),
|
|
158
|
+
});
|
|
153
159
|
// A sheet failure the wallet reported before any charge existed (pay.js
|
|
154
160
|
// refused, merchant validation unavailable, the sheet errored) — shown
|
|
155
161
|
// beside the form, which stays usable.
|
|
@@ -187,6 +193,8 @@ export function WalletCardPane(props: WalletPaneProps): JSX.Element {
|
|
|
187
193
|
tenantSlug={tenantSlug}
|
|
188
194
|
onResolved={onResolved}
|
|
189
195
|
pollIntervalMs={pollIntervalMs}
|
|
196
|
+
freshInstrument={freshInstrument}
|
|
197
|
+
basket={basket}
|
|
190
198
|
/>
|
|
191
199
|
</Box>
|
|
192
200
|
);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE WAY OUT OF A CHECKOUT (FUT-1240).
|
|
3
|
+
*
|
|
4
|
+
* A checkout has two controls that leave it — the chrome's back link on the
|
|
5
|
+
* first step, and "voltar ao cardápio" on the confirmation — and until this
|
|
6
|
+
* existed they answered differently: back honoured a registered
|
|
7
|
+
* {@link CheckoutExit} while the confirmation went straight to
|
|
8
|
+
* `ports.exitToCatalog`. So a host that registered its router's own catalog
|
|
9
|
+
* route got it from one control and a full page navigation from the other,
|
|
10
|
+
* with the difference visible only to the shopper who pressed the second one.
|
|
11
|
+
*
|
|
12
|
+
* `exit` is a FACTORY-scope constant, so calling its `useCatalog()` here is a
|
|
13
|
+
* hook call whose presence never changes between renders.
|
|
14
|
+
*/
|
|
15
|
+
import { useCallback } from "react";
|
|
16
|
+
|
|
17
|
+
import type { FlowsRuntime } from "./runtime";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Leave for the host's catalog, through whichever door it registered.
|
|
21
|
+
*
|
|
22
|
+
* `exit` wins when there is one — it is the host's own router, and a router
|
|
23
|
+
* navigation keeps the SPA alive. `ports.exitToCatalog` is the fallback every
|
|
24
|
+
* host already wires, and stays the answer for a host that registered nothing.
|
|
25
|
+
*/
|
|
26
|
+
export function useCatalogExit(runtime: FlowsRuntime): () => void {
|
|
27
|
+
const exit = runtime.config.exit;
|
|
28
|
+
const catalog = exit?.useCatalog();
|
|
29
|
+
return useCallback(() => {
|
|
30
|
+
if (exit && catalog) exit.navigate(catalog.to);
|
|
31
|
+
else runtime.config.ports.exitToCatalog();
|
|
32
|
+
}, [exit, catalog, runtime]);
|
|
33
|
+
}
|