@12-apps/payments-frontend 3.20.0 → 3.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/payments-frontend",
3
- "version": "3.20.0",
3
+ "version": "3.21.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Browser half of the vendor-agnostic payments platform: plug-and-play MUI components for the per-provider settings page (credential form from each provider's schema, masked hints, verify/enable) and the checkout page (PIX QR + polling, card tokenization, hosted-checkout redirect), plus the headless hooks and fetch clients they build on. Talks only to the host's payments HTTP surface — never to a provider directly. Microfrontend-ready: no app coupling, host injects theme and auth.",
@@ -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": {
@@ -158,6 +158,7 @@ function ActivationBody({
158
158
  <CardVerification
159
159
  verifyChargeUrl={config.verifyChargeUrl(provider)}
160
160
  provider={provider}
161
+ displayName={displayName}
161
162
  ownerEmail={ownerEmail}
162
163
  onVerified={onVerified}
163
164
  CardSurface={config.CardSurface}
@@ -35,10 +35,13 @@ export type CardSurface = ComponentType<{ children: ReactNode }>;
35
35
  function ChargeForm({
36
36
  verification,
37
37
  amountLabel,
38
+ displayName,
38
39
  validateTaxId,
39
40
  }: {
40
41
  verification: ActivationCharge;
41
42
  amountLabel: string | null;
43
+ /** The provider being activated, as the owner knows it. */
44
+ displayName: string;
42
45
  validateTaxId: (value: string) => string | undefined;
43
46
  }): JSX.Element {
44
47
  const { taxId, actions } = useActivationCopy();
@@ -65,7 +68,7 @@ function ChargeForm({
65
68
  placeholder={taxId.placeholder}
66
69
  value={formatCpf(cpf)}
67
70
  error={Boolean(cpfError)}
68
- helperText={cpfError ?? taxId.hint}
71
+ helperText={cpfError ?? taxId.hint(displayName)}
69
72
  onChange={(event) => setCpf(onlyDigits(event.target.value))}
70
73
  onBlur={() => validateTaxId(cpf)}
71
74
  data-testid="verify-charge-cpf"
@@ -110,6 +113,7 @@ function CardOutcome({
110
113
  export function CardVerification({
111
114
  verifyChargeUrl,
112
115
  provider,
116
+ displayName,
113
117
  ownerEmail,
114
118
  onVerified,
115
119
  CardSurface: Surface,
@@ -118,6 +122,8 @@ export function CardVerification({
118
122
  }: {
119
123
  verifyChargeUrl: string;
120
124
  provider: string;
125
+ /** The name the OWNER reads; `provider` beside it is the machine key. */
126
+ displayName: string;
121
127
  /** Who is paying — the signed-in owner. The host reads its own session. */
122
128
  ownerEmail: string;
123
129
  onVerified: () => void;
@@ -173,6 +179,7 @@ export function CardVerification({
173
179
  <ChargeForm
174
180
  verification={verification}
175
181
  amountLabel={amountLabel}
182
+ displayName={displayName}
176
183
  validateTaxId={validateTaxId}
177
184
  />
178
185
  </Surface>
@@ -88,7 +88,7 @@ export const EN_US_ACTIVATION_STEP_COPY: ActivationStepCopy = {
88
88
  },
89
89
  taxId: {
90
90
  label: "Cardholder's CPF",
91
- hint: 'PagBank requires it on every card charge.',
91
+ hint: (displayName) => `${displayName} requires it on every card charge.`,
92
92
  placeholder: '000.000.000-00',
93
93
  },
94
94
  charge: {
@@ -25,8 +25,10 @@ import type { ActivationStepCopy } from './copy';
25
25
  * sent looking for "Integrated Checkout" is looking for a screen that does not
26
26
  * exist. **CPF** and its `000.000.000-00` mask are a Brazilian document and a
27
27
  * FORMAT — a translated mask shows a shape the field refuses. **PagBank** and
28
- * **InfinitePay** are names. `{provider}` is this package's own placeholder,
29
- * substituted after the sentence is chosen.
28
+ * **InfinitePay** are names PagBank now only as the console label above, since
29
+ * the tax-id hint takes the provider as an ARGUMENT rather than naming one
30
+ * (FUT-675). `{provider}` is this package's own placeholder, substituted after
31
+ * the sentence is chosen.
30
32
  */
31
33
  export const PT_BR_ACTIVATION_STEP_COPY: ActivationStepCopy = {
32
34
  intro: {
@@ -104,7 +106,7 @@ export const PT_BR_ACTIVATION_STEP_COPY: ActivationStepCopy = {
104
106
  },
105
107
  taxId: {
106
108
  label: 'CPF do titular',
107
- hint: 'Exigido pelo PagBank em qualquer cobrança no cartão.',
109
+ hint: (displayName) => `A ${displayName} exige o CPF em qualquer cobrança no cartão.`,
108
110
  placeholder: '000.000.000-00',
109
111
  },
110
112
  charge: {
@@ -126,7 +126,14 @@ export interface ActivationOutcomeCopy {
126
126
  /** The CPF field the card flow asks for beside the card. */
127
127
  export interface ActivationTaxIdCopy {
128
128
  readonly label: string;
129
- readonly hint: string;
129
+ /**
130
+ * WHO is asking for the document, by name. A parameter rather than a
131
+ * sentence, for the reason the setup guide's `credentials` takes one: the
132
+ * provider is a fact about the connection being activated, not about the
133
+ * language. Both packs had spelled one provider's name into the string, so a
134
+ * Stone activation told the owner PagBank required their CPF (FUT-675).
135
+ */
136
+ readonly hint: (displayName: string) => string;
130
137
  readonly placeholder: string;
131
138
  }
132
139
 
@@ -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',