@12-apps/payments-frontend 3.24.1 → 3.26.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-flow.tsx +10 -2
- package/src/components/checkout/live-context.tsx +108 -0
- package/src/components/checkout/payment-status-parts.tsx +19 -3
- package/src/components/checkout/payment-status.tsx +17 -1
- 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/use-payment-polling.ts +4 -0
- package/src/flows/create-payment-flows.tsx +1 -0
- package/src/flows/screens-pay.tsx +1 -0
- package/src/flows/types.ts +5 -2
- 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.26.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.",
|
|
@@ -95,6 +95,11 @@ export interface CheckoutFlowProps extends CheckoutHostPorts {
|
|
|
95
95
|
validateApplePayMerchant?: (validationURL: string) => Promise<unknown>;
|
|
96
96
|
/** Host content shown on the paid confirmation (the storefront's install invite). */
|
|
97
97
|
confirmationExtra?: ReactNode;
|
|
98
|
+
/**
|
|
99
|
+
* Host illustration that replaces the paid confirmation's icon —
|
|
100
|
+
* `PaymentStatusProps.paidHero`: pass it only when there is one, and mark it `aria-hidden`.
|
|
101
|
+
*/
|
|
102
|
+
confirmationHero?: ReactNode;
|
|
98
103
|
/** Host content shown AFTER the confirmation's actions — `PaymentStatusProps.paidFooter`. */
|
|
99
104
|
confirmationFooter?: ReactNode;
|
|
100
105
|
/** Whether the way out leads the paid screen — see {@link BackActionEmphasis}. */
|
|
@@ -185,6 +190,7 @@ function StatusStep({
|
|
|
185
190
|
settlement,
|
|
186
191
|
cart,
|
|
187
192
|
confirmationExtra,
|
|
193
|
+
confirmationHero,
|
|
188
194
|
confirmationFooter,
|
|
189
195
|
backActionEmphasis,
|
|
190
196
|
}: {
|
|
@@ -193,6 +199,7 @@ function StatusStep({
|
|
|
193
199
|
settlement: SettlementCheckout | null | undefined;
|
|
194
200
|
cart: CheckoutCartView;
|
|
195
201
|
confirmationExtra: ReactNode;
|
|
202
|
+
confirmationHero: ReactNode;
|
|
196
203
|
confirmationFooter: ReactNode;
|
|
197
204
|
backActionEmphasis: BackActionEmphasis | undefined;
|
|
198
205
|
}): JSX.Element {
|
|
@@ -206,6 +213,7 @@ function StatusStep({
|
|
|
206
213
|
onRegenerate={() => { c.setStep("payment"); void c.startPayment("PIX"); }}
|
|
207
214
|
onBackToMenu={c.goToMenu}
|
|
208
215
|
paidExtra={confirmationExtra}
|
|
216
|
+
paidHero={confirmationHero}
|
|
209
217
|
paidFooter={confirmationFooter}
|
|
210
218
|
backActionEmphasis={backActionEmphasis}
|
|
211
219
|
awaitingTimedOut={c.awaitingTimedOut}
|
|
@@ -308,7 +316,7 @@ function PagamentoStep({
|
|
|
308
316
|
}
|
|
309
317
|
|
|
310
318
|
function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Element {
|
|
311
|
-
const { copy, cart, defaultBuyer, settlement, taxIdOnFile = false, providerConfig, tenantSlug, confirmationExtra, confirmationFooter, backActionEmphasis, validateApplePayMerchant, oneClick = false, ...ports } = props;
|
|
319
|
+
const { copy, cart, defaultBuyer, settlement, taxIdOnFile = false, providerConfig, tenantSlug, confirmationExtra, confirmationHero, confirmationFooter, backActionEmphasis, validateApplePayMerchant, oneClick = false, ...ports } = props;
|
|
312
320
|
// Resolved for NO method on purpose (FUT-595): the Dados step opens before
|
|
313
321
|
// the picker, and the form is filled once — so it asks for the union of what
|
|
314
322
|
// any chain member may need rather than re-opening after the choice. A chain
|
|
@@ -365,7 +373,7 @@ function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Ele
|
|
|
365
373
|
) : null}
|
|
366
374
|
|
|
367
375
|
{c.step === "status" ? (
|
|
368
|
-
<StatusStep copy={copy} c={c} settlement={settlement} cart={cart} confirmationExtra={confirmationExtra} confirmationFooter={confirmationFooter} backActionEmphasis={backActionEmphasis} />
|
|
376
|
+
<StatusStep copy={copy} c={c} settlement={settlement} cart={cart} confirmationExtra={confirmationExtra} confirmationHero={confirmationHero} confirmationFooter={confirmationFooter} backActionEmphasis={backActionEmphasis} />
|
|
369
377
|
) : null}
|
|
370
378
|
</Box>
|
|
371
379
|
);
|
|
@@ -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
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Box } from "@mui/material";
|
|
2
|
-
import type { JSX } from "react";
|
|
2
|
+
import type { JSX, ReactNode } from "react";
|
|
3
3
|
|
|
4
4
|
import { CheckCircleOutlineIcon, ErrorOutlineIcon, ScheduleIcon } from "./icons";
|
|
5
5
|
import type { CheckoutDecline } from "./decline";
|
|
@@ -120,17 +120,27 @@ function failedOutcome(copy: PaymentStatusCopy, decline: CheckoutDecline | null)
|
|
|
120
120
|
return (reason ? copy.declined?.[reason] : undefined) ?? copy.failed;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
/**
|
|
123
|
+
/**
|
|
124
|
+
* The headline block: icon, outcome, and one supporting line.
|
|
125
|
+
*
|
|
126
|
+
* `hero` replaces the PAID icon with the host's own illustration — a store's
|
|
127
|
+
* mascot celebrating the order, say — and only the icon: the heading and the
|
|
128
|
+
* support line stay the package's. Every other status keeps its icon, because
|
|
129
|
+
* a host celebrating a payment that failed would be telling the buyer the
|
|
130
|
+
* opposite of what happened.
|
|
131
|
+
*/
|
|
124
132
|
export function OutcomeHero({
|
|
125
133
|
copy,
|
|
126
134
|
status,
|
|
127
135
|
wait,
|
|
128
136
|
decline,
|
|
137
|
+
hero,
|
|
129
138
|
}: {
|
|
130
139
|
copy: PaymentStatusCopy;
|
|
131
140
|
status: OrderStatus;
|
|
132
141
|
wait: WaitState;
|
|
133
142
|
decline: CheckoutDecline | null;
|
|
143
|
+
hero?: ReactNode;
|
|
134
144
|
}): JSX.Element {
|
|
135
145
|
const { Text } = useCheckoutComponents();
|
|
136
146
|
const face = status === "AWAITING_PAYMENT" ? awaitingFace(copy, wait) : null;
|
|
@@ -154,7 +164,13 @@ export function OutcomeHero({
|
|
|
154
164
|
}
|
|
155
165
|
sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1, textAlign: "center" }}
|
|
156
166
|
>
|
|
157
|
-
|
|
167
|
+
{status === "PAID" && hero ? (
|
|
168
|
+
<Box data-testid="payment-paid-hero" sx={{ display: "flex" }}>
|
|
169
|
+
{hero}
|
|
170
|
+
</Box>
|
|
171
|
+
) : (
|
|
172
|
+
<Box sx={{ color: TONE_COLOR[visual.tone], display: "flex" }}>{visual.icon}</Box>
|
|
173
|
+
)}
|
|
158
174
|
<Text variant="heading" size="md" weight="bold" as="h2">
|
|
159
175
|
{outcome.heading}
|
|
160
176
|
</Text>
|
|
@@ -56,6 +56,16 @@ interface PaymentStatusProps {
|
|
|
56
56
|
* is the difference between an offer and an interruption.
|
|
57
57
|
*/
|
|
58
58
|
paidExtra?: ReactNode;
|
|
59
|
+
/**
|
|
60
|
+
* Host content that REPLACES the PAID icon at the top of the screen — the
|
|
61
|
+
* storefront's mascot celebrating a buyer's first order or birthday. Absent,
|
|
62
|
+
* the screen is exactly what it was. Never shown on any other status.
|
|
63
|
+
*
|
|
64
|
+
* Pass it only when there is something to show (`cond ? <Mascot /> : undefined`):
|
|
65
|
+
* an element that renders nothing still replaces the icon. It is decorative —
|
|
66
|
+
* the heading beside it says what happened — so mark it `aria-hidden`.
|
|
67
|
+
*/
|
|
68
|
+
paidHero?: ReactNode;
|
|
59
69
|
/**
|
|
60
70
|
* Host content rendered AFTER the action row, and ONLY on PAID.
|
|
61
71
|
*
|
|
@@ -182,7 +192,13 @@ export function PaymentStatus(props: PaymentStatusProps): JSX.Element {
|
|
|
182
192
|
data-timed-out={view.wait.timedOut ? "true" : undefined}
|
|
183
193
|
sx={{ display: "flex", flexDirection: "column", gap: 3, alignItems: "stretch", py: 2 }}
|
|
184
194
|
>
|
|
185
|
-
<OutcomeHero
|
|
195
|
+
<OutcomeHero
|
|
196
|
+
copy={copy}
|
|
197
|
+
status={view.effective}
|
|
198
|
+
wait={view.wait}
|
|
199
|
+
decline={view.decline}
|
|
200
|
+
hero={props.paidHero}
|
|
201
|
+
/>
|
|
186
202
|
|
|
187
203
|
{view.paid ? (
|
|
188
204
|
<PaidFacts copy={copy} totalLabel={totalLabel} orderId={orderId} buyerEmail={buyerEmail} />
|
|
@@ -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.
|
|
@@ -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(() => {
|
|
@@ -106,6 +106,7 @@ function buildCheckout(
|
|
|
106
106
|
providerConfig={config}
|
|
107
107
|
tenantSlug={tenantSlug}
|
|
108
108
|
confirmationExtra={runtime.config.confirmation?.extra}
|
|
109
|
+
confirmationHero={runtime.config.confirmation?.hero}
|
|
109
110
|
validateApplePayMerchant={ports.validateApplePayMerchant}
|
|
110
111
|
/>
|
|
111
112
|
);
|
|
@@ -93,6 +93,7 @@ function buildPaymentStatus(runtime: FlowsRuntime): CheckoutScreens["PaymentStat
|
|
|
93
93
|
orderId={payable?.orderId}
|
|
94
94
|
onBackToMenu={backToMenu}
|
|
95
95
|
paidExtra={runtime.config.confirmation?.extra}
|
|
96
|
+
paidHero={runtime.config.confirmation?.hero}
|
|
96
97
|
/>
|
|
97
98
|
</FlowsShell>
|
|
98
99
|
);
|
package/src/flows/types.ts
CHANGED
|
@@ -144,8 +144,11 @@ export interface PaymentFlowsConfig extends CheckoutPipelineConfig {
|
|
|
144
144
|
* makes a new host answer once, at the one call site that knows the answer.
|
|
145
145
|
*/
|
|
146
146
|
copy: CheckoutCopyFE;
|
|
147
|
-
/**
|
|
148
|
-
|
|
147
|
+
/**
|
|
148
|
+
* Host content under the paid receipt (the storefront's PWA install invite),
|
|
149
|
+
* and the host's own illustration in place of the paid icon (`hero`).
|
|
150
|
+
*/
|
|
151
|
+
confirmation?: { extra?: ReactNode; hero?: ReactNode };
|
|
149
152
|
/**
|
|
150
153
|
* Warnings the host reports (Sentry). Default SILENT — never `console`: a
|
|
151
154
|
* library that writes to a buyer's console tells them nothing and tells the
|
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
|