@12-apps/payments-frontend 3.19.0 → 3.20.1
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/card-view.tsx +30 -28
- package/src/components/checkout/checkout-flow.tsx +40 -20
- package/src/components/checkout/en-US.ts +9 -0
- package/src/components/checkout/one-click.tsx +179 -0
- package/src/components/checkout/payment-status.tsx +130 -38
- package/src/components/checkout/pix-view.tsx +34 -12
- package/src/components/checkout/pt-BR.ts +7 -0
- package/src/components/checkout/screens-copy.ts +19 -0
- package/src/components/checkout/screens-en-US.ts +4 -0
- package/src/components/checkout/screens-pt-BR.ts +2 -0
- package/src/components/checkout/stalled-wait.tsx +58 -0
- package/src/components/checkout/use-card-checkout.ts +53 -11
- package/src/components/checkout/use-checkout-controller.ts +48 -13
- package/src/components/checkout/use-payment-polling.ts +250 -64
- package/src/components/checkout/use-wallet-charge.ts +20 -6
- package/src/components/checkout/view-copy.ts +15 -0
- package/src/components/checkout/wallet-pane.tsx +17 -16
- package/src/flows/copy.ts +19 -0
- package/src/flows/screens-hosted.tsx +72 -17
|
@@ -1,19 +1,28 @@
|
|
|
1
|
-
import { useEffect, useState } from "react";
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
|
|
3
3
|
import { useCheckoutClientApi } from "./client-context";
|
|
4
|
+
import type { Result } from "../../result";
|
|
4
5
|
import { TERMINAL_STATUSES, type OrderStatus } from "./types";
|
|
5
6
|
|
|
6
7
|
interface PollingOptions {
|
|
7
|
-
/** Delay between polls (ms). */
|
|
8
|
+
/** Delay between successful polls (ms). */
|
|
8
9
|
intervalMs?: number;
|
|
9
10
|
/** Poll only while `true` (e.g. after a card charge is submitted). */
|
|
10
11
|
enabled?: boolean;
|
|
11
12
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* Undefined ⇒ unbounded
|
|
13
|
+
* WALL-CLOCK bound on the whole wait (FUT-1144): stop scheduling and report
|
|
14
|
+
* `timedOut` once this many milliseconds have passed since the wait began.
|
|
15
|
+
* Undefined ⇒ unbounded (the PIX consumer passes none — its charge expires
|
|
16
|
+
* server-side and comes back as a terminal EXPIRED).
|
|
17
|
+
*
|
|
18
|
+
* It used to be a count of HEALTHY polls, which measured the wrong thing in
|
|
19
|
+
* the only case that matters. A wait that is failing makes no healthy polls,
|
|
20
|
+
* so the count stood still while the clock ran: the hosted-return leg spun
|
|
21
|
+
* "Confirmando seu pagamento…" forever on a connection that never came back,
|
|
22
|
+
* because the only counter that could have stopped it was the one the
|
|
23
|
+
* failure had frozen. Wall time cannot be frozen by the failure it measures.
|
|
15
24
|
*/
|
|
16
|
-
|
|
25
|
+
maxWaitMs?: number;
|
|
17
26
|
/**
|
|
18
27
|
* Opt-in BACKOFF: after this many healthy polls, keep asking at
|
|
19
28
|
* {@link slowIntervalMs} instead of {@link intervalMs}.
|
|
@@ -32,8 +41,44 @@ interface PollingOptions {
|
|
|
32
41
|
slowIntervalMs?: number;
|
|
33
42
|
}
|
|
34
43
|
|
|
35
|
-
/**
|
|
36
|
-
|
|
44
|
+
/** What a consumer reads off the wait, and the one action it can take. */
|
|
45
|
+
interface PaymentPollingState {
|
|
46
|
+
status: OrderStatus | null;
|
|
47
|
+
/**
|
|
48
|
+
* The last poll failed. TRANSIENT (FUT-1144): the wait keeps running on a
|
|
49
|
+
* backoff and this clears the moment a poll succeeds, so a consumer must
|
|
50
|
+
* render it as "we are still trying", never as "we gave up".
|
|
51
|
+
*/
|
|
52
|
+
error: string | null;
|
|
53
|
+
/** The wall-clock bound elapsed — nothing further is scheduled. */
|
|
54
|
+
timedOut: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Ask NOW, and start the wait over: the buyer's own "check again". Restarts
|
|
57
|
+
* the clock too, so it is the way back from {@link timedOut}. A no-op once
|
|
58
|
+
* the order has settled — there is nothing left to ask about.
|
|
59
|
+
*/
|
|
60
|
+
checkAgain: () => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The slowest a failing poll may go (FUT-1144).
|
|
65
|
+
*
|
|
66
|
+
* Consecutive failures double the delay — 2.5 s, 5 s, 10 s — and stop there.
|
|
67
|
+
* The cap is what keeps the recovery cheap: a shopper whose signal comes back
|
|
68
|
+
* during a Wi-Fi→4G handoff waits at most this long to be told they paid, and
|
|
69
|
+
* the two re-arm events below usually beat it outright.
|
|
70
|
+
*/
|
|
71
|
+
const MAX_ERROR_BACKOFF_MS = 10_000;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* How long a re-arm must stay quiet after the last ask.
|
|
75
|
+
*
|
|
76
|
+
* Returning to a tab commonly fires `visibilitychange` and `online` together —
|
|
77
|
+
* and a shopper flicking between the bank app and the store fires them again
|
|
78
|
+
* per trip. This collapses a burst into one request without delaying it: the
|
|
79
|
+
* first re-arm of a burst polls immediately, the rest fall inside the gap.
|
|
80
|
+
*/
|
|
81
|
+
const REARM_QUIET_MS = 1_000;
|
|
37
82
|
|
|
38
83
|
/**
|
|
39
84
|
* How long before the next ask, given how many healthy polls have happened.
|
|
@@ -46,31 +91,200 @@ const MAX_POLL_ERRORS = 4;
|
|
|
46
91
|
* poll FOLLOWING the threshold rather than one early: a wait described as "N
|
|
47
92
|
* fast polls" has to actually make N of them.
|
|
48
93
|
*/
|
|
49
|
-
function
|
|
94
|
+
function healthyDelay(healthy: number, options: PollingOptions): number {
|
|
50
95
|
const { intervalMs = 2500, slowAfterPolls, slowIntervalMs } = options;
|
|
51
96
|
const backingOff =
|
|
52
97
|
slowAfterPolls !== undefined && slowIntervalMs !== undefined && healthy >= slowAfterPolls;
|
|
53
98
|
return backingOff ? slowIntervalMs : intervalMs;
|
|
54
99
|
}
|
|
55
100
|
|
|
101
|
+
/**
|
|
102
|
+
* The same rule with consecutive FAILURES folded in (FUT-1144).
|
|
103
|
+
*
|
|
104
|
+
* Errors never stop the wait, they only slow it. Doubling from the healthy
|
|
105
|
+
* cadence is what makes a ten-second blip cost one extra beat instead of the
|
|
106
|
+
* whole confirmation: four failures used to be terminal, and four failures is
|
|
107
|
+
* what a Wi-Fi→4G handoff produces at the default 2.5 s.
|
|
108
|
+
*
|
|
109
|
+
* The cap is `MAX_ERROR_BACKOFF_MS` or the healthy cadence, whichever is
|
|
110
|
+
* larger — a failing poll must never ask FASTER than a succeeding one, which
|
|
111
|
+
* is what a bare cap would do to a consumer whose slow interval is longer.
|
|
112
|
+
*/
|
|
113
|
+
function pollDelay(healthy: number, errors: number, options: PollingOptions): number {
|
|
114
|
+
const base = healthyDelay(healthy, options);
|
|
115
|
+
if (errors === 0) return base;
|
|
116
|
+
return Math.min(base * 2 ** (errors - 1), Math.max(base, MAX_ERROR_BACKOFF_MS));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Where a running wait writes what it has learned. */
|
|
120
|
+
interface PollSink {
|
|
121
|
+
setStatus: (status: OrderStatus) => void;
|
|
122
|
+
setError: (error: string | null) => void;
|
|
123
|
+
setTimedOut: (timedOut: boolean) => void;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The mutable bookkeeping one wait carries.
|
|
128
|
+
*
|
|
129
|
+
* `settled` and `stopped` are deliberately NOT the same flag. Settled means the
|
|
130
|
+
* order reached a terminal status and there is nothing left to ask about, ever.
|
|
131
|
+
* Stopped only means nothing is scheduled — which the wall clock running out
|
|
132
|
+
* also produces, and which the buyer's own "check again" is allowed to undo.
|
|
133
|
+
*/
|
|
134
|
+
function newRun() {
|
|
135
|
+
return {
|
|
136
|
+
cancelled: false,
|
|
137
|
+
settled: false,
|
|
138
|
+
stopped: false,
|
|
139
|
+
inFlight: false,
|
|
140
|
+
errors: 0,
|
|
141
|
+
healthy: 0,
|
|
142
|
+
startedAt: 0,
|
|
143
|
+
askedAt: 0,
|
|
144
|
+
timer: undefined as ReturnType<typeof setTimeout> | undefined,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The handle the hook holds on one running wait. */
|
|
149
|
+
interface PollLoop {
|
|
150
|
+
/** Reset the clock and the counters, then ask immediately. */
|
|
151
|
+
restart: () => void;
|
|
152
|
+
/** Ask immediately, keeping the clock — the re-arm events' entry point. */
|
|
153
|
+
poke: () => void;
|
|
154
|
+
/** Tear down: nothing further is scheduled and nothing further is written. */
|
|
155
|
+
stop: () => void;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* One self-scheduling wait, with no React in it.
|
|
160
|
+
*
|
|
161
|
+
* A `setTimeout` chain rather than an interval, so requests never overlap; the
|
|
162
|
+
* mutable counters live in one object because the flakiness gate's
|
|
163
|
+
* `no-global-state-mutation` is about exactly this shape, and because the
|
|
164
|
+
* whole loop has to be cancellable from a React cleanup.
|
|
165
|
+
*/
|
|
166
|
+
function createPollLoop(
|
|
167
|
+
ask: () => Promise<Result<OrderStatus>>,
|
|
168
|
+
options: PollingOptions,
|
|
169
|
+
sink: PollSink,
|
|
170
|
+
): PollLoop {
|
|
171
|
+
const run = newRun();
|
|
172
|
+
|
|
173
|
+
const clearPending = (): void => {
|
|
174
|
+
if (run.timer !== undefined) clearTimeout(run.timer);
|
|
175
|
+
run.timer = undefined;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const outOfTime = (delay: number): boolean =>
|
|
179
|
+
options.maxWaitMs !== undefined && Date.now() - run.startedAt + delay >= options.maxWaitMs;
|
|
180
|
+
|
|
181
|
+
const tick = async (): Promise<void> => {
|
|
182
|
+
if (run.cancelled || run.settled || run.inFlight) return;
|
|
183
|
+
run.inFlight = true;
|
|
184
|
+
run.askedAt = Date.now();
|
|
185
|
+
const result = await ask();
|
|
186
|
+
run.inFlight = false;
|
|
187
|
+
if (run.cancelled || run.settled) return;
|
|
188
|
+
if (result.ok) {
|
|
189
|
+
run.errors = 0;
|
|
190
|
+
sink.setError(null);
|
|
191
|
+
sink.setStatus(result.data);
|
|
192
|
+
if (TERMINAL_STATUSES.includes(result.data)) {
|
|
193
|
+
run.settled = true;
|
|
194
|
+
run.stopped = true;
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
run.healthy += 1;
|
|
198
|
+
} else {
|
|
199
|
+
run.errors += 1;
|
|
200
|
+
sink.setError(result.error);
|
|
201
|
+
}
|
|
202
|
+
const delay = pollDelay(run.healthy, run.errors, options);
|
|
203
|
+
if (outOfTime(delay)) {
|
|
204
|
+
run.stopped = true;
|
|
205
|
+
sink.setTimedOut(true);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
clearPending();
|
|
209
|
+
run.timer = setTimeout(() => void tick(), delay);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
restart: (): void => {
|
|
214
|
+
if (run.cancelled || run.settled) return;
|
|
215
|
+
clearPending();
|
|
216
|
+
run.stopped = false;
|
|
217
|
+
run.errors = 0;
|
|
218
|
+
run.healthy = 0;
|
|
219
|
+
run.startedAt = Date.now();
|
|
220
|
+
sink.setTimedOut(false);
|
|
221
|
+
sink.setError(null);
|
|
222
|
+
void tick();
|
|
223
|
+
},
|
|
224
|
+
poke: (): void => {
|
|
225
|
+
if (run.cancelled || run.stopped || run.inFlight) return;
|
|
226
|
+
if (Date.now() - run.askedAt < REARM_QUIET_MS) return;
|
|
227
|
+
clearPending();
|
|
228
|
+
void tick();
|
|
229
|
+
},
|
|
230
|
+
stop: (): void => {
|
|
231
|
+
run.cancelled = true;
|
|
232
|
+
clearPending();
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Ask again the moment the shopper could plausibly have an answer for us.
|
|
239
|
+
*
|
|
240
|
+
* Those two moments are exactly the ones this bug was reported from: a buyer
|
|
241
|
+
* switching to their bank app (iOS aborts the in-flight fetches, and the tab
|
|
242
|
+
* comes back `visible`), and a handset moving between Wi-Fi and 4G (`online`).
|
|
243
|
+
* Waiting out the backoff after either is time spent not telling somebody
|
|
244
|
+
* their payment landed.
|
|
245
|
+
*
|
|
246
|
+
* Guarded for a host with no DOM — this package is imported by SSR frames, and
|
|
247
|
+
* a hook that throws at import time takes the whole checkout with it.
|
|
248
|
+
*/
|
|
249
|
+
function listenForRearm(poke: () => void): () => void {
|
|
250
|
+
if (typeof document === "undefined" || typeof window === "undefined") {
|
|
251
|
+
return (): void => undefined;
|
|
252
|
+
}
|
|
253
|
+
const onVisible = (): void => {
|
|
254
|
+
if (document.visibilityState === "visible") poke();
|
|
255
|
+
};
|
|
256
|
+
document.addEventListener("visibilitychange", onVisible);
|
|
257
|
+
window.addEventListener("online", poke);
|
|
258
|
+
return (): void => {
|
|
259
|
+
document.removeEventListener("visibilitychange", onVisible);
|
|
260
|
+
window.removeEventListener("online", poke);
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
56
264
|
/**
|
|
57
265
|
* Poll an order's payment status until it reaches a terminal state.
|
|
58
266
|
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
267
|
+
* Stops as soon as the order is PAID/FAILED/EXPIRED, when the wall-clock bound
|
|
268
|
+
* elapses, and on unmount or an order-id change. This is the client half of the
|
|
269
|
+
* async confirmation the provider's webhook drives on the server.
|
|
270
|
+
*
|
|
271
|
+
* A FAILED POLL IS NOT AN ENDING (FUT-1144). It slows the wait and is reported
|
|
272
|
+
* as `error` so a screen can say so, and the next success clears it. The wait
|
|
273
|
+
* ended for four consecutive errors once — ~10 s of no signal — and left a PIX
|
|
274
|
+
* QR under a red alert with no retry, a card spinner replaced by one, and the
|
|
275
|
+
* hosted return spinning forever. None of the three could recover on its own,
|
|
276
|
+
* which is what made a blip cost a payment nobody was ever told about.
|
|
63
277
|
*/
|
|
64
278
|
export function usePaymentPolling(
|
|
65
279
|
orderId: string | null,
|
|
66
280
|
{
|
|
67
281
|
intervalMs = 2500,
|
|
68
282
|
enabled = true,
|
|
69
|
-
|
|
283
|
+
maxWaitMs,
|
|
70
284
|
slowAfterPolls,
|
|
71
285
|
slowIntervalMs,
|
|
72
286
|
}: PollingOptions = {},
|
|
73
|
-
):
|
|
287
|
+
): PaymentPollingState {
|
|
74
288
|
const [status, setStatus] = useState<OrderStatus | null>(null);
|
|
75
289
|
const [error, setError] = useState<string | null>(null);
|
|
76
290
|
const [timedOut, setTimedOut] = useState(false);
|
|
@@ -78,60 +292,32 @@ export function usePaymentPolling(
|
|
|
78
292
|
// belongs in the deps below rather than being read out of a ref: a checkout
|
|
79
293
|
// re-pointed at another mount must re-poll against THAT one.
|
|
80
294
|
const client = useCheckoutClientApi();
|
|
295
|
+
// The live wait, so the returned action stays stable across renders while
|
|
296
|
+
// still reaching whichever loop the effect currently owns.
|
|
297
|
+
const loop = useRef<PollLoop | null>(null);
|
|
81
298
|
|
|
82
299
|
useEffect(() => {
|
|
83
|
-
if (!orderId || !enabled)
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
300
|
+
if (!orderId || !enabled) return undefined;
|
|
86
301
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
const result = await client.getStatus(orderId);
|
|
96
|
-
if (cancelled) {
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
if (result.ok) {
|
|
100
|
-
errorCount = 0;
|
|
101
|
-
setStatus(result.data);
|
|
102
|
-
if (TERMINAL_STATUSES.includes(result.data)) {
|
|
103
|
-
return; // terminal — stop polling
|
|
104
|
-
}
|
|
105
|
-
healthyCount += 1;
|
|
106
|
-
if (maxHealthyPolls !== undefined && healthyCount >= maxHealthyPolls) {
|
|
107
|
-
// A healthy-but-still-AWAITING stream never trips the error cap, so
|
|
108
|
-
// bound it separately (FUT-191): stop scheduling and let the consumer
|
|
109
|
-
// show a "taking longer" state instead of an infinite spinner.
|
|
110
|
-
setTimedOut(true);
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
} else {
|
|
114
|
-
errorCount += 1;
|
|
115
|
-
if (errorCount >= MAX_POLL_ERRORS) {
|
|
116
|
-
// Give up rather than spin forever; surface the error to the consumer.
|
|
117
|
-
setError(result.error);
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
timer = setTimeout(() => {
|
|
122
|
-
void tick();
|
|
123
|
-
}, pollDelay(healthyCount, { intervalMs, slowAfterPolls, slowIntervalMs }));
|
|
124
|
-
};
|
|
125
|
-
|
|
126
|
-
void tick();
|
|
302
|
+
const running = createPollLoop(
|
|
303
|
+
() => client.getStatus(orderId),
|
|
304
|
+
{ intervalMs, maxWaitMs, slowAfterPolls, slowIntervalMs },
|
|
305
|
+
{ setStatus, setError, setTimedOut },
|
|
306
|
+
);
|
|
307
|
+
loop.current = running;
|
|
308
|
+
running.restart();
|
|
309
|
+
const unlisten = listenForRearm(running.poke);
|
|
127
310
|
|
|
128
311
|
return () => {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
312
|
+
unlisten();
|
|
313
|
+
running.stop();
|
|
314
|
+
loop.current = null;
|
|
133
315
|
};
|
|
134
|
-
}, [orderId, intervalMs, enabled,
|
|
316
|
+
}, [orderId, intervalMs, enabled, maxWaitMs, slowAfterPolls, slowIntervalMs, client]);
|
|
317
|
+
|
|
318
|
+
const checkAgain = useCallback(() => {
|
|
319
|
+
loop.current?.restart();
|
|
320
|
+
}, []);
|
|
135
321
|
|
|
136
|
-
return { status, error, timedOut };
|
|
322
|
+
return { status, error, timedOut, checkAgain };
|
|
137
323
|
}
|
|
@@ -36,9 +36,16 @@ export interface WalletCharge {
|
|
|
36
36
|
errorCode: string | null;
|
|
37
37
|
/** The charge is unresolved: no pay control may render (FUT-563). */
|
|
38
38
|
unresolved: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* The last status poll failed. TRANSIENT (FUT-1144): the wait carries on at a
|
|
41
|
+
* backed-off cadence and this clears on the next success, so the pane shows
|
|
42
|
+
* it as "still trying" beside {@link pollCheckAgain} rather than as an end.
|
|
43
|
+
*/
|
|
39
44
|
pollError: string | null;
|
|
40
|
-
/** The
|
|
45
|
+
/** The bounded AWAITING wait elapsed (FUT-191, now wall-clock — FUT-1144). */
|
|
41
46
|
pollTimedOut: boolean;
|
|
47
|
+
/** Ask now and restart the wait — the buyer's "verificar de novo". */
|
|
48
|
+
pollCheckAgain: () => void;
|
|
42
49
|
/**
|
|
43
50
|
* Charge the wallet's key. The button calls this once the sheet resolves.
|
|
44
51
|
* Resolves `true` when the charge was ACCEPTED — paid, confirming, or
|
|
@@ -49,10 +56,11 @@ export interface WalletCharge {
|
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
59
|
+
* The wallet AWAITING wait — the card path's own bound (FUT-191): 90 s, in WALL
|
|
60
|
+
* TIME rather than healthy polls (FUT-1144, and see `CARD_AWAITING_WAIT_MS` for
|
|
61
|
+
* why a count of the polls that SUCCEEDED cannot bound a wait that is failing).
|
|
54
62
|
*/
|
|
55
|
-
const
|
|
63
|
+
const WALLET_AWAITING_WAIT_MS = 90_000;
|
|
56
64
|
|
|
57
65
|
/** The wallet charge state machine. See the module comment. */
|
|
58
66
|
export function useWalletCharge(
|
|
@@ -67,10 +75,15 @@ export function useWalletCharge(
|
|
|
67
75
|
const client = useCheckoutClientApi();
|
|
68
76
|
const navigate = useCheckoutNavigate();
|
|
69
77
|
|
|
70
|
-
const {
|
|
78
|
+
const {
|
|
79
|
+
status,
|
|
80
|
+
error: pollError,
|
|
81
|
+
timedOut: pollTimedOut,
|
|
82
|
+
checkAgain: pollCheckAgain,
|
|
83
|
+
} = usePaymentPolling(order.orderId, {
|
|
71
84
|
enabled: phase === "polling",
|
|
72
85
|
intervalMs: pollIntervalMs,
|
|
73
|
-
|
|
86
|
+
maxWaitMs: WALLET_AWAITING_WAIT_MS,
|
|
74
87
|
});
|
|
75
88
|
|
|
76
89
|
useEffect(() => {
|
|
@@ -124,6 +137,7 @@ export function useWalletCharge(
|
|
|
124
137
|
unresolved: errorCode === UNRESOLVED_CODE,
|
|
125
138
|
pollError,
|
|
126
139
|
pollTimedOut,
|
|
140
|
+
pollCheckAgain,
|
|
127
141
|
payWithKey,
|
|
128
142
|
};
|
|
129
143
|
}
|
|
@@ -74,8 +74,23 @@ export interface PaymentStatusCopy {
|
|
|
74
74
|
failed: StatusOutcomeCopy;
|
|
75
75
|
expired: StatusOutcomeCopy;
|
|
76
76
|
awaitingTimedOut: StatusOutcomeCopy;
|
|
77
|
+
/**
|
|
78
|
+
* The wait cannot reach the payment right now (FUT-1144) — and is STILL
|
|
79
|
+
* ASKING, which is the whole difference between this outcome and the one
|
|
80
|
+
* above it, and why the one above it wins when a screen somehow has both. The
|
|
81
|
+
* resumed hosted return used to render neither: its poll could fail forever
|
|
82
|
+
* and the screen went on saying "isso costuma levar alguns segundos" under a
|
|
83
|
+
* spinner, so the one leg with no PIX or card view of its own was also the
|
|
84
|
+
* one that never mentioned a problem.
|
|
85
|
+
*/
|
|
86
|
+
awaitingUnreachable: StatusOutcomeCopy;
|
|
77
87
|
retryAction: string;
|
|
78
88
|
regenerateAction: string;
|
|
89
|
+
/**
|
|
90
|
+
* Ask now, rather than waiting for the next automatic poll — and, once the
|
|
91
|
+
* wait has run out, the only thing that starts it again.
|
|
92
|
+
*/
|
|
93
|
+
checkAgainAction: string;
|
|
79
94
|
backAction: string;
|
|
80
95
|
/** The paid receipt's three row labels. */
|
|
81
96
|
amountLabel: string;
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
googlePayConfig,
|
|
13
13
|
} from "./method-capability";
|
|
14
14
|
import type { ProviderCheckoutScreenProps } from "./providers/types";
|
|
15
|
+
import { StalledWait } from "./stalled-wait";
|
|
15
16
|
import { useCheckoutComponents } from "./ui";
|
|
16
17
|
import { useWalletCharge, type WalletCharge } from "./use-wallet-charge";
|
|
17
18
|
|
|
@@ -35,29 +36,29 @@ type WalletPaneProps = ProviderCheckoutScreenProps & {
|
|
|
35
36
|
order: NonNullable<ProviderCheckoutScreenProps["order"]>;
|
|
36
37
|
};
|
|
37
38
|
|
|
38
|
-
/** Post-submit confirmation,
|
|
39
|
+
/** Post-submit confirmation, timeout > error > spinner — the card view's order. */
|
|
39
40
|
function WalletProcessing({ wallet }: { wallet: WalletCharge }): JSX.Element {
|
|
40
|
-
const {
|
|
41
|
+
const { LoadingState } = useCheckoutComponents();
|
|
41
42
|
const copy = useCheckoutCopy().screens.settling;
|
|
42
|
-
if (wallet.
|
|
43
|
+
if (wallet.pollTimedOut) {
|
|
43
44
|
return (
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
<StalledWait
|
|
46
|
+
title={copy.takingLonger}
|
|
47
|
+
description={copy.takingLongerHelp}
|
|
48
|
+
onCheckAgain={wallet.pollCheckAgain}
|
|
49
|
+
testId="wallet-poll-timeout"
|
|
50
|
+
actionTestId="wallet-check-again"
|
|
50
51
|
/>
|
|
51
52
|
);
|
|
52
53
|
}
|
|
53
|
-
if (wallet.
|
|
54
|
+
if (wallet.pollError) {
|
|
54
55
|
return (
|
|
55
|
-
<
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
<StalledWait
|
|
57
|
+
title={copy.connectionLost}
|
|
58
|
+
description={wallet.pollError}
|
|
59
|
+
onCheckAgain={wallet.pollCheckAgain}
|
|
60
|
+
testId="wallet-poll-error"
|
|
61
|
+
actionTestId="wallet-check-again"
|
|
61
62
|
/>
|
|
62
63
|
);
|
|
63
64
|
}
|
package/src/flows/copy.ts
CHANGED
|
@@ -59,6 +59,25 @@ export interface CheckoutCopyFE {
|
|
|
59
59
|
* part only a host can own.
|
|
60
60
|
*/
|
|
61
61
|
returnTimedOut?: string;
|
|
62
|
+
/**
|
|
63
|
+
* The return leg while the poll cannot REACH us (FUT-1144) — a dropped
|
|
64
|
+
* connection, or a browser that aborted our requests while the buyer was on
|
|
65
|
+
* the provider's page. The wait is still running and re-arms the moment the
|
|
66
|
+
* tab or the signal comes back, so the sentence must say "still trying", not
|
|
67
|
+
* "failed".
|
|
68
|
+
*
|
|
69
|
+
* OPTIONAL, on the {@link returnTimedOut} precedent: without it the screen
|
|
70
|
+
* shows the transport's own sentence — which the host already owns, through
|
|
71
|
+
* `views.screens.transport` — under `returnPending` as a warning.
|
|
72
|
+
*/
|
|
73
|
+
returnUnreachable?: string;
|
|
74
|
+
/**
|
|
75
|
+
* The label on the buyer's "ask now" (FUT-1144), offered whenever the wait is
|
|
76
|
+
* unreachable or has elapsed. OPTIONAL for the same reason, and its ABSENCE
|
|
77
|
+
* costs the button: a control with no host-written label could only carry
|
|
78
|
+
* this package's Portuguese.
|
|
79
|
+
*/
|
|
80
|
+
returnCheckAgain?: string;
|
|
62
81
|
/** The Dados step's primary action. */
|
|
63
82
|
continueAction: string;
|
|
64
83
|
/**
|
|
@@ -108,13 +108,60 @@ function buildHostedHandoff(runtime: FlowsRuntime): CheckoutScreens["HostedHando
|
|
|
108
108
|
* Stated here rather than imported because the two waits are the same DECISION
|
|
109
109
|
* arrived at twice, not one shared implementation: this screen takes its FAST
|
|
110
110
|
* interval from the host's `polling` config when there is one, and a host that
|
|
111
|
-
* tunes that must not have this package's
|
|
112
|
-
* wall-clock window than the constant's comment claims
|
|
111
|
+
* tunes that must not have this package's bound silently mean a different
|
|
112
|
+
* wall-clock window than the constant's comment claims — which is exactly what
|
|
113
|
+
* a bound counted in POLLS did, and why it is counted in milliseconds now
|
|
114
|
+
* (FUT-1144).
|
|
113
115
|
*/
|
|
114
116
|
const RETURN_FAST_MS = 2_500;
|
|
115
117
|
const RETURN_SLOW_MS = 10_000;
|
|
116
118
|
const RETURN_FAST_POLLS = (2 * 60_000) / RETURN_FAST_MS;
|
|
117
|
-
const
|
|
119
|
+
const RETURN_WINDOW_MS = 15 * 60_000;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The two ways this wait stops looking like progress, said as a warning with
|
|
123
|
+
* the buyer's own "ask now" under it (FUT-1144).
|
|
124
|
+
*
|
|
125
|
+
* The button is drawn only when the host wrote a label for it. That is the
|
|
126
|
+
* `returnTimedOut` precedent one step further: a bound with no copy still
|
|
127
|
+
* stops the spinner, and an action with no copy could only be labelled in this
|
|
128
|
+
* package's Portuguese, so it is the one half that stands down.
|
|
129
|
+
*/
|
|
130
|
+
function ReturnStalled({
|
|
131
|
+
runtime,
|
|
132
|
+
description,
|
|
133
|
+
onCheckAgain,
|
|
134
|
+
testId,
|
|
135
|
+
}: {
|
|
136
|
+
runtime: FlowsRuntime;
|
|
137
|
+
description: string | undefined;
|
|
138
|
+
onCheckAgain: () => void;
|
|
139
|
+
testId: string;
|
|
140
|
+
}): JSX.Element {
|
|
141
|
+
const { Alert, Button } = useCheckoutComponents();
|
|
142
|
+
return (
|
|
143
|
+
<Box data-testid="checkout-hosted-return" sx={{ py: 4, display: "flex", flexDirection: "column", gap: 2 }}>
|
|
144
|
+
<Alert
|
|
145
|
+
variant="warning"
|
|
146
|
+
title={runtime.copy.returnPending}
|
|
147
|
+
{...(description === undefined ? {} : { description })}
|
|
148
|
+
showIcon
|
|
149
|
+
data-testid={testId}
|
|
150
|
+
/>
|
|
151
|
+
{runtime.copy.returnCheckAgain === undefined ? null : (
|
|
152
|
+
<Button
|
|
153
|
+
variant="outline"
|
|
154
|
+
color="neutral"
|
|
155
|
+
size="md"
|
|
156
|
+
onClick={onCheckAgain}
|
|
157
|
+
dataTestId="checkout-hosted-return-check-again"
|
|
158
|
+
>
|
|
159
|
+
{runtime.copy.returnCheckAgain}
|
|
160
|
+
</Button>
|
|
161
|
+
)}
|
|
162
|
+
</Box>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
118
165
|
|
|
119
166
|
function buildHostedReturn(runtime: FlowsRuntime): CheckoutScreens["HostedReturn"] {
|
|
120
167
|
function HostedReturnBody({
|
|
@@ -126,15 +173,15 @@ function buildHostedReturn(runtime: FlowsRuntime): CheckoutScreens["HostedReturn
|
|
|
126
173
|
// Read-and-clear, once, on first render: the resumed view belongs to
|
|
127
174
|
// exactly one return trip.
|
|
128
175
|
const [parked] = useState(takeHostedOrder);
|
|
129
|
-
// Bounded, for the reason on
|
|
176
|
+
// Bounded, for the reason on RETURN_WINDOW_MS: nothing here can ever reach a
|
|
130
177
|
// terminal state on its own, so an unbounded poll is a spinner the buyer
|
|
131
178
|
// watches until they close the tab.
|
|
132
|
-
const { status, timedOut } = usePaymentPolling(parked?.orderId ?? null, {
|
|
179
|
+
const { status, timedOut, error, checkAgain } = usePaymentPolling(parked?.orderId ?? null, {
|
|
133
180
|
enabled: Boolean(parked),
|
|
134
181
|
intervalMs: runtime.config.polling?.intervalMs ?? RETURN_FAST_MS,
|
|
135
182
|
slowAfterPolls: RETURN_FAST_POLLS,
|
|
136
183
|
slowIntervalMs: RETURN_SLOW_MS,
|
|
137
|
-
|
|
184
|
+
maxWaitMs: RETURN_WINDOW_MS,
|
|
138
185
|
});
|
|
139
186
|
|
|
140
187
|
useEffect(() => {
|
|
@@ -152,19 +199,27 @@ function buildHostedReturn(runtime: FlowsRuntime): CheckoutScreens["HostedReturn
|
|
|
152
199
|
/>
|
|
153
200
|
);
|
|
154
201
|
}
|
|
202
|
+
// STOPPED beats STILL TRYING. A wait that failed its way to the wall clock
|
|
203
|
+
// carries both, and the elapsed state is the one that stops asking — and the
|
|
204
|
+
// one whose sentence says not to pay again.
|
|
155
205
|
if (timedOut) {
|
|
156
206
|
return (
|
|
157
|
-
<
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
207
|
+
<ReturnStalled
|
|
208
|
+
runtime={runtime}
|
|
209
|
+
description={runtime.copy.returnTimedOut}
|
|
210
|
+
onCheckAgain={checkAgain}
|
|
211
|
+
testId="checkout-hosted-return-timeout"
|
|
212
|
+
/>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
if (error !== null) {
|
|
216
|
+
return (
|
|
217
|
+
<ReturnStalled
|
|
218
|
+
runtime={runtime}
|
|
219
|
+
description={runtime.copy.returnUnreachable ?? error}
|
|
220
|
+
onCheckAgain={checkAgain}
|
|
221
|
+
testId="checkout-hosted-return-unreachable"
|
|
222
|
+
/>
|
|
168
223
|
);
|
|
169
224
|
}
|
|
170
225
|
return (
|