@12-apps/payments-frontend 3.20.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/payments-frontend",
3
- "version": "3.20.0",
3
+ "version": "3.20.1",
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.",
@@ -22,7 +22,7 @@
22
22
  "storybook:build": "storybook build"
23
23
  },
24
24
  "dependencies": {
25
- "@12-apps/payments-backend": "^4.26.0",
25
+ "@12-apps/payments-backend": "^4.26.1",
26
26
  "react-qr-code": "^2.2.0"
27
27
  },
28
28
  "peerDependencies": {
@@ -10,45 +10,47 @@ import {
10
10
 
11
11
  import { useCheckoutCopy } from "./copy-context";
12
12
  import { UNRESOLVED_CODE } from "./failure-codes";
13
+ import { StalledWait } from "./stalled-wait";
13
14
  import type { CardChainLink } from "./method-capability";
14
15
  import type { BuyerInfo, CheckoutOrder, OrderStatus } from "./types";
15
16
  import { useCheckoutComponents } from "./ui";
16
- import { useCardCheckout } from "./use-card-checkout";
17
+ import { useCardCheckout, type CardCheckout } from "./use-card-checkout";
17
18
 
18
19
  /**
19
- * Post-submit confirmation state, error > timeout > spinner (FUT-191): a poll
20
- * failure is a danger Alert, the healthy-poll cap elapsing is a warning (the
21
- * order stays AWAITING server-side and is recoverable by webhook/reconcile/
22
- * backfill), and otherwise the now bounded — confirmation spinner shows.
20
+ * Post-submit confirmation state: timeout > error > spinner.
21
+ *
22
+ * The elapsed wall-clock wait leads (the order stays AWAITING server-side and is
23
+ * recoverable by webhook/reconcile/backfill, which is what its copy says), then
24
+ * a poll that cannot reach us — a warning saying we are STILL TRYING, where
25
+ * FUT-1144 found a danger Alert over a wait that had actually given up — then
26
+ * the ordinary bounded spinner.
27
+ *
28
+ * That order inverted with the meaning of the two flags. An error used to BE the
29
+ * ending; now the clock is, and a wait that failed its way to the clock carries
30
+ * both. "We keep trying" over a wait nothing is scheduled for is the lie.
23
31
  */
24
- function SubmittedState({
25
- pollError,
26
- pollTimedOut,
27
- }: {
28
- pollError: string | null;
29
- pollTimedOut: boolean;
30
- }): JSX.Element {
31
- const { Alert, LoadingState } = useCheckoutComponents();
32
+ function SubmittedState({ card }: { card: CardCheckout }): JSX.Element {
33
+ const { LoadingState } = useCheckoutComponents();
32
34
  const copy = useCheckoutCopy().screens.settling;
33
- if (pollError) {
35
+ if (card.pollTimedOut) {
34
36
  return (
35
- <Alert
36
- variant="danger"
37
- title={copy.cannotConfirm}
38
- description={pollError}
39
- showIcon
40
- data-testid="card-poll-error"
37
+ <StalledWait
38
+ title={copy.takingLonger}
39
+ description={copy.takingLongerHelp}
40
+ onCheckAgain={card.pollCheckAgain}
41
+ testId="card-poll-timeout"
42
+ actionTestId="card-check-again"
41
43
  />
42
44
  );
43
45
  }
44
- if (pollTimedOut) {
46
+ if (card.pollError) {
45
47
  return (
46
- <Alert
47
- variant="warning"
48
- title={copy.takingLonger}
49
- description={copy.takingLongerHelp}
50
- showIcon
51
- data-testid="card-poll-timeout"
48
+ <StalledWait
49
+ title={copy.connectionLost}
50
+ description={card.pollError}
51
+ onCheckAgain={card.pollCheckAgain}
52
+ testId="card-poll-error"
53
+ actionTestId="card-check-again"
52
54
  />
53
55
  );
54
56
  }
@@ -139,7 +141,7 @@ export function CardView({
139
141
  const unresolved = cc.errorCode === UNRESOLVED_CODE;
140
142
 
141
143
  if (cc.submitted) {
142
- return <SubmittedState pollError={cc.pollError} pollTimedOut={cc.pollTimedOut} />;
144
+ return <SubmittedState card={cc} />;
143
145
  }
144
146
 
145
147
  return (
@@ -174,6 +174,11 @@ function StatusStep({
174
174
  onBackToMenu={c.goToMenu}
175
175
  paidExtra={confirmationExtra}
176
176
  awaitingTimedOut={c.resumeTimedOut}
177
+ // The resumed leg's own trouble, and the way out of it (FUT-1144). Both
178
+ // are inert for a checkout that never left this tab — nothing was parked,
179
+ // so nothing is being polled here.
180
+ awaitingError={c.resumeError}
181
+ onCheckAgain={c.resumeCheckAgain}
177
182
  />
178
183
  );
179
184
  }
@@ -40,8 +40,17 @@ export const EN_US_PAYMENT_STATUS_COPY: PaymentStatusCopy = {
40
40
  "If you have already paid, the order is confirmed as soon as the provider tells us — " +
41
41
  "do not pay again. You can close this screen.",
42
42
  },
43
+ awaitingUnreachable: {
44
+ heading: "We cannot reach the payment right now",
45
+ // "do not pay again" leads the second sentence for the same reason it
46
+ // leads `awaitingTimedOut`: a second payment is the expensive mistake.
47
+ support:
48
+ "We are still trying. If you have already paid, do not pay again — " +
49
+ "the order is confirmed as soon as the provider tells us.",
50
+ },
43
51
  retryAction: "Try again",
44
52
  regenerateAction: "Generate a new code",
53
+ checkAgainAction: "Check again",
45
54
  backAction: "Back to the menu",
46
55
  amountLabel: "Amount paid",
47
56
  referenceLabel: "Order",
@@ -4,7 +4,7 @@ import type { JSX, ReactNode } from "react";
4
4
  import { CheckCircleOutlineIcon, ErrorOutlineIcon, ScheduleIcon } from "./icons";
5
5
  import type { OrderStatus } from "./types";
6
6
  import { useCheckoutComponents } from "./ui";
7
- import type { PaymentStatusCopy } from "./view-copy";
7
+ import type { PaymentStatusCopy, StatusOutcomeCopy } from "./view-copy";
8
8
 
9
9
  /**
10
10
  * The last screen of checkout.
@@ -79,37 +79,65 @@ function orderReference(orderId: string): string {
79
79
  return orderId.replace(/-/g, "").slice(0, 8).toUpperCase();
80
80
  }
81
81
 
82
+ /** How the wait itself is going, when it has not resolved into an outcome. */
83
+ interface WaitState {
84
+ /** The bounded wall-clock wait elapsed — nothing further is scheduled. */
85
+ timedOut: boolean;
86
+ /** The last poll failed, and the wait is still running (FUT-1144). */
87
+ unreachable: boolean;
88
+ }
89
+
90
+ /**
91
+ * Which of AWAITING's three faces this is.
92
+ *
93
+ * STOPPED beats STILL TRYING, and the order is the whole honesty of the screen.
94
+ * A wait that ran its clock out while failing carries BOTH flags — the last
95
+ * poll's error is still the last thing that happened — and saying "we keep
96
+ * trying" over a wait nothing is scheduled for is precisely the lie this ticket
97
+ * exists to remove. The elapsed state is also the one carrying "não pague de
98
+ * novo", which is the sentence that matters most when we have stopped looking.
99
+ *
100
+ * Both keep AWAITING's neutral clock icon and take WARNING's tone: the order is
101
+ * not resolved, and calm-but-alert is the visual for that.
102
+ */
103
+ function awaitingFace(
104
+ copy: PaymentStatusCopy,
105
+ wait: WaitState,
106
+ ): { outcome: StatusOutcomeCopy; tone: OutcomeVisual["tone"]; testId: string } | null {
107
+ if (wait.timedOut) {
108
+ return { outcome: copy.awaitingTimedOut, tone: "warning", testId: "payment-awaiting-timeout" };
109
+ }
110
+ if (wait.unreachable) {
111
+ return { outcome: copy.awaitingUnreachable, tone: "warning", testId: "payment-awaiting-unreachable" };
112
+ }
113
+ return null;
114
+ }
115
+
82
116
  /** The headline block: icon, outcome, and one supporting line. */
83
117
  function OutcomeHero({
84
118
  copy,
85
119
  status,
86
- timedOut = false,
120
+ wait,
87
121
  }: {
88
122
  copy: PaymentStatusCopy;
89
123
  status: OrderStatus;
90
- timedOut?: boolean;
124
+ wait: WaitState;
91
125
  }): JSX.Element {
92
126
  const { Text } = useCheckoutComponents();
93
- const timedOutWait = timedOut && status === "AWAITING_PAYMENT";
94
- // The timed-out wait keeps AWAITING's neutral clock icon but WARNING's tone:
95
- // the order is not resolved, and calm-but-alert is the visual for that.
96
- const visual = timedOutWait
97
- ? { icon: OUTCOME_VISUAL.AWAITING_PAYMENT.icon, tone: "warning" as const }
127
+ const face = status === "AWAITING_PAYMENT" ? awaitingFace(copy, wait) : null;
128
+ const visual = face
129
+ ? { icon: OUTCOME_VISUAL.AWAITING_PAYMENT.icon, tone: face.tone }
98
130
  : OUTCOME_VISUAL[status];
99
- const outcome = timedOutWait ? copy.awaitingTimedOut : copy[OUTCOME_COPY_KEY[status]];
131
+ const outcome = face ? face.outcome : copy[OUTCOME_COPY_KEY[status]];
100
132
  return (
101
133
  <Box
102
134
  // `payment-paid` is load-bearing for the storefront journeys — it is how
103
- // they assert the buyer actually got there. The timed-out wait gets its
135
+ // they assert the buyer actually got there. Each unsettled wait gets its
104
136
  // OWN id rather than reusing `payment-awaiting_payment`: a test that
105
- // cannot tell "still asking" from "stopped asking" is a test that would
106
- // pass against the unbounded spinner this replaced.
137
+ // cannot tell "still asking" from "stopped asking" from "cannot reach the
138
+ // payment" is a test that would pass against the spinner this replaced.
107
139
  data-testid={
108
- timedOut && status === "AWAITING_PAYMENT"
109
- ? "payment-awaiting-timeout"
110
- : status === "PAID"
111
- ? "payment-paid"
112
- : `payment-${status.toLowerCase()}`
140
+ face ? face.testId : status === "PAID" ? "payment-paid" : `payment-${status.toLowerCase()}`
113
141
  }
114
142
  sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1, textAlign: "center" }}
115
143
  >
@@ -176,23 +204,41 @@ function PaidFacts({
176
204
  );
177
205
  }
178
206
 
179
- /** The next-action row: retry / regenerate for failures, always back-to-menu. */
207
+ /** The next-action row: retry / regenerate / check-again, always back-to-menu. */
180
208
  function StatusActions({
181
209
  copy,
182
210
  status,
183
211
  onRetry,
184
212
  onRegenerate,
213
+ onCheckAgain,
185
214
  onBackToMenu,
186
215
  }: {
187
216
  copy: PaymentStatusCopy;
188
217
  status: OrderStatus;
189
218
  onRetry?: () => void;
190
219
  onRegenerate?: () => void;
220
+ /**
221
+ * Offered only while the wait is unsettled AND not visibly working — the
222
+ * caller decides that; here it is simply present or absent. A button under a
223
+ * healthy spinner would invite a tap that changes nothing.
224
+ */
225
+ onCheckAgain?: () => void;
191
226
  onBackToMenu: () => void;
192
227
  }): JSX.Element {
193
228
  const { Button } = useCheckoutComponents();
194
229
  return (
195
230
  <Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
231
+ {onCheckAgain ? (
232
+ <Button
233
+ variant="solid"
234
+ color="primary"
235
+ size="lg"
236
+ onClick={onCheckAgain}
237
+ dataTestId="payment-check-again"
238
+ >
239
+ {copy.checkAgainAction}
240
+ </Button>
241
+ ) : null}
196
242
  {status === "FAILED" && onRetry ? (
197
243
  <Button variant="solid" color="primary" size="lg" onClick={onRetry} dataTestId="payment-retry">
198
244
  {copy.retryAction}
@@ -218,18 +264,8 @@ function StatusActions({
218
264
  );
219
265
  }
220
266
 
221
- export function PaymentStatus({
222
- copy,
223
- status,
224
- totalLabel,
225
- orderId,
226
- buyerEmail,
227
- onRetry,
228
- onRegenerate,
229
- onBackToMenu,
230
- paidExtra,
231
- awaitingTimedOut = false,
232
- }: {
267
+ /** What the last screen of checkout is handed. */
268
+ interface PaymentStatusProps {
233
269
  /** Every sentence and label this screen renders — the HOST's words. */
234
270
  copy: PaymentStatusCopy;
235
271
  status: OrderStatus | null;
@@ -249,14 +285,69 @@ export function PaymentStatus({
249
285
  */
250
286
  paidExtra?: ReactNode;
251
287
  /**
252
- * The wait has been given up on see {@link AWAITING_TIMED_OUT}. Only
253
- * meaningful while AWAITING_PAYMENT; every other status has already resolved,
254
- * so a stale flag cannot change what a settled screen says.
288
+ * The wait has been given up on. Only meaningful while AWAITING_PAYMENT;
289
+ * every other status has already resolved, so a stale flag cannot change what
290
+ * a settled screen says.
255
291
  */
256
292
  awaitingTimedOut?: boolean;
257
- }): JSX.Element {
293
+ /**
294
+ * The wait's last poll failed (FUT-1144). Same scope rule as
295
+ * {@link awaitingTimedOut}, and it YIELDS to it: a wait that failed its way to
296
+ * the wall clock has both, and the honest thing to say then is that we have
297
+ * stopped asking.
298
+ */
299
+ awaitingError?: string | null;
300
+ /**
301
+ * Ask now. Rendered ONLY while the automatic wait is not visibly working —
302
+ * unreachable, or elapsed — so the buyer always has something to press when
303
+ * the spinner cannot honestly stand for progress, and nothing extra to think
304
+ * about when it can.
305
+ */
306
+ onCheckAgain?: () => void;
307
+ }
308
+
309
+ /**
310
+ * The wait has stopped LOOKING like progress: it cannot reach us, or it has run
311
+ * its clock out. Either way the spinner would be a lie and the buyer is owed
312
+ * something to press. Scoped to AWAITING because every other status has already
313
+ * resolved, so a stale flag cannot change what a settled screen says.
314
+ */
315
+ function isStalled(status: OrderStatus, wait: WaitState): boolean {
316
+ if (status !== "AWAITING_PAYMENT") return false;
317
+ return wait.timedOut || wait.unreachable;
318
+ }
319
+
320
+ /**
321
+ * The check-again action, but only where it can honestly be offered — a button
322
+ * under a healthy spinner invites a tap that changes nothing.
323
+ */
324
+ function offeredCheckAgain(
325
+ stalled: boolean,
326
+ onCheckAgain: (() => void) | undefined,
327
+ ): (() => void) | undefined {
328
+ return stalled ? onCheckAgain : undefined;
329
+ }
330
+
331
+ export function PaymentStatus({
332
+ copy,
333
+ status,
334
+ totalLabel,
335
+ orderId,
336
+ buyerEmail,
337
+ onRetry,
338
+ onRegenerate,
339
+ onBackToMenu,
340
+ paidExtra,
341
+ awaitingTimedOut = false,
342
+ awaitingError = null,
343
+ onCheckAgain,
344
+ }: PaymentStatusProps): JSX.Element {
258
345
  const { LoadingState } = useCheckoutComponents();
259
346
  const effective: OrderStatus = status ?? "AWAITING_PAYMENT";
347
+ const wait: WaitState = { timedOut: awaitingTimedOut, unreachable: awaitingError !== null };
348
+ const stalled = isStalled(effective, wait);
349
+ const paid = effective === "PAID";
350
+ const spinning = effective === "AWAITING_PAYMENT" && !stalled;
260
351
 
261
352
  return (
262
353
  <Box
@@ -265,15 +356,15 @@ export function PaymentStatus({
265
356
  data-timed-out={awaitingTimedOut ? "true" : undefined}
266
357
  sx={{ display: "flex", flexDirection: "column", gap: 3, alignItems: "stretch", py: 2 }}
267
358
  >
268
- <OutcomeHero copy={copy} status={effective} timedOut={awaitingTimedOut} />
359
+ <OutcomeHero copy={copy} status={effective} wait={wait} />
269
360
 
270
- {effective === "PAID" ? (
361
+ {paid ? (
271
362
  <PaidFacts copy={copy} totalLabel={totalLabel} orderId={orderId} buyerEmail={buyerEmail} />
272
363
  ) : null}
273
364
 
274
- {effective === "PAID" ? paidExtra : null}
365
+ {paid ? paidExtra : null}
275
366
 
276
- {effective === "AWAITING_PAYMENT" && !awaitingTimedOut ? (
367
+ {spinning ? (
277
368
  <LoadingState variant="spinner" size="md" message="" dataTestId="payment-pending" />
278
369
  ) : null}
279
370
 
@@ -282,6 +373,7 @@ export function PaymentStatus({
282
373
  status={effective}
283
374
  onRetry={onRetry}
284
375
  onRegenerate={onRegenerate}
376
+ onCheckAgain={offeredCheckAgain(stalled, onCheckAgain)}
285
377
  onBackToMenu={onBackToMenu}
286
378
  />
287
379
  </Box>
@@ -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
+ }