@12-apps/payments-frontend 3.24.0 → 3.25.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 +1 -1
- package/src/components/checkout/checkout-actions.ts +1 -108
- package/src/components/checkout/live-context.tsx +108 -0
- package/src/components/checkout/poll-live.ts +46 -0
- package/src/components/checkout/poll-loop.ts +13 -13
- package/src/components/checkout/poll-rearm.ts +1 -1
- package/src/components/checkout/start-payment.ts +151 -0
- package/src/components/checkout/use-checkout-controller.ts +2 -2
- package/src/components/checkout/use-payment-polling.ts +4 -0
- package/src/index.ts +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/payments-frontend",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.25.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"description": "Browser half of the vendor-agnostic payments platform: plug-and-play MUI components for the per-provider settings page (credential form from each provider's schema, masked hints, verify/enable) and the checkout page (PIX QR + polling, card tokenization, hosted-checkout redirect), plus the headless hooks and fetch clients they build on. Talks only to the host's payments HTTP surface — never to a provider directly. Microfrontend-ready: no app coupling, host injects theme and auth.",
|
|
@@ -3,9 +3,7 @@ import { useCallback, useEffect, useState, type Dispatch, type SetStateAction }
|
|
|
3
3
|
import { buyerGateError } from "./buyer-gate";
|
|
4
4
|
import type { ConfirmationWait } from "./confirmation-wait";
|
|
5
5
|
import type { CheckoutDecline } from "./decline";
|
|
6
|
-
import { forgetHostedOrder
|
|
7
|
-
import { parkedBasket, type CheckoutBasketIdentity } from "./basket";
|
|
8
|
-
import type { CheckoutNavigate } from "./navigate-context";
|
|
6
|
+
import { forgetHostedOrder } from "./hosted-return";
|
|
9
7
|
import type { CheckoutScreensCopy } from "./screens-copy";
|
|
10
8
|
import type {
|
|
11
9
|
BuyerContact,
|
|
@@ -13,8 +11,6 @@ import type {
|
|
|
13
11
|
BuyerInfo,
|
|
14
12
|
CheckoutCustomerField,
|
|
15
13
|
CheckoutOrder,
|
|
16
|
-
CreateOrderRequest,
|
|
17
|
-
CreateOrderResult,
|
|
18
14
|
OrderStatus,
|
|
19
15
|
PaymentMethod,
|
|
20
16
|
} from "./types";
|
|
@@ -282,106 +278,3 @@ export function useRetryAction(input: {
|
|
|
282
278
|
clearError, decline, order, setDecline, setFinalStatus, setFreshInstrument, setOrder, setStep,
|
|
283
279
|
]);
|
|
284
280
|
}
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* Hand the buyer to a redirect provider's own page, if that is where this
|
|
288
|
-
* charge settles (FUT-556).
|
|
289
|
-
*
|
|
290
|
-
* Called BEFORE the order is stored: storing it first would render the PIX or
|
|
291
|
-
* card step for a provider that returned neither, which is the dead end this
|
|
292
|
-
* fixes.
|
|
293
|
-
*
|
|
294
|
-
* A full navigation rather than the host's router, because the destination is
|
|
295
|
-
* another origin. The return trip comes back to this same checkout route
|
|
296
|
-
* carrying `transaction_nsu` + `slug`, which the status poll already reads.
|
|
297
|
-
*
|
|
298
|
-
* @returns true when the buyer is on their way and the caller must stop.
|
|
299
|
-
*/
|
|
300
|
-
function handOverToProvider(
|
|
301
|
-
order: CheckoutOrder,
|
|
302
|
-
navigate: CheckoutNavigate,
|
|
303
|
-
tenantSlug?: string,
|
|
304
|
-
basket?: CheckoutBasketIdentity,
|
|
305
|
-
): boolean {
|
|
306
|
-
if (!order.hostedCheckoutUrl) return false;
|
|
307
|
-
// PARK FIRST, navigate second. The order is the only thing the return trip
|
|
308
|
-
// has to rehydrate from, and the navigation may tear this SPA down before
|
|
309
|
-
// any later write lands.
|
|
310
|
-
//
|
|
311
|
-
// The STORE goes with it: one tab holds one slot, and on a multi-tenant
|
|
312
|
-
// storefront every store shares an origin. Without the slug, abandoning this
|
|
313
|
-
// hand-off and opening another store's checkout resumed THIS order there.
|
|
314
|
-
//
|
|
315
|
-
// So does the BASKET (FUT-1213): a hand-off nobody completed must not resume
|
|
316
|
-
// itself over the shopper's next basket, and the only way to tell the two
|
|
317
|
-
// apart later is to record which basket this one was raised from.
|
|
318
|
-
rememberHostedOrder(order, {
|
|
319
|
-
tenantSlug,
|
|
320
|
-
basket: parkedBasket(basket),
|
|
321
|
-
handoff: true,
|
|
322
|
-
});
|
|
323
|
-
navigate(order.hostedCheckoutUrl);
|
|
324
|
-
return true;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
/**
|
|
328
|
-
* Raise the order for a chosen method, and decide what happens to it.
|
|
329
|
-
*
|
|
330
|
-
* Three outcomes, in order: a refusal the step renders, a HAND-OFF that leaves
|
|
331
|
-
* this page for the provider's own, and an order raised here.
|
|
332
|
-
*/
|
|
333
|
-
export function useStartPayment(input: {
|
|
334
|
-
buyer: BuyerInfo;
|
|
335
|
-
saveProfile: boolean;
|
|
336
|
-
createOrder: (request: CreateOrderRequest) => Promise<CreateOrderResult>;
|
|
337
|
-
navigate: CheckoutNavigate;
|
|
338
|
-
tenantSlug: string | undefined;
|
|
339
|
-
basket: CheckoutBasketIdentity | undefined;
|
|
340
|
-
failure: { clear: () => void; fail: (next: { message: string; field?: BuyerField | null; code?: string }) => void };
|
|
341
|
-
setCreating: Dispatch<SetStateAction<boolean>>;
|
|
342
|
-
setDecline: Dispatch<SetStateAction<CheckoutDecline | null>>;
|
|
343
|
-
setOrder: Dispatch<SetStateAction<CheckoutOrder | null>>;
|
|
344
|
-
setFinalStatus: Dispatch<SetStateAction<OrderStatus | null>>;
|
|
345
|
-
}): (chosen: PaymentMethod, override?: BuyerInfo) => Promise<void> {
|
|
346
|
-
const { buyer, saveProfile, createOrder, navigate, tenantSlug, basket, failure } = input;
|
|
347
|
-
const { setCreating, setDecline, setOrder, setFinalStatus } = input;
|
|
348
|
-
return useCallback(
|
|
349
|
-
async (chosen: PaymentMethod, override?: BuyerInfo) => {
|
|
350
|
-
failure.clear();
|
|
351
|
-
setDecline(null);
|
|
352
|
-
// THE CHARGE BEING REPLACED IS DROPPED FIRST (FUT-1170), before the raise
|
|
353
|
-
// rather than after it. Raising a payment means whatever was on screen is
|
|
354
|
-
// no longer the one being paid, and a provider round trip is long enough
|
|
355
|
-
// for the difference to matter: "Gerar novo código" left the expired
|
|
356
|
-
// charge mounted, so its own view polled it, got the terminal EXPIRED it
|
|
357
|
-
// was always going to get, and bounced the flow to the confirmation
|
|
358
|
-
// screen — where the new charge then landed with nothing polling it.
|
|
359
|
-
//
|
|
360
|
-
// A no-op on every other caller (the auto-raise, the alternate e-mail and
|
|
361
|
-
// the retry all run with no order held), which is the point: the clear
|
|
362
|
-
// belongs to what raising a payment MEANS, not to the one path that
|
|
363
|
-
// noticed.
|
|
364
|
-
setOrder(null);
|
|
365
|
-
setFinalStatus(null);
|
|
366
|
-
setCreating(true);
|
|
367
|
-
const result = await createOrder({ method: chosen, buyer: override ?? buyer, saveProfile });
|
|
368
|
-
setCreating(false);
|
|
369
|
-
if (!result.ok) {
|
|
370
|
-
failure.fail(result.error);
|
|
371
|
-
return;
|
|
372
|
-
}
|
|
373
|
-
if (handOverToProvider(result.data, navigate, tenantSlug, basket)) return;
|
|
374
|
-
// PARKED EVEN THOUGH NOBODY IS LEAVING (FUT-1140). A low-memory phone
|
|
375
|
-
// discards this tab while the shopper is in their bank app, and the SPA
|
|
376
|
-
// that comes back has never heard of the order it raised — so the buyer
|
|
377
|
-
// meets an empty cart and a retry button instead of the confirmation for
|
|
378
|
-
// the payment they just made.
|
|
379
|
-
rememberHostedOrder(result.data, { tenantSlug, basket: parkedBasket(basket) });
|
|
380
|
-
setOrder(result.data);
|
|
381
|
-
},
|
|
382
|
-
[
|
|
383
|
-
buyer, saveProfile, createOrder, failure, navigate, tenantSlug, basket,
|
|
384
|
-
setCreating, setDecline, setOrder, setFinalStatus,
|
|
385
|
-
],
|
|
386
|
-
);
|
|
387
|
-
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host's realtime channel for THIS buyer, handed to every payment wait
|
|
3
|
+
* below it (FUT-649).
|
|
4
|
+
*
|
|
5
|
+
* Nothing here knows what the channel is. A host that can hear an order move —
|
|
6
|
+
* a signed-in buyer's own topic, say — passes whether that channel is live and
|
|
7
|
+
* a way to be told when it fires; every wait under the provider then asks the
|
|
8
|
+
* moment it fires and slows its own timer while the channel is live. A tree with
|
|
9
|
+
* no provider, or a host that passes `live: false` (a guest, who has no channel
|
|
10
|
+
* of their own), polls exactly as before.
|
|
11
|
+
*
|
|
12
|
+
* The hint carries nothing and is never trusted as an answer: it only makes the
|
|
13
|
+
* wait ask `/status` now. The endpoint stays the truth.
|
|
14
|
+
*/
|
|
15
|
+
import { createContext, useCallback, useContext, useEffect, useRef, type JSX, type ReactNode } from "react";
|
|
16
|
+
|
|
17
|
+
import type { PollLoop } from "./poll-loop";
|
|
18
|
+
import { REARM_QUIET_MS } from "./poll-rearm";
|
|
19
|
+
|
|
20
|
+
/** What a host hands the checkout about its own realtime channel. */
|
|
21
|
+
export interface CheckoutLiveSignal {
|
|
22
|
+
/** Whether the channel is connected right now. */
|
|
23
|
+
live: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Be told when the channel says this buyer's orders moved. Returns the
|
|
26
|
+
* unsubscribe. Should be stable across renders: a new function re-subscribes.
|
|
27
|
+
*/
|
|
28
|
+
subscribe: (onHint: () => void) => () => void;
|
|
29
|
+
/** The wait's healthy delay while live (ms). Defaults to {@link DEFAULT_LIVE_INTERVAL_MS}. */
|
|
30
|
+
liveIntervalMs?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The floor under a lost hint while the channel is live: the same 15 s the PIX
|
|
35
|
+
* wait already backs off to, so a wait whose channel is up never asks faster
|
|
36
|
+
* than the slowest wait it would have become anyway.
|
|
37
|
+
*/
|
|
38
|
+
export const DEFAULT_LIVE_INTERVAL_MS = 15_000;
|
|
39
|
+
|
|
40
|
+
const CheckoutLiveContext = createContext<CheckoutLiveSignal | null>(null);
|
|
41
|
+
|
|
42
|
+
/** Hand every payment wait below the host's channel. */
|
|
43
|
+
export function CheckoutLiveProvider({
|
|
44
|
+
signal,
|
|
45
|
+
children,
|
|
46
|
+
}: {
|
|
47
|
+
signal: CheckoutLiveSignal;
|
|
48
|
+
children: ReactNode;
|
|
49
|
+
}): JSX.Element {
|
|
50
|
+
return <CheckoutLiveContext.Provider value={signal}>{children}</CheckoutLiveContext.Provider>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** What one wait reads off the provider, for `createPollLoop`. */
|
|
54
|
+
interface LiveWait {
|
|
55
|
+
isLive: () => boolean;
|
|
56
|
+
liveIntervalMs: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type LoopRef = { readonly current: PollLoop | null };
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Ask now — and if the loop declines because it asked a moment ago, ask again
|
|
63
|
+
* once that quiet window is over. The window exists so a flapping network does
|
|
64
|
+
* not stampede `/status`; a hint is different, because the ask it lands just
|
|
65
|
+
* after may have been answered BEFORE the order moved. Dropping it would leave
|
|
66
|
+
* the buyer on the live floor with the news already sent.
|
|
67
|
+
*/
|
|
68
|
+
function wake(loop: LoopRef, pending: { timer?: ReturnType<typeof setTimeout> }): void {
|
|
69
|
+
if (loop.current?.poke() !== false) return;
|
|
70
|
+
clearTimeout(pending.timer);
|
|
71
|
+
pending.timer = setTimeout(() => loop.current?.poke(), REARM_QUIET_MS);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Tie one wait to the host's channel: a hint asks now, and a channel that
|
|
76
|
+
* DROPS asks now too — the timer may be sleeping out the live floor, and the
|
|
77
|
+
* buyer should not wait the rest of it with nobody left to wake them.
|
|
78
|
+
*
|
|
79
|
+
* `loop` is the hook's own ref to its running loop, read when the event lands.
|
|
80
|
+
*/
|
|
81
|
+
export function useLiveWait(loop: LoopRef): LiveWait {
|
|
82
|
+
const signal = useContext(CheckoutLiveContext);
|
|
83
|
+
const live = signal?.live === true;
|
|
84
|
+
const liveRef = useRef(live);
|
|
85
|
+
const pending = useRef<{ timer?: ReturnType<typeof setTimeout> }>({});
|
|
86
|
+
const subscribe = signal?.subscribe;
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
const was = liveRef.current;
|
|
90
|
+
liveRef.current = live;
|
|
91
|
+
if (was && !live) wake(loop, pending.current);
|
|
92
|
+
}, [live, loop]);
|
|
93
|
+
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (!subscribe) return undefined;
|
|
96
|
+
const retry = pending.current;
|
|
97
|
+
const unsubscribe = subscribe(() => wake(loop, retry));
|
|
98
|
+
return () => {
|
|
99
|
+
unsubscribe();
|
|
100
|
+
clearTimeout(retry.timer);
|
|
101
|
+
};
|
|
102
|
+
}, [subscribe, loop]);
|
|
103
|
+
|
|
104
|
+
// Stable, so the loop's deps do not churn: the loop reads the flag when it
|
|
105
|
+
// schedules, and a flip must never restart the wait's wall clock.
|
|
106
|
+
const isLive = useCallback(() => liveRef.current, []);
|
|
107
|
+
return { isLive, liveIntervalMs: signal?.liveIntervalMs ?? DEFAULT_LIVE_INTERVAL_MS };
|
|
108
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cadence a payment wait keeps while the host HEARS the order move
|
|
3
|
+
* (FUT-649).
|
|
4
|
+
*
|
|
5
|
+
* The wait asks `/status` every 2.5 s because, on its own, asking is the only
|
|
6
|
+
* way it can learn that a webhook settled the order in another process. A host
|
|
7
|
+
* that holds a realtime channel for this buyer already learns it the moment it
|
|
8
|
+
* happens — `CheckoutLiveProvider` hands that hint in and the wait asks at once.
|
|
9
|
+
* While that channel is live the timer is only the floor under a lost hint, so
|
|
10
|
+
* it slows to {@link LiveCadence.liveIntervalMs}; the moment the channel drops,
|
|
11
|
+
* the ordinary cadence is back on the next tick.
|
|
12
|
+
*
|
|
13
|
+
* Split out of `poll-loop.ts` so the loop stays one question: WHEN to ask. This
|
|
14
|
+
* file answers only how being live changes that answer.
|
|
15
|
+
*/
|
|
16
|
+
export interface LiveCadence {
|
|
17
|
+
/**
|
|
18
|
+
* Whether the host's channel is live right now. Read at every scheduling
|
|
19
|
+
* decision rather than once, so a channel that drops mid-wait takes effect
|
|
20
|
+
* at the next tick instead of at the next restart.
|
|
21
|
+
*/
|
|
22
|
+
isLive?: () => boolean;
|
|
23
|
+
/**
|
|
24
|
+
* The healthy delay while live (ms). A floor, never a speed-up: a slower
|
|
25
|
+
* cadence already in force (the PIX backoff) is kept.
|
|
26
|
+
*/
|
|
27
|
+
liveIntervalMs?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `delay`, stretched to the live floor while the host's channel is live. */
|
|
31
|
+
export function withLive(delay: number, options: LiveCadence): number {
|
|
32
|
+
if (options.liveIntervalMs === undefined || options.isLive?.() !== true) return delay;
|
|
33
|
+
return Math.max(delay, options.liveIntervalMs);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether the wait must stay open until its wall clock actually strikes.
|
|
38
|
+
*
|
|
39
|
+
* The loop normally ends one sleep EARLY when that sleep would cross
|
|
40
|
+
* `maxWaitMs` — nothing could land in it. While live, something can: the hint.
|
|
41
|
+
* Ending early there would hand a 90 s card wait over at 75 s and then refuse
|
|
42
|
+
* the hint that arrives at 80 s, so the armed deadline ends it instead.
|
|
43
|
+
*/
|
|
44
|
+
export function waitsForDeadline(options: LiveCadence): boolean {
|
|
45
|
+
return options.isLive?.() === true;
|
|
46
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Result } from "../../result";
|
|
2
|
+
import { waitsForDeadline, withLive, type LiveCadence } from "./poll-live";
|
|
2
3
|
import { claimRearm } from "./poll-rearm";
|
|
3
4
|
import { TERMINAL_STATUSES, type OrderStatus } from "./types";
|
|
4
5
|
|
|
@@ -11,7 +12,7 @@ import { TERMINAL_STATUSES, type OrderStatus } from "./types";
|
|
|
11
12
|
* attempt counter that lets a hung request be abandoned rather than waited on.
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
|
-
export interface PollingOptions {
|
|
15
|
+
export interface PollingOptions extends LiveCadence {
|
|
15
16
|
/** Delay between successful polls (ms). */
|
|
16
17
|
intervalMs?: number;
|
|
17
18
|
/**
|
|
@@ -106,7 +107,7 @@ function healthyDelay(healthy: number, options: PollingOptions): number {
|
|
|
106
107
|
const { intervalMs = 2500, slowAfterPolls, slowIntervalMs } = options;
|
|
107
108
|
const backingOff =
|
|
108
109
|
slowAfterPolls !== undefined && slowIntervalMs !== undefined && healthy >= slowAfterPolls;
|
|
109
|
-
return backingOff ? slowIntervalMs : intervalMs;
|
|
110
|
+
return withLive(backingOff ? slowIntervalMs : intervalMs, options);
|
|
110
111
|
}
|
|
111
112
|
|
|
112
113
|
/**
|
|
@@ -281,7 +282,8 @@ async function askOnce(
|
|
|
281
282
|
|
|
282
283
|
/** Whether sleeping `delay` would carry the wait past its wall-clock bound. */
|
|
283
284
|
function outOfTime(run: PollRun, options: PollingOptions, delay: number): boolean {
|
|
284
|
-
|
|
285
|
+
if (options.maxWaitMs === undefined || waitsForDeadline(options)) return false;
|
|
286
|
+
return Date.now() - run.startedAt + delay >= options.maxWaitMs;
|
|
285
287
|
}
|
|
286
288
|
|
|
287
289
|
/**
|
|
@@ -316,8 +318,8 @@ function scheduleNext(
|
|
|
316
318
|
export interface PollLoop {
|
|
317
319
|
/** Reset the clock and the counters, then ask immediately. */
|
|
318
320
|
restart: () => void;
|
|
319
|
-
/** Ask immediately, keeping the clock — the re-arm events' entry point. */
|
|
320
|
-
poke: () =>
|
|
321
|
+
/** Ask immediately, keeping the clock — the re-arm events' entry point. Whether it asked. */
|
|
322
|
+
poke: () => boolean;
|
|
321
323
|
/** Tear down: nothing further is scheduled and nothing further is written. */
|
|
322
324
|
stop: () => void;
|
|
323
325
|
}
|
|
@@ -336,8 +338,6 @@ export function createPollLoop(
|
|
|
336
338
|
sink: PollSink,
|
|
337
339
|
): PollLoop {
|
|
338
340
|
const run = newRun();
|
|
339
|
-
|
|
340
|
-
|
|
341
341
|
const tick = async (): Promise<void> => {
|
|
342
342
|
if (run.cancelled || run.settled || run.inFlight) return;
|
|
343
343
|
run.inFlight = true;
|
|
@@ -364,9 +364,8 @@ export function createPollLoop(
|
|
|
364
364
|
restart: (): void => {
|
|
365
365
|
if (run.cancelled || run.settled) return;
|
|
366
366
|
clearPending(run);
|
|
367
|
-
// Same reasoning as `poke`, and this one is the buyer pressing a button:
|
|
368
|
-
//
|
|
369
|
-
// an ask was still notionally in flight — is the exact complaint.
|
|
367
|
+
// Same reasoning as `poke`, and this one is the buyer pressing a button: "Verificar
|
|
368
|
+
// de novo" that cleared the panel and sent nothing (an ask was in flight) is the complaint.
|
|
370
369
|
run.attempt += 1;
|
|
371
370
|
run.inFlight = false;
|
|
372
371
|
run.stopped = false;
|
|
@@ -379,17 +378,18 @@ export function createPollLoop(
|
|
|
379
378
|
sink.setError(null);
|
|
380
379
|
void tick();
|
|
381
380
|
},
|
|
382
|
-
poke: ():
|
|
383
|
-
if (run.cancelled || run.stopped) return;
|
|
381
|
+
poke: (): boolean => {
|
|
382
|
+
if (run.cancelled || run.stopped) return false;
|
|
384
383
|
// Deliberately NOT gated on `inFlight`: the shopper who just came back
|
|
385
384
|
// from their bank app is exactly the case where the previous ask is a
|
|
386
385
|
// socket that died while the screen was hidden. Abandon it and ask now —
|
|
387
386
|
// but `claimRearm` bounds how many live asks that may abandon in a row.
|
|
388
|
-
if (!claimRearm(run)) return;
|
|
387
|
+
if (!claimRearm(run)) return false;
|
|
389
388
|
run.attempt += 1;
|
|
390
389
|
run.inFlight = false;
|
|
391
390
|
clearPending(run);
|
|
392
391
|
void tick();
|
|
392
|
+
return true;
|
|
393
393
|
},
|
|
394
394
|
stop: (): void => {
|
|
395
395
|
run.cancelled = true;
|
|
@@ -28,7 +28,7 @@ interface RearmState {
|
|
|
28
28
|
* per trip. This collapses a burst into one request without delaying it: the
|
|
29
29
|
* first re-arm of a burst polls immediately, the rest fall inside the gap.
|
|
30
30
|
*/
|
|
31
|
-
const REARM_QUIET_MS = 1_000;
|
|
31
|
+
export const REARM_QUIET_MS = 1_000;
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* How many live asks a re-arm may abandon before it has to let one finish.
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { useCallback, type Dispatch, type SetStateAction } from "react";
|
|
2
|
+
|
|
3
|
+
import { parkedBasket, type CheckoutBasketIdentity } from "./basket";
|
|
4
|
+
import type { Step } from "./checkout-actions";
|
|
5
|
+
import type { CheckoutDecline } from "./decline";
|
|
6
|
+
import { rememberHostedOrder } from "./hosted-return";
|
|
7
|
+
import type { CheckoutNavigate } from "./navigate-context";
|
|
8
|
+
import type {
|
|
9
|
+
BuyerField,
|
|
10
|
+
BuyerInfo,
|
|
11
|
+
CheckoutOrder,
|
|
12
|
+
CreateOrderRequest,
|
|
13
|
+
CreateOrderResult,
|
|
14
|
+
OrderStatus,
|
|
15
|
+
PaymentMethod,
|
|
16
|
+
} from "./types";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* RAISING the order, and what becomes of it.
|
|
20
|
+
*
|
|
21
|
+
* Split out of `./checkout-actions.ts` when that file reached its 400-line gate
|
|
22
|
+
* — the same seam it was itself split along. What is here is the one moment the
|
|
23
|
+
* flow asks the host for an order and then has to decide, from the answer
|
|
24
|
+
* alone, which of four things is true: it was refused, it is already finished,
|
|
25
|
+
* it is payable somewhere else, or it is payable here.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Hand the buyer to a redirect provider's own page, if that is where this
|
|
30
|
+
* charge settles (FUT-556).
|
|
31
|
+
*
|
|
32
|
+
* Called BEFORE the order is stored: storing it first would render the PIX or
|
|
33
|
+
* card step for a provider that returned neither, which is the dead end this
|
|
34
|
+
* fixes.
|
|
35
|
+
*
|
|
36
|
+
* A full navigation rather than the host's router, because the destination is
|
|
37
|
+
* another origin. The return trip comes back to this same checkout route
|
|
38
|
+
* carrying `transaction_nsu` + `slug`, which the status poll already reads.
|
|
39
|
+
*
|
|
40
|
+
* @returns true when the buyer is on their way and the caller must stop.
|
|
41
|
+
*/
|
|
42
|
+
function handOverToProvider(
|
|
43
|
+
order: CheckoutOrder,
|
|
44
|
+
navigate: CheckoutNavigate,
|
|
45
|
+
tenantSlug?: string,
|
|
46
|
+
basket?: CheckoutBasketIdentity,
|
|
47
|
+
): boolean {
|
|
48
|
+
if (!order.hostedCheckoutUrl) return false;
|
|
49
|
+
// PARK FIRST, navigate second. The order is the only thing the return trip
|
|
50
|
+
// has to rehydrate from, and the navigation may tear this SPA down before
|
|
51
|
+
// any later write lands.
|
|
52
|
+
//
|
|
53
|
+
// The STORE goes with it: one tab holds one slot, and on a multi-tenant
|
|
54
|
+
// storefront every store shares an origin. Without the slug, abandoning this
|
|
55
|
+
// hand-off and opening another store's checkout resumed THIS order there.
|
|
56
|
+
//
|
|
57
|
+
// So does the BASKET (FUT-1213): a hand-off nobody completed must not resume
|
|
58
|
+
// itself over the shopper's next basket, and the only way to tell the two
|
|
59
|
+
// apart later is to record which basket this one was raised from.
|
|
60
|
+
rememberHostedOrder(order, {
|
|
61
|
+
tenantSlug,
|
|
62
|
+
basket: parkedBasket(basket),
|
|
63
|
+
handoff: true,
|
|
64
|
+
});
|
|
65
|
+
navigate(order.hostedCheckoutUrl);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Raise the order for a chosen method, and decide what happens to it.
|
|
71
|
+
*
|
|
72
|
+
* FOUR outcomes, in order: a refusal the step renders, an order that came back
|
|
73
|
+
* ALREADY RESOLVED, a HAND-OFF to the provider's own page, and an order raised
|
|
74
|
+
* here to be paid on this one.
|
|
75
|
+
*
|
|
76
|
+
* The resolved case is first because a raise does not always leave something to
|
|
77
|
+
* pay: a host whose buyer spends a stored balance settles a fully covered
|
|
78
|
+
* pedido server-side and answers PAID with no payable at all. That used to fall
|
|
79
|
+
* through and merely `setOrder`, leaving the flow on Pagamento holding a paid
|
|
80
|
+
* pedido — invisible on the PIX/card screen, which polls and resolves itself,
|
|
81
|
+
* and a dead end on the hand-off screen, which polls nothing and so offered a
|
|
82
|
+
* provider a charge that no longer existed. Reading `status` rather than the
|
|
83
|
+
* absence of a payable is what tells that apart from a failed raise.
|
|
84
|
+
*/
|
|
85
|
+
export function useStartPayment(input: {
|
|
86
|
+
buyer: BuyerInfo;
|
|
87
|
+
saveProfile: boolean;
|
|
88
|
+
createOrder: (request: CreateOrderRequest) => Promise<CreateOrderResult>;
|
|
89
|
+
navigate: CheckoutNavigate;
|
|
90
|
+
tenantSlug: string | undefined;
|
|
91
|
+
basket: CheckoutBasketIdentity | undefined;
|
|
92
|
+
failure: { clear: () => void; fail: (next: { message: string; field?: BuyerField | null; code?: string }) => void };
|
|
93
|
+
setCreating: Dispatch<SetStateAction<boolean>>;
|
|
94
|
+
setDecline: Dispatch<SetStateAction<CheckoutDecline | null>>;
|
|
95
|
+
setOrder: Dispatch<SetStateAction<CheckoutOrder | null>>;
|
|
96
|
+
setFinalStatus: Dispatch<SetStateAction<OrderStatus | null>>;
|
|
97
|
+
/** Moves the flow to Confirmação for an order that needs no payment. */
|
|
98
|
+
setStep: Dispatch<SetStateAction<Step>>;
|
|
99
|
+
}): (chosen: PaymentMethod, override?: BuyerInfo) => Promise<void> {
|
|
100
|
+
const { buyer, saveProfile, createOrder, navigate, tenantSlug, basket, failure } = input;
|
|
101
|
+
const { setCreating, setDecline, setOrder, setFinalStatus, setStep } = input;
|
|
102
|
+
return useCallback(
|
|
103
|
+
async (chosen: PaymentMethod, override?: BuyerInfo) => {
|
|
104
|
+
failure.clear();
|
|
105
|
+
setDecline(null);
|
|
106
|
+
// THE CHARGE BEING REPLACED IS DROPPED FIRST (FUT-1170), before the raise
|
|
107
|
+
// rather than after it. Raising a payment means whatever was on screen is
|
|
108
|
+
// no longer the one being paid, and a provider round trip is long enough
|
|
109
|
+
// for the difference to matter: "Gerar novo código" left the expired
|
|
110
|
+
// charge mounted, so its own view polled it, got the terminal EXPIRED it
|
|
111
|
+
// was always going to get, and bounced the flow to the confirmation
|
|
112
|
+
// screen — where the new charge then landed with nothing polling it.
|
|
113
|
+
//
|
|
114
|
+
// A no-op on every other caller (the auto-raise, the alternate e-mail and
|
|
115
|
+
// the retry all run with no order held), which is the point: the clear
|
|
116
|
+
// belongs to what raising a payment MEANS, not to the one path that
|
|
117
|
+
// noticed.
|
|
118
|
+
setOrder(null);
|
|
119
|
+
setFinalStatus(null);
|
|
120
|
+
setCreating(true);
|
|
121
|
+
const result = await createOrder({ method: chosen, buyer: override ?? buyer, saveProfile });
|
|
122
|
+
setCreating(false);
|
|
123
|
+
if (!result.ok) {
|
|
124
|
+
failure.fail(result.error);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
// NOTHING LEFT TO PAY — see the docblock. Parked first for the same
|
|
128
|
+
// reason every other raised order is: the confirmation this is about to
|
|
129
|
+
// show has to survive a tab the phone discards on the way to it.
|
|
130
|
+
if (result.data.status !== "AWAITING_PAYMENT") {
|
|
131
|
+
rememberHostedOrder(result.data, { tenantSlug, basket: parkedBasket(basket) });
|
|
132
|
+
setOrder(result.data);
|
|
133
|
+
setFinalStatus(result.data.status);
|
|
134
|
+
setStep("status");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (handOverToProvider(result.data, navigate, tenantSlug, basket)) return;
|
|
138
|
+
// PARKED EVEN THOUGH NOBODY IS LEAVING (FUT-1140). A low-memory phone
|
|
139
|
+
// discards this tab while the shopper is in their bank app, and the SPA
|
|
140
|
+
// that comes back has never heard of the order it raised — so the buyer
|
|
141
|
+
// meets an empty cart and a retry button instead of the confirmation for
|
|
142
|
+
// the payment they just made.
|
|
143
|
+
rememberHostedOrder(result.data, { tenantSlug, basket: parkedBasket(basket) });
|
|
144
|
+
setOrder(result.data);
|
|
145
|
+
},
|
|
146
|
+
[
|
|
147
|
+
buyer, saveProfile, createOrder, failure, navigate, tenantSlug, basket,
|
|
148
|
+
setCreating, setDecline, setOrder, setFinalStatus, setStep,
|
|
149
|
+
],
|
|
150
|
+
);
|
|
151
|
+
}
|
|
@@ -9,9 +9,9 @@ import {
|
|
|
9
9
|
useResumedCheckout,
|
|
10
10
|
useRetryAction,
|
|
11
11
|
useSettledPort,
|
|
12
|
-
useStartPayment,
|
|
13
12
|
type Step,
|
|
14
13
|
} from "./checkout-actions";
|
|
14
|
+
import { useStartPayment } from "./start-payment";
|
|
15
15
|
import { useConfirmationWait } from "./confirmation-wait";
|
|
16
16
|
import { useCheckoutCopy } from "./copy-context";
|
|
17
17
|
|
|
@@ -139,7 +139,7 @@ export function useCheckoutController(
|
|
|
139
139
|
});
|
|
140
140
|
const startPayment = useStartPayment({
|
|
141
141
|
buyer, saveProfile, createOrder, navigate, tenantSlug, basket, failure,
|
|
142
|
-
setCreating, setDecline, setOrder, setFinalStatus,
|
|
142
|
+
setCreating, setDecline, setOrder, setFinalStatus, setStep,
|
|
143
143
|
});
|
|
144
144
|
const { payWithEmail, handleResolved } = useResolutionActions({
|
|
145
145
|
buyer, method, startPayment, setBuyerState, setDecline, setFinalStatus, setStep,
|
|
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateA
|
|
|
3
3
|
import { useCheckoutClientApi } from "./client-context";
|
|
4
4
|
import { useCheckoutCopy } from "./copy-context";
|
|
5
5
|
import { createPollLoop, type PollLoop, type PollingOptions, type PollSink } from "./poll-loop";
|
|
6
|
+
import { useLiveWait } from "./live-context";
|
|
6
7
|
import type { OrderStatus } from "./types";
|
|
7
8
|
|
|
8
9
|
/** What a consumer reads off the wait, and the one action it can take. */
|
|
@@ -134,6 +135,7 @@ export function usePaymentPolling(
|
|
|
134
135
|
// The live wait, so the returned action stays stable across renders while
|
|
135
136
|
// still reaching whichever loop the effect currently owns.
|
|
136
137
|
const loop = useRef<PollLoop | null>(null);
|
|
138
|
+
const { isLive, liveIntervalMs } = useLiveWait(loop); // FUT-649
|
|
137
139
|
|
|
138
140
|
useEffect(() => {
|
|
139
141
|
if (!orderId || !enabled) return undefined;
|
|
@@ -147,6 +149,7 @@ export function usePaymentPolling(
|
|
|
147
149
|
slowIntervalMs,
|
|
148
150
|
askTimeoutMs,
|
|
149
151
|
askTimeoutError: transportCopy.offline,
|
|
152
|
+
...{ isLive, liveIntervalMs },
|
|
150
153
|
},
|
|
151
154
|
sinkFor(orderId, setAnswer),
|
|
152
155
|
);
|
|
@@ -169,6 +172,7 @@ export function usePaymentPolling(
|
|
|
169
172
|
askTimeoutMs,
|
|
170
173
|
client,
|
|
171
174
|
transportCopy,
|
|
175
|
+
isLive, liveIntervalMs,
|
|
172
176
|
]);
|
|
173
177
|
|
|
174
178
|
const checkAgain = useCallback(() => {
|
package/src/index.ts
CHANGED
|
@@ -131,6 +131,13 @@ export type {
|
|
|
131
131
|
} from './components/checkout/screens-copy';
|
|
132
132
|
export { PT_BR_CHECKOUT_SCREENS_COPY } from './components/checkout/screens-pt-BR';
|
|
133
133
|
export { fetchCheckoutConfig } from './components/checkout/client';
|
|
134
|
+
// The host's realtime channel for this buyer (FUT-649): a hint asks `/status`
|
|
135
|
+
// now, and every wait slows to a floor while the channel is live.
|
|
136
|
+
export {
|
|
137
|
+
CheckoutLiveProvider,
|
|
138
|
+
DEFAULT_LIVE_INTERVAL_MS,
|
|
139
|
+
type CheckoutLiveSignal,
|
|
140
|
+
} from './components/checkout/live-context';
|
|
134
141
|
/**
|
|
135
142
|
* The `sessionStorage` key the hosted-checkout return leg parks the raised
|
|
136
143
|
* order under. Public because it is already observable — a spec asserting the
|