@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.
@@ -4,6 +4,7 @@ import QRCode from "react-qr-code";
4
4
 
5
5
  import { useCheckoutCopy } from "./copy-context";
6
6
  import { ContentCopyIcon } from "./icons";
7
+ import { StalledWait } from "./stalled-wait";
7
8
  import type { CheckoutOrder, OrderStatus, PixCharge } from "./types";
8
9
  import { useCheckoutComponents } from "./ui";
9
10
  import { usePaymentPolling } from "./use-payment-polling";
@@ -71,19 +72,38 @@ function PixCodeBox({ pix }: { pix: PixCharge }): JSX.Element {
71
72
  );
72
73
  }
73
74
 
74
- /** The live footer: poll error, or the pulsing "awaiting payment" indicator. */
75
- function PixPollFooter({ error }: { error: string | null }): JSX.Element {
76
- const { Alert, Text } = useCheckoutComponents();
75
+ /**
76
+ * The live footer: the pulsing "awaiting payment" indicator, or while the
77
+ * poll cannot reach us — the same wait said out loud, with a way to hurry it.
78
+ *
79
+ * A WARNING rather than a danger (FUT-1144). The old red panel said "não foi
80
+ * possível confirmar o pagamento" and meant it: four consecutive failures ended
81
+ * the wait, so a shopper who paid during a ten-second blip watched a QR under a
82
+ * final-sounding refusal that would never update. The QR is still good, the
83
+ * wait is still running, and the sentence now says both.
84
+ */
85
+ function PixPollFooter({
86
+ error,
87
+ onCheckAgain,
88
+ }: {
89
+ error: string | null;
90
+ onCheckAgain: () => void;
91
+ }): JSX.Element {
92
+ const { Text } = useCheckoutComponents();
77
93
  const { pix, settling } = useCheckoutCopy().screens;
78
94
  if (error) {
95
+ // The same panel the card and wallet panes show, held to the width of the
96
+ // copy-and-paste strip above it so the centred PIX column stays a column.
79
97
  return (
80
- <Alert
81
- variant="danger"
82
- title={settling.cannotConfirm}
83
- description={error}
84
- showIcon
85
- data-testid="pix-poll-error"
86
- />
98
+ <Box sx={{ width: "100%", maxWidth: 420 }}>
99
+ <StalledWait
100
+ title={settling.connectionLost}
101
+ description={error}
102
+ onCheckAgain={onCheckAgain}
103
+ testId="pix-poll-error"
104
+ actionTestId="pix-check-again"
105
+ />
106
+ </Box>
87
107
  );
88
108
  }
89
109
  return (
@@ -107,7 +127,9 @@ export function PixView({
107
127
  }): JSX.Element {
108
128
  const { Text } = useCheckoutComponents();
109
129
  const copy = useCheckoutCopy().screens.pix;
110
- const { status, error } = usePaymentPolling(order.orderId, { intervalMs: pollIntervalMs });
130
+ const { status, error, checkAgain } = usePaymentPolling(order.orderId, {
131
+ intervalMs: pollIntervalMs,
132
+ });
111
133
 
112
134
  // Bubble a terminal status up once, so the parent can advance to the status step.
113
135
  useEffect(() => {
@@ -160,7 +182,7 @@ export function PixView({
160
182
  {copy.validUntil(validUntil)}
161
183
  </Text>
162
184
 
163
- <PixPollFooter error={error} />
185
+ <PixPollFooter error={error} onCheckAgain={checkAgain} />
164
186
  </Box>
165
187
  );
166
188
  }
@@ -34,8 +34,15 @@ export const PT_BR_PAYMENT_STATUS_COPY: PaymentStatusCopy = {
34
34
  "Se você já pagou, o pedido é confirmado assim que a operadora avisar — " +
35
35
  "não pague de novo. Você pode fechar esta tela.",
36
36
  },
37
+ awaitingUnreachable: {
38
+ heading: "Não conseguimos falar com o pagamento agora",
39
+ support:
40
+ "Continuamos tentando por aqui. Se você já pagou, não pague de novo — " +
41
+ "o pedido é confirmado assim que a operadora avisar.",
42
+ },
37
43
  retryAction: "Tentar novamente",
38
44
  regenerateAction: "Gerar novo código",
45
+ checkAgainAction: "Verificar de novo",
39
46
  backAction: "Voltar ao cardápio",
40
47
  amountLabel: "Valor pago",
41
48
  referenceLabel: "Pedido",
@@ -61,6 +61,25 @@ export interface SettlingCopy {
61
61
  confirming: string;
62
62
  /** It came back refused. */
63
63
  cannotPay: string;
64
+ /**
65
+ * We cannot reach the payment right now — a dropped connection, a handset
66
+ * moving between Wi-Fi and 4G, a browser that aborted our requests while the
67
+ * shopper was in their bank app (FUT-1144).
68
+ *
69
+ * TRANSIENT, and the sentence must say so: the screen is still asking, on a
70
+ * backoff, and it re-asks the moment the tab comes back or the signal
71
+ * returns. This was `cannotConfirm` — "não foi possível confirmar o
72
+ * pagamento" — under which the wait had actually STOPPED, so a shopper who
73
+ * had paid read a final-sounding refusal and was never told otherwise.
74
+ */
75
+ connectionLost: string;
76
+ /**
77
+ * Ask again, now. Offered beside {@link connectionLost} and beside the
78
+ * elapsed wait, because a shopper watching a screen that cannot reach us
79
+ * needs something to press — and because pressing it is what restarts a wait
80
+ * that has run out.
81
+ */
82
+ checkAgainAction: string;
64
83
  }
65
84
 
66
85
  /** The PIX pane: the QR, the copyable code, and the wait. */
@@ -35,6 +35,10 @@ export const EN_US_CHECKOUT_SCREENS_COPY: CheckoutScreensCopy = {
35
35
  processing: 'Processing payment…',
36
36
  confirming: 'We are confirming your payment',
37
37
  cannotPay: 'Could not pay',
38
+ // "we keep trying" is the load-bearing half: the wait has not ended, and a
39
+ // shopper who reads a final-sounding refusal pays a second time.
40
+ connectionLost: 'No connection right now — we keep trying',
41
+ checkAgainAction: 'Check again',
38
42
  },
39
43
  pix: {
40
44
  heading: 'Pay with PIX',
@@ -26,6 +26,8 @@ export const PT_BR_CHECKOUT_SCREENS_COPY: CheckoutScreensCopy = {
26
26
  processing: 'Processando pagamento…',
27
27
  confirming: 'Estamos confirmando seu pagamento',
28
28
  cannotPay: 'Não foi possível pagar',
29
+ connectionLost: 'Sem conexão no momento — continuamos tentando',
30
+ checkAgainAction: 'Verificar de novo',
29
31
  },
30
32
  pix: {
31
33
  heading: 'Pague com PIX',
@@ -0,0 +1,58 @@
1
+ import { Box } from "@mui/material";
2
+ import type { JSX } from "react";
3
+
4
+ import { useCheckoutCopy } from "./copy-context";
5
+ import { useCheckoutComponents } from "./ui";
6
+
7
+ /**
8
+ * A wait that has stopped LOOKING like progress, said as a warning with the
9
+ * buyer's own "ask now" underneath (FUT-1144).
10
+ *
11
+ * Two situations reach it and they are deliberately the same shape: the poll
12
+ * cannot reach us, or the wall-clock wait has run out. In both, the confirmation
13
+ * spinner would be telling the buyer something is happening when nothing is —
14
+ * and by this point in the card and wallet panes every pay control is already
15
+ * gone, so without this button the screen has no control at all while it reports
16
+ * a problem. Pressing it restarts the wait, which is what makes it worth
17
+ * offering after a timeout and not only during a blip.
18
+ *
19
+ * A WARNING rather than a danger, in both. Neither says the payment failed:
20
+ * the charge is recoverable by webhook, reconciliation or backfill, and a red
21
+ * panel over a recoverable charge is what pushes a buyer into paying twice.
22
+ *
23
+ * One component for the card and the wallet because it is one decision. The
24
+ * panes' own confirmation states were near-identical before this and drifted
25
+ * apart in exactly the way that ends with a product telling a buyer two
26
+ * different things about one situation depending on which button they pressed.
27
+ */
28
+ export function StalledWait({
29
+ title,
30
+ description,
31
+ onCheckAgain,
32
+ testId,
33
+ actionTestId,
34
+ }: {
35
+ title: string;
36
+ description: string;
37
+ onCheckAgain: () => void;
38
+ /** The alert's id — each pane names its own situation for its own suites. */
39
+ testId: string;
40
+ actionTestId: string;
41
+ }): JSX.Element {
42
+ const { Alert, Button } = useCheckoutComponents();
43
+ const copy = useCheckoutCopy().screens.settling;
44
+ return (
45
+ <Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
46
+ <Alert variant="warning" title={title} description={description} showIcon data-testid={testId} />
47
+ <Button
48
+ variant="outline"
49
+ color="neutral"
50
+ size="md"
51
+ onClick={onCheckAgain}
52
+ dataTestId={actionTestId}
53
+ >
54
+ {copy.checkAgainAction}
55
+ </Button>
56
+ </Box>
57
+ );
58
+ }
@@ -26,6 +26,7 @@ import { useCheckoutClientApi } from "./client-context";
26
26
  import { rememberHostedOrder } from "./hosted-return";
27
27
  import { useCheckoutNavigate, type CheckoutNavigate } from "./navigate-context";
28
28
  import type { CardChainLink } from "./method-capability";
29
+ import { useOneClickArmed, useOneClickPay } from "./one-click";
29
30
  import type { BuyerInfo, CheckoutOrder, OrderStatus } from "./types";
30
31
  import { usePaymentPolling } from "./use-payment-polling";
31
32
  import type { CardCopy } from "../../card/copy";
@@ -99,7 +100,7 @@ function useCardPublicKey(
99
100
  }
100
101
 
101
102
  /** Everything the card view renders — all checkout state + the submit handler. */
102
- interface CardCheckout {
103
+ export interface CardCheckout {
103
104
  savedCards: SavedCard[];
104
105
  selection: string;
105
106
  setSelection: (id: string) => void;
@@ -116,9 +117,16 @@ interface CardCheckout {
116
117
  errorCode: string | null;
117
118
  submitting: boolean;
118
119
  submitted: boolean;
120
+ /**
121
+ * The last status poll failed. TRANSIENT (FUT-1144): the wait carries on at a
122
+ * backed-off cadence and this clears on the next success, so the view shows
123
+ * it as "still trying" beside {@link pollCheckAgain} rather than as an end.
124
+ */
119
125
  pollError: string | null;
120
- /** The healthy-poll cap elapsed while still AWAITING (FUT-191 bounded wait). */
126
+ /** The bounded AWAITING wait elapsed (FUT-191, now wall-clock — FUT-1144). */
121
127
  pollTimedOut: boolean;
128
+ /** Ask now and restart the wait — the buyer's "verificar de novo". */
129
+ pollCheckAgain: () => void;
122
130
  handlePay: () => Promise<void>;
123
131
  }
124
132
 
@@ -141,14 +149,20 @@ type CardSubmit = Pick<
141
149
  | "errorCode"
142
150
  | "pollError"
143
151
  | "pollTimedOut"
152
+ | "pollCheckAgain"
144
153
  | "handlePay"
145
154
  >;
146
155
 
147
156
  /**
148
- * Healthy-poll cap for the card AWAITING wait: 36 polls 90 s at the 2500 ms
149
- * default interval (FUT-191). PIX passes no cap and keeps today's behavior.
157
+ * The card AWAITING wait, bounded in WALL TIME: 90 s (FUT-191, FUT-1144).
158
+ *
159
+ * It was 36 healthy polls, which is the same 90 s at the default 2500 ms
160
+ * interval and an unbounded wait at any other — including the one that
161
+ * mattered, where every poll is FAILING and the healthy count never moves.
162
+ * PIX passes no bound at all and keeps today's behavior: its charge expires
163
+ * server-side and comes back as a terminal EXPIRED.
150
164
  */
151
- const CARD_AWAITING_POLL_CAP = 36;
165
+ const CARD_AWAITING_WAIT_MS = 90_000;
152
166
 
153
167
  /**
154
168
  * Hand the buyer to the provider's authentication page (FUT-698) — Stripe's
@@ -209,6 +223,31 @@ async function resolveInstruments(input: {
209
223
  );
210
224
  }
211
225
 
226
+ /**
227
+ * The card's status wait, named the way the view reads it.
228
+ *
229
+ * Its own function so `useCardSubmit` stays inside the size gate, and so the
230
+ * one decision here — this wait is bounded in WALL TIME — sits beside the
231
+ * constant that states it rather than inside a submit machine.
232
+ */
233
+ function useCardWait(
234
+ orderId: string,
235
+ submitted: boolean,
236
+ intervalMs: number,
237
+ ): {
238
+ status: OrderStatus | null;
239
+ pollError: string | null;
240
+ pollTimedOut: boolean;
241
+ pollCheckAgain: () => void;
242
+ } {
243
+ const { status, error, timedOut, checkAgain } = usePaymentPolling(orderId, {
244
+ enabled: submitted,
245
+ intervalMs,
246
+ maxWaitMs: CARD_AWAITING_WAIT_MS,
247
+ });
248
+ return { status, pollError: error, pollTimedOut: timedOut, pollCheckAgain: checkAgain };
249
+ }
250
+
212
251
  /**
213
252
  * The submit state machine (FUT-58): validate → tokenize (self-heal) → charge →
214
253
  * poll for the async confirmation, bubbling the terminal status up via
@@ -234,11 +273,7 @@ function useCardSubmit(
234
273
  const navigate = useCheckoutNavigate();
235
274
  const cardCopy = useCheckoutCopy().card;
236
275
 
237
- const { status, error: pollError, timedOut: pollTimedOut } = usePaymentPolling(order.orderId, {
238
- enabled: submitted,
239
- intervalMs: pollIntervalMs,
240
- maxHealthyPolls: CARD_AWAITING_POLL_CAP,
241
- });
276
+ const { status, ...wait } = useCardWait(order.orderId, submitted, pollIntervalMs);
242
277
 
243
278
  useEffect(() => {
244
279
  if (status && status !== "AWAITING_PAYMENT") onResolved(status);
@@ -292,7 +327,7 @@ function useCardSubmit(
292
327
  else setSubmitted(true);
293
328
  };
294
329
 
295
- return { submitting, submitted, error, errorCode, pollError, pollTimedOut, handlePay };
330
+ return { submitting, submitted, error, errorCode, ...wait, handlePay };
296
331
  }
297
332
 
298
333
  /**
@@ -339,6 +374,13 @@ export function useCardCheckout(
339
374
  { card, usingNewCard, selection, saveCard, validate, setFieldErrors },
340
375
  providerChain,
341
376
  );
377
+ // The tap a one-click buyer already made (`./one-click.tsx`). Nothing about
378
+ // the charge differs — this only presses the button, and only while a SAVED
379
+ // card is the selection, which is a state the picker reaches exactly when the
380
+ // instrument list came back with something. A buyer with no saved card is
381
+ // left on the form, which is the ordinary step 2.
382
+ const ready = !usingNewCard && !submit.submitting && !submit.submitted && submit.error === null;
383
+ useOneClickPay({ armed: useOneClickArmed(), ready, pay: submit.handlePay });
342
384
 
343
385
  return {
344
386
  savedCards,
@@ -147,15 +147,19 @@ function handOverToProvider(
147
147
  * confirmation is at most 2.5 s late, and an abandoned checkout costs ~126
148
148
  * polls instead of the 360 a flat 2.5 s would have.
149
149
  *
150
- * The three constants are ONE decision — 48 × 2.5 s + 78 × 10 s ≈ 15 min — so
151
- * the cap is derived rather than typed, and cannot drift from the comment.
152
- *
153
150
  * Fifteen minutes because by then a webhook that was ever coming has come. Past
154
151
  * that the answer will not change while the buyer watches: the scheduled
155
152
  * reconciliation is what rescues a genuinely late one, and it does that whether
156
153
  * the tab is open or not.
157
154
  *
158
- * The card wait is bounded at 90 s (`CARD_AWAITING_POLL_CAP`) because a card
155
+ * The BOUND is that wall-clock window, not a poll count (FUT-1144). The two are
156
+ * the same number for a healthy wait — 48 × 2.5 s + 78 × 10 s = 15 min — and
157
+ * they part company for the wait that needed bounding: a poll that FAILS
158
+ * incremented nothing, so a connection that never came back left this screen
159
+ * asking, and spinning, with no end at all. A clock cannot be stopped by the
160
+ * failure it is measuring.
161
+ *
162
+ * The card wait is bounded at 90 s (`CARD_AWAITING_WAIT_MS`) because a card
159
163
  * authorises inline and a buyer is holding their phone. This leg is the other
160
164
  * shape: the buyer has already been off to another site and back, and may
161
165
  * legitimately still be finishing there.
@@ -165,8 +169,7 @@ const HOSTED_RESUME_SLOW_MS = 10_000;
165
169
  /** Two minutes at the fast rate, before the wait is worth economising on. */
166
170
  const HOSTED_RESUME_FAST_POLLS = (2 * 60_000) / HOSTED_RESUME_FAST_MS;
167
171
  /** Thirteen more at the slow one — 15 minutes all told. */
168
- const HOSTED_RESUME_POLL_CAP =
169
- HOSTED_RESUME_FAST_POLLS + (13 * 60_000) / HOSTED_RESUME_SLOW_MS;
172
+ const HOSTED_RESUME_WINDOW_MS = 15 * 60_000;
170
173
 
171
174
  /**
172
175
  * The leg of checkout that resumes after a hosted provider sent the buyer back
@@ -184,21 +187,56 @@ const HOSTED_RESUME_POLL_CAP =
184
187
  * had no way to reach a terminal state, because the ORDER has none: expiry is
185
188
  * PIX-only, and a redirect charge carries no QR window to lapse. `timedOut` is
186
189
  * what the screen says instead of spinning.
190
+ *
191
+ * `error` is the half that was dropped on the floor (FUT-1144), and dropping it
192
+ * is what made this leg the SILENT one. The poll below has always been able to
193
+ * fail; this hook returned `status` and `timedOut` and nothing else, so a leg
194
+ * whose every request was failing looked identical to one still waiting — the
195
+ * spinner, forever, with the reason a `console`-less browser away. It is
196
+ * surfaced now, together with the wait's own `checkAgain`, because a screen that
197
+ * says "we cannot reach the payment" and offers nothing to press is only half
198
+ * of an answer.
187
199
  */
188
200
  function useHostedResume(tenantSlug?: string): {
189
201
  order: CheckoutOrder | null;
190
202
  status: OrderStatus | null;
191
203
  timedOut: boolean;
204
+ error: string | null;
205
+ checkAgain: () => void;
192
206
  } {
193
207
  const [order] = useState(() => takeHostedOrder(tenantSlug));
194
- const { status, timedOut } = usePaymentPolling(order?.orderId ?? null, {
208
+ const { status, timedOut, error, checkAgain } = usePaymentPolling(order?.orderId ?? null, {
195
209
  enabled: Boolean(order),
196
210
  intervalMs: HOSTED_RESUME_FAST_MS,
197
211
  slowAfterPolls: HOSTED_RESUME_FAST_POLLS,
198
212
  slowIntervalMs: HOSTED_RESUME_SLOW_MS,
199
- maxHealthyPolls: HOSTED_RESUME_POLL_CAP,
213
+ maxWaitMs: HOSTED_RESUME_WINDOW_MS,
200
214
  });
201
- return { order, status, timedOut };
215
+ return { order, status, timedOut, error, checkAgain };
216
+ }
217
+
218
+ /**
219
+ * What the resumed leg contributes to the controller's surface.
220
+ *
221
+ * `resumeTimedOut` is only ever true on that leg — `useHostedResume` is the
222
+ * sole caller that bounds its wait, and a buyer who never left has a card or
223
+ * PIX view reporting its own. `resumeError` and `resumeCheckAgain` are the
224
+ * transient failure and the buyer's way out of it (FUT-1144).
225
+ *
226
+ * All three are inert for a checkout that never left this tab: with nothing
227
+ * parked the poll is disabled, so the error stays null, the bound never
228
+ * elapses, and the action has no wait to restart.
229
+ */
230
+ function resumeSurface(resume: ReturnType<typeof useHostedResume>): {
231
+ resumeTimedOut: boolean;
232
+ resumeError: string | null;
233
+ resumeCheckAgain: () => void;
234
+ } {
235
+ return {
236
+ resumeTimedOut: resume.timedOut,
237
+ resumeError: resume.error,
238
+ resumeCheckAgain: resume.checkAgain,
239
+ };
202
240
  }
203
241
 
204
242
  /**
@@ -338,10 +376,7 @@ export function useCheckoutController(
338
376
  return {
339
377
  step, setStep, method, setMethod, buyer, setBuyer, saveProfile, setSaveProfile,
340
378
  order, finalStatus: finalStatus ?? resume.status, creating,
341
- // Only ever true on the resumed leg: `useHostedResume` is the sole caller
342
- // that caps its polls, and a buyer who never left has a card or PIX view
343
- // reporting its own wait.
344
- resumeTimedOut: resume.timedOut,
379
+ ...resumeSurface(resume),
345
380
  createError: failure.message, errorField: failure.field, errorCode: failure.code,
346
381
  goToMenu: onExitToMenu, back, editBuyer,
347
382
  goToPayment, startPayment, payWithEmail, handleResolved, retry, completed,