@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.
@@ -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
- * Opt-in bound on successful non-terminal polls (FUT-191): stop scheduling and
13
- * report `timedOut` once this many healthy AWAITING responses arrive.
14
- * Undefined ⇒ unbounded, today's behavior (the PIX consumer passes no cap).
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
- maxHealthyPolls?: number;
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
- /** Consecutive poll errors tolerated before giving up (avoids an infinite spinner). */
36
- const MAX_POLL_ERRORS = 4;
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 pollDelay(healthy: number, options: PollingOptions): number {
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
- * Uses a self-scheduling `setTimeout` (never overlapping requests) that stops as
60
- * soon as the order is PAID/FAILED/EXPIRED, and tears down on unmount or when the
61
- * order id changes. This is the client half of the async confirmation the
62
- * PagSeguro webhook drives on the server.
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
- maxHealthyPolls,
283
+ maxWaitMs,
70
284
  slowAfterPolls,
71
285
  slowIntervalMs,
72
286
  }: PollingOptions = {},
73
- ): { status: OrderStatus | null; error: string | null; timedOut: boolean } {
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
- let cancelled = false;
88
- let timer: ReturnType<typeof setTimeout> | undefined;
89
- let errorCount = 0;
90
- let healthyCount = 0;
91
- setTimedOut(false);
92
- setError(null);
93
-
94
- const tick = async (): Promise<void> => {
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
- cancelled = true;
130
- if (timer) {
131
- clearTimeout(timer);
132
- }
312
+ unlisten();
313
+ running.stop();
314
+ loop.current = null;
133
315
  };
134
- }, [orderId, intervalMs, enabled, maxHealthyPolls, slowAfterPolls, slowIntervalMs, client]);
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 healthy-poll cap elapsed while still AWAITING (FUT-191). */
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
- * Healthy-poll cap for the wallet AWAITING wait — the card path's own cap
53
- * (FUT-191): 36 polls 90 s at the 2500 ms default interval.
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 WALLET_AWAITING_POLL_CAP = 36;
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 { status, error: pollError, timedOut: pollTimedOut } = usePaymentPolling(order.orderId, {
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
- maxHealthyPolls: WALLET_AWAITING_POLL_CAP,
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, error > timeout > spinner — the card view's order. */
39
+ /** Post-submit confirmation, timeout > error > spinner — the card view's order. */
39
40
  function WalletProcessing({ wallet }: { wallet: WalletCharge }): JSX.Element {
40
- const { Alert, LoadingState } = useCheckoutComponents();
41
+ const { LoadingState } = useCheckoutComponents();
41
42
  const copy = useCheckoutCopy().screens.settling;
42
- if (wallet.pollError) {
43
+ if (wallet.pollTimedOut) {
43
44
  return (
44
- <Alert
45
- variant="danger"
46
- title={copy.cannotConfirm}
47
- description={wallet.pollError}
48
- showIcon
49
- data-testid="wallet-poll-error"
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.pollTimedOut) {
54
+ if (wallet.pollError) {
54
55
  return (
55
- <Alert
56
- variant="warning"
57
- title={copy.takingLonger}
58
- description={copy.takingLongerHelp}
59
- showIcon
60
- data-testid="wallet-poll-timeout"
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 cap silently mean a different
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 RETURN_POLL_CAP = RETURN_FAST_POLLS + (13 * 60_000) / RETURN_SLOW_MS;
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 RETURN_POLL_CAP: nothing here can ever reach a
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
- maxHealthyPolls: RETURN_POLL_CAP,
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
- <Box data-testid="checkout-hosted-return" sx={{ py: 4 }}>
158
- <Alert
159
- variant="warning"
160
- title={runtime.copy.returnPending}
161
- {...(runtime.copy.returnTimedOut === undefined
162
- ? {}
163
- : { description: runtime.copy.returnTimedOut })}
164
- showIcon
165
- data-testid="checkout-hosted-return-timeout"
166
- />
167
- </Box>
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 (