@12-apps/payments-frontend 3.33.0 → 3.34.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.33.0",
3
+ "version": "3.34.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.",
@@ -13,6 +13,7 @@ import { CheckoutCopyProvider } from "./copy-context";
13
13
  import { OneClickProvider, useOneClick } from "./one-click";
14
14
  import { CheckoutComponentsProvider, useCheckoutComponents, type CheckoutComponents } from "./ui";
15
15
  import type { CheckoutViewCopy } from "./view-copy";
16
+ import { STEP_COLUMN_MAX } from "./payment-step-layout";
16
17
  import { useCheckoutController, type CheckoutHostPorts } from "./use-checkout-controller";
17
18
 
18
19
  /** Step ids are the flow's own contract; the labels beside them are host copy. */
@@ -340,7 +341,8 @@ function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Ele
340
341
  }
341
342
 
342
343
  return (
343
- <Box sx={{ display: "flex", flexDirection: "column", gap: { xs: 2, sm: 3 } }}>
344
+ // Pagamento alone takes the host's full width (its PIX pane, FUT-3367).
345
+ <Box sx={{ display: "flex", flexDirection: "column", gap: { xs: 2, sm: 3 }, width: "100%", mx: "auto", maxWidth: c.step === "payment" ? "none" : STEP_COLUMN_MAX }}>
344
346
  <CheckoutHeader copy={copy} step={c.step} onBack={c.back} />
345
347
 
346
348
  <ProgressHeader copy={copy} step={c.step} completed={c.completed} />
@@ -6,6 +6,8 @@ import { displayTotals, PayBarTotal } from "./checkout-totals";
6
6
  import { useCheckoutCopy } from "./copy-context";
7
7
  import { useMethodChoice } from "./method-choice";
8
8
  import { MethodPicker } from "./method-picker";
9
+ import { PixStageProvider } from "./pix-stage";
10
+ import { StepColumn, usePaymentPixStage } from "./payment-step-layout";
9
11
  import { PaymentErrorPanel } from "./payment-error-panel";
10
12
  import { PayerSummary } from "./payer-summary";
11
13
  import { resolveCheckoutScreen } from "./providers/registry";
@@ -228,22 +230,6 @@ interface PaymentStepProps {
228
230
  onResolved: OnCheckoutResolved;
229
231
  }
230
232
 
231
- /**
232
- * Step 2 "Pagamento" — pick PIX or card and pay on the SAME page. Selecting a
233
- * method auto-raises its order and reveals its UI (PIX QR / card form) with no
234
- * intermediate tap; switching method clears the previous order (controller).
235
- *
236
- * ## Unless the choice is not ours to ask
237
- *
238
- * A store that finishes checkout on the provider's own page gets NO picker
239
- * here (`methodChosenAtProvider`). Its screen renders a single "Seguir para o
240
- * pagamento" instead, and pressing it selects the store's hand-off method —
241
- * which is the same event a tile press is, so the auto-raise, the error panel
242
- * and the retry below all keep working unchanged. Preselection is suppressed
243
- * for the same flow, and deliberately: it exists to spare a buyer a tap that
244
- * buys them nothing, but here the tap is the buyer's consent to LEAVE, and
245
- * taking it for them would redirect a checkout the moment it rendered.
246
- */
247
233
  export function PaymentStep({
248
234
  method,
249
235
  onMethodChange,
@@ -270,55 +256,58 @@ export function PaymentStep({
270
256
  const config = providerConfig ?? null;
271
257
  const choice = useMethodChoice(config, method, onMethodChange);
272
258
  useAutoRaiseOrder(order, method, creating, createError, onGenerate);
259
+ const pixStage = usePaymentPixStage(choice, onMethodChange);
273
260
 
274
261
  return (
275
- <Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
276
- {/* THE AMOUNT, before anything asks for it (FUT-1179). At the top rather
277
- than in a sticky bar of its own: this step's actions belong to the
278
- pane below it — the PIX code, the card form's own pay bar, the
279
- hand-off button — and a second bar would put two "pay" controls on
280
- one screen. */}
281
- <PaymentStepTotal money={{ order, cartTotals, totalOverride, discountLines }} />
262
+ <PixStageProvider stage={pixStage}>
263
+ <Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
264
+ {/* THE AMOUNT, before anything asks for it (FUT-1179). At the top rather
265
+ than in a sticky bar of its own: this step's actions belong to the
266
+ pane below it — the PIX code, the card form's own pay bar, the
267
+ hand-off button — and a second bar would put two "pay" controls on
268
+ one screen. */}
269
+ <PaymentStepTotal money={{ order, cartTotals, totalOverride, discountLines }} />
282
270
 
283
- {/* Self-hiding: renders only for a flow whose Dados step was skipped. */}
284
- <PayerSummary name={buyer.name} taxId={buyer.taxId} onEdit={onEditBuyer} />
271
+ {/* Self-hiding: renders only for a flow whose Dados step was skipped. */}
272
+ <StepColumn>
273
+ <PayerSummary name={buyer.name} taxId={buyer.taxId} onEdit={onEditBuyer} />
274
+ </StepColumn>
285
275
 
286
- {choice.atProvider ? null : (
287
- <MethodPicker
288
- value={method}
289
- onChange={onMethodChange}
290
- cardUnavailable={choice.cardUnavailable}
291
- offered={choice.offered}
292
- pixByStore={config?.chain?.find((link) => link.methods.includes("PIX"))?.confirmation === "MANUAL"}
293
- />
294
- )}
276
+ {choice.atProvider || pixStage.afterCopy ? null : (
277
+ <MethodPicker value={method} onChange={onMethodChange} cardUnavailable={choice.cardUnavailable} offered={choice.offered} />
278
+ )}
295
279
 
296
- <PaymentBody
297
- order={order}
298
- buyer={buyer}
299
- providerConfig={config}
300
- method={method}
301
- tenantSlug={tenantSlug}
302
- onResolved={onResolved}
303
- onStart={choice.onStart}
304
- creating={creating}
305
- pollIntervalMs={pollIntervalMs}
306
- freshInstrument={freshInstrument}
307
- basket={basket}
308
- validateApplePayMerchant={validateApplePayMerchant}
309
- />
280
+ <StepColumn full={order?.method === "PIX"}>
281
+ <PaymentBody
282
+ order={order}
283
+ buyer={buyer}
284
+ providerConfig={config}
285
+ method={method}
286
+ tenantSlug={tenantSlug}
287
+ onResolved={onResolved}
288
+ onStart={choice.onStart}
289
+ creating={creating}
290
+ pollIntervalMs={pollIntervalMs}
291
+ freshInstrument={freshInstrument}
292
+ basket={basket}
293
+ validateApplePayMerchant={validateApplePayMerchant}
294
+ />
295
+ </StepColumn>
310
296
 
311
- <RaisingState
312
- order={order}
313
- method={method}
314
- creating={creating && !choice.atProvider}
315
- createError={createError}
316
- errorField={errorField}
317
- errorCode={errorCode}
318
- onUseEmail={onUseEmail}
319
- onGenerate={onGenerate}
320
- />
321
- </Box>
297
+ <StepColumn>
298
+ <RaisingState
299
+ order={order}
300
+ method={method}
301
+ creating={creating && !choice.atProvider}
302
+ createError={createError}
303
+ errorField={errorField}
304
+ errorCode={errorCode}
305
+ onUseEmail={onUseEmail}
306
+ onGenerate={onGenerate}
307
+ />
308
+ </StepColumn>
309
+ </Box>
310
+ </PixStageProvider>
322
311
  );
323
312
  }
324
313
 
@@ -63,3 +63,9 @@ export const PersonOutlineIcon = makeIcon([
63
63
  export const CloseIcon = makeIcon([
64
64
  'M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z',
65
65
  ]);
66
+
67
+ export const QrCodeIcon = makeIcon([
68
+ 'M3 11h8V3H3zm2-6h4v4H5zM3 21h8v-8H3zm2-6h4v4H5zm8-12v8h8V3zm6 6h-4V5h4zm0 10h2v2h-2zm-6-6h2v2h-2zm2 2h2v2h-2zm-2 2h2v2h-2zm2 2h2v2h-2zm2-2h2v2h-2zm0-4h2v2h-2zm2 2h2v2h-2z',
69
+ ]);
70
+
71
+ export const CheckIcon = makeIcon(['M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z']);
@@ -10,52 +10,42 @@ import { useCheckoutComponents } from "./ui";
10
10
  interface MethodOption {
11
11
  value: PaymentMethod;
12
12
  label: string;
13
- description: string;
14
13
  icon: JSX.Element;
15
14
  }
16
15
 
17
- /**
18
- * The two tiles, named by the HOST (FUT-760).
19
- *
20
- * Built per render rather than as a module constant, because the words are no
21
- * longer this package's: a frozen `OPTIONS` array could only hold the origin
22
- * host's Portuguese. The order and the icons are still ours — that is layout,
23
- * not language.
24
- */
25
- function methodOptions(copy: MethodPickerCopy, pixByStore: boolean): MethodOption[] {
26
- // A Pix the store confirms by hand is not "aprovação imediata" (FUT-3232).
27
- const pixDescription = (pixByStore ? copy.pixManualDescription : undefined) ?? copy.pixDescription;
16
+ function methodOptions(copy: MethodPickerCopy): MethodOption[] {
28
17
  return [
29
- { value: "PIX", label: copy.pixLabel, description: pixDescription, icon: <PixIcon /> },
30
- {
31
- value: "CARD",
32
- label: copy.cardLabel,
33
- description: copy.cardDescription,
34
- icon: <CreditCardIcon />,
35
- },
18
+ { value: "PIX", label: copy.pixLabel, icon: <PixIcon fontSize="small" /> },
19
+ { value: "CARD", label: copy.cardLabel, icon: <CreditCardIcon fontSize="small" /> },
36
20
  ];
37
21
  }
38
22
 
39
- /** Text/icon color for a tile's state — one place, so the ternaries stay flat. */
40
- function tileColor(unavailable: boolean, selected: boolean, muted: string): string {
23
+ function tileColor(unavailable: boolean, selected: boolean): string {
41
24
  if (unavailable) return "text.disabled";
42
- return selected ? "primary.main" : muted;
25
+ return selected ? "primary.main" : "text.primary";
43
26
  }
44
27
 
45
- /** One selectable (or disabled) payment-method tile of the segmented control. */
28
+ /**
29
+ * One method: its icon and its name on one line (the 2026-10-06 Pix
30
+ * redesign). The line under the name went: it cost the phone a row above the
31
+ * Pix code, and the name says enough. A tile the store cannot charge is
32
+ * disabled, and the reason is part of its accessible name ("Cartão, …") as
33
+ * well as the caption under it.
34
+ */
46
35
  function MethodTile({
47
36
  option,
48
37
  selected,
49
38
  unavailable,
39
+ reason,
50
40
  onSelect,
51
41
  }: {
52
42
  option: MethodOption;
53
43
  selected: boolean;
54
44
  unavailable: boolean;
45
+ reason: string;
55
46
  onSelect: () => void;
56
47
  }): JSX.Element {
57
48
  const { Text } = useCheckoutComponents();
58
- const copy = useCheckoutCopy().screens.method;
59
49
  const cursor = unavailable ? "not-allowed" : "pointer";
60
50
  return (
61
51
  <Box
@@ -63,24 +53,25 @@ function MethodTile({
63
53
  type="button"
64
54
  role="radio"
65
55
  aria-checked={selected}
56
+ aria-label={unavailable ? `${option.label}, ${reason}` : undefined}
66
57
  disabled={unavailable}
67
58
  onClick={unavailable ? undefined : onSelect}
68
59
  data-testid={`checkout-method-${option.value}`}
69
60
  sx={{
70
- flex: 1,
71
- minWidth: 0,
61
+ width: "100%",
62
+ minHeight: 48,
72
63
  display: "flex",
73
64
  alignItems: "center",
65
+ justifyContent: "center",
74
66
  gap: 1,
75
- p: 1.25,
67
+ px: 1.5,
76
68
  cursor,
77
- borderRadius: 2,
78
- border: "2px solid",
69
+ borderRadius: 1.5,
70
+ border: selected ? "2px solid" : "1px solid",
79
71
  borderColor: selected ? "primary.main" : "divider",
80
72
  bgcolor: selected ? "action.selected" : "background.paper",
81
- color: tileColor(unavailable, selected, "text.primary"),
73
+ color: tileColor(unavailable, selected),
82
74
  opacity: unavailable ? 0.6 : 1,
83
- textAlign: "left",
84
75
  transition: "border-color 0.15s, background-color 0.15s",
85
76
  "& *": { cursor },
86
77
  "&:hover": { borderColor: unavailable ? "divider" : "primary.main" },
@@ -91,47 +82,16 @@ function MethodTile({
91
82
  },
92
83
  }}
93
84
  >
94
- <Box sx={{ display: "flex", color: tileColor(unavailable, selected, "text.secondary") }}>
95
- {option.icon}
96
- </Box>
97
- <Box sx={{ minWidth: 0 }}>
98
- <Text variant="body" size="sm" weight="semibold" as="span">
99
- {option.label}
100
- </Text>
101
- <Text variant="caption" size="xs" color="secondary" as="p">
102
- {unavailable ? copy.unavailableHere : option.description}
103
- </Text>
104
- </Box>
85
+ <Box sx={{ display: "flex" }}>{option.icon}</Box>
86
+ <Text variant="body" size="sm" weight={selected ? "bold" : "semibold"} as="span">
87
+ {option.label}
88
+ </Text>
105
89
  </Box>
106
90
  );
107
91
  }
108
92
 
109
- /**
110
- * PIX / Card payment-method picker as a compact two-button segmented control.
111
- * Each option is a real focusable `<button role="radio">` (keyboard + AT
112
- * operable), selected-state shown by the button fill itself — so there's no radio
113
- * dot taking space and the two always sit side by side.
114
- *
115
- * `offered` derives the options from the chain's declared capabilities
116
- * (FUT-698): a provider that cannot PIX must not offer PIX — the charge walk
117
- * would refuse the method after the buyer already chose it. `null` (config
118
- * still loading / fetch blip) fails OPEN and offers everything: the server
119
- * still fails the charge closed.
120
- *
121
- * `cardUnavailable` DISABLES the card option with a caption saying so
122
- * (FUT-697): a provider with no browser card path must not take the buyer to a
123
- * form that fails at the last click — and a tile that silently vanished would
124
- * leave an unexplained one-option "choice". The caller preselects the sole
125
- * remaining method, so no extra tap is demanded either.
126
- */
127
- function visibleOptions(
128
- copy: MethodPickerCopy,
129
- offered: PaymentMethod[] | null,
130
- pixByStore: boolean,
131
- ): MethodOption[] {
132
- return methodOptions(copy, pixByStore).filter(
133
- (option) => offered === null || offered.includes(option.value),
134
- );
93
+ function visibleOptions(copy: MethodPickerCopy, offered: PaymentMethod[] | null): MethodOption[] {
94
+ return methodOptions(copy).filter((option) => offered === null || offered.includes(option.value));
135
95
  }
136
96
 
137
97
  export function MethodPicker({
@@ -139,7 +99,6 @@ export function MethodPicker({
139
99
  onChange,
140
100
  cardUnavailable = false,
141
101
  offered = null,
142
- pixByStore = false,
143
102
  }: {
144
103
  /** The chosen method, or `null` before the buyer has picked one. */
145
104
  value: PaymentMethod | null;
@@ -148,36 +107,39 @@ export function MethodPicker({
148
107
  cardUnavailable?: boolean;
149
108
  /** Methods the store's chain can charge, or `null` while unknown (fail open). */
150
109
  offered?: PaymentMethod[] | null;
151
- /** True ⇒ the chain's first PIX provider is one the STORE confirms by hand. */
152
- pixByStore?: boolean;
153
110
  }): JSX.Element {
154
111
  const { Text } = useCheckoutComponents();
155
112
  const copy = useCheckoutCopy().screens.method;
156
- const options = visibleOptions(copy, offered, pixByStore);
113
+ const options = visibleOptions(copy, offered);
157
114
  return (
158
- <Box>
159
- <Text variant="body" size="sm" weight="bold" as="p" style={{ marginBottom: 8 }}>
160
- {copy.groupLabel}
161
- </Text>
162
- <Box
163
- role="radiogroup"
164
- aria-label={copy.groupLabel}
165
- data-testid="checkout-method"
166
- sx={{ display: "flex", gap: 1 }}
167
- >
168
- {options.map((option) => {
169
- const unavailable = option.value === "CARD" && cardUnavailable;
170
- return (
115
+ <Box
116
+ role="radiogroup"
117
+ aria-label={copy.groupLabel}
118
+ data-testid="checkout-method"
119
+ sx={{ display: "grid", gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))`, gap: 1 }}
120
+ >
121
+ {options.map((option) => {
122
+ const unavailable = option.value === "CARD" && cardUnavailable;
123
+ const reasonId = `method-reason-${option.value}`;
124
+ return (
125
+ <Box key={option.value} sx={{ display: "flex", flexDirection: "column", gap: 0.5, minWidth: 0 }}>
171
126
  <MethodTile
172
- key={option.value}
173
127
  option={option}
174
128
  selected={option.value === value && !unavailable}
175
129
  unavailable={unavailable}
130
+ reason={copy.unavailableHere}
176
131
  onSelect={() => onChange(option.value)}
177
132
  />
178
- );
179
- })}
180
- </Box>
133
+ {unavailable ? (
134
+ <Box aria-hidden data-testid={reasonId} sx={{ textAlign: "center" }}>
135
+ <Text variant="caption" size="xs" color="secondary" as="p" style={{ margin: 0 }}>
136
+ {copy.unavailableHere}
137
+ </Text>
138
+ </Box>
139
+ ) : null}
140
+ </Box>
141
+ );
142
+ })}
181
143
  </Box>
182
144
  );
183
145
  }
@@ -0,0 +1,65 @@
1
+ import { Box } from "@mui/material";
2
+ import { useCallback, useMemo, useState, type JSX, type ReactNode } from "react";
3
+
4
+ import type { PixStage } from "./pix-stage";
5
+ import type { PaymentMethod } from "./types";
6
+
7
+ /**
8
+ * The width of the Dados and confirmation steps, and of every non-PIX pane:
9
+ * the content of the 720px column the checkout always had once its host's
10
+ * 24px gutters are taken (672). A host that widens the checkout for the PIX
11
+ * pane (FUT-3367) leaves everything else exactly as wide as before.
12
+ */
13
+ export const STEP_COLUMN_MAX = 672;
14
+
15
+ /**
16
+ * The payment step's form column. The host may give this step more room than
17
+ * the others (the 2026-10-06 Pix redesign lets the PIX pane put its QR and code
18
+ * side by side), and only that pane uses it: the card form, wallets, hand-off,
19
+ * the payer line and the raising and error panels keep the 672px they
20
+ * always had. `full` lets the PIX pane through.
21
+ */
22
+ export function StepColumn({ full = false, children }: { full?: boolean; children: ReactNode }): JSX.Element {
23
+ // `:empty` keeps a self-hiding child (the payer line, the raising state)
24
+ // from leaving a gap in the step's flex column.
25
+ return <Box sx={{ width: "100%", mx: "auto", maxWidth: full ? "none" : STEP_COLUMN_MAX, "&:empty": { display: "none" } }}>{children}</Box>;
26
+ }
27
+
28
+ /**
29
+ * The payment step's half of the PIX pane's after-copy face (the 2026-10-06
30
+ * Pix redesign): the flag that hides the method picker, and the way to the
31
+ * card that replaces it — offered only when the store actually takes a card,
32
+ * since with the picker hidden it is the buyer's only route there.
33
+ */
34
+ export function usePaymentPixStage(
35
+ choice: { offered: PaymentMethod[] | null; cardUnavailable: boolean },
36
+ onMethodChange: (method: PaymentMethod) => void,
37
+ ): PixStage {
38
+ const [afterCopy, setAfterCopy] = useState(false);
39
+ const cardOffered = (choice.offered === null || choice.offered.includes("CARD")) && !choice.cardUnavailable;
40
+ const preferCard = useCallback(() => {
41
+ setAfterCopy(false);
42
+ onMethodChange("CARD");
43
+ }, [onMethodChange]);
44
+ return useMemo(
45
+ () => ({ afterCopy, setAfterCopy, preferCard: cardOffered ? preferCard : undefined }),
46
+ [afterCopy, cardOffered, preferCard],
47
+ );
48
+ }
49
+
50
+ /**
51
+ * Step 2 "Pagamento" — pick PIX or card and pay on the SAME page. Selecting a
52
+ * method auto-raises its order and reveals its UI (PIX QR / card form) with no
53
+ * intermediate tap; switching method clears the previous order (controller).
54
+ *
55
+ * ## Unless the choice is not ours to ask
56
+ *
57
+ * A store that finishes checkout on the provider's own page gets NO picker
58
+ * here (`methodChosenAtProvider`). Its screen renders a single "Seguir para o
59
+ * pagamento" instead, and pressing it selects the store's hand-off method —
60
+ * which is the same event a tile press is, so the auto-raise, the error panel
61
+ * and the retry below all keep working unchanged. Preselection is suppressed
62
+ * for the same flow, and deliberately: it exists to spare a buyer a tap that
63
+ * buys them nothing, but here the tap is the buyer's consent to LEAVE, and
64
+ * taking it for them would redirect a checkout the moment it rendered.
65
+ */
@@ -0,0 +1,182 @@
1
+ import { Box } from "@mui/material";
2
+ import type { JSX, ReactNode } from "react";
3
+
4
+ import { useCheckoutCopy } from "./copy-context";
5
+ import { CheckIcon, ContentCopyIcon, CreditCardIcon, QrCodeIcon, ScheduleIcon } from "./icons";
6
+ import { Emphasized, PIX_WIDE, PixQr } from "./pix-parts";
7
+ import type { PixAfterCopyCopy } from "./screens-copy";
8
+ import type { PixCharge } from "./types";
9
+ import { useCheckoutComponents } from "./ui";
10
+
11
+ /**
12
+ * The PIX pane after a copy (the 2026-10-06 Pix redesign): the buyer has left
13
+ * for their bank app and comes back to a screen that says what happens next —
14
+ * a three-step checklist with the one they are on in bold — instead of the
15
+ * code they already have.
16
+ *
17
+ * Every Pix gets it; whether the BANK or the STORE confirms changes only the
18
+ * words (`PixAfterCopyCopy`). Wide, a smaller QR stays on the right for a
19
+ * buyer who would rather scan after all; narrow, "Ver QR code" goes back to
20
+ * the pane on its QR tab.
21
+ */
22
+
23
+ type StepState = "done" | "current" | "pending";
24
+
25
+ function StepMark({ state, index }: { state: StepState; index: number }): JSX.Element {
26
+ const size = { width: 26, height: 26, [PIX_WIDE]: { width: 28, height: 28 } };
27
+ if (state === "done") {
28
+ return (
29
+ <Box sx={{ ...size, flex: "none", borderRadius: "50%", bgcolor: "success.main", color: "success.contrastText", display: "flex", alignItems: "center", justifyContent: "center" }}>
30
+ <CheckIcon sx={{ fontSize: 16 }} />
31
+ </Box>
32
+ );
33
+ }
34
+ return (
35
+ <Box
36
+ sx={{
37
+ ...size,
38
+ flex: "none",
39
+ boxSizing: "border-box",
40
+ borderRadius: "50%",
41
+ border: "2px solid",
42
+ borderColor: state === "current" ? "primary.main" : "divider",
43
+ color: state === "current" ? "primary.main" : "text.secondary",
44
+ fontSize: "0.75rem",
45
+ fontWeight: 700,
46
+ display: "flex",
47
+ alignItems: "center",
48
+ justifyContent: "center",
49
+ }}
50
+ >
51
+ {index}
52
+ </Box>
53
+ );
54
+ }
55
+
56
+ function Step({ state, index, children }: { state: StepState; index: number; children: ReactNode }): JSX.Element {
57
+ const { Text } = useCheckoutComponents();
58
+ return (
59
+ <Box component="li" data-testid={`pix-after-copy-step-${index}`} data-state={state} sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
60
+ <StepMark state={state} index={index} />
61
+ <Text variant="body" size="sm" weight={state === "current" ? "bold" : undefined} color={state === "current" ? undefined : "secondary"} as="span">
62
+ {children}
63
+ </Text>
64
+ </Box>
65
+ );
66
+ }
67
+
68
+ /** The checklist. Step 2's wording follows the layout (an app, or internet banking too). */
69
+ function Checklist({ words, deadline }: { words: PixAfterCopyCopy; deadline: string }): JSX.Element {
70
+ return (
71
+ <Box
72
+ component="ol"
73
+ sx={{ m: 0, p: 2, listStyle: "none", border: "1px solid", borderColor: "divider", borderRadius: 1.5, display: "flex", flexDirection: "column", gap: 1.75, [PIX_WIDE]: { py: 2.25, px: 2.5, gap: 2 } }}
74
+ >
75
+ <Step state="done" index={1}>{words.stepCopied}</Step>
76
+ <Step state="current" index={2}>
77
+ <Box component="span" sx={{ [PIX_WIDE]: { display: "none" } }}>{words.stepPay(false)}</Box>
78
+ <Box component="span" sx={{ display: "none", [PIX_WIDE]: { display: "inline" } }}>{words.stepPay(true)}</Box>
79
+ </Step>
80
+ <Step state="pending" index={3}>{words.stepConfirm(deadline)}</Step>
81
+ </Box>
82
+ );
83
+ }
84
+
85
+ /** The clock, the title and the sentence: a centred stack narrow, a row wide. */
86
+ function Heading({ words, totalLabel }: { words: PixAfterCopyCopy; totalLabel: string }): JSX.Element {
87
+ const { Text } = useCheckoutComponents();
88
+ return (
89
+ <Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1.25, textAlign: "center", [PIX_WIDE]: { flexDirection: "row", gap: 2, textAlign: "left" } }}>
90
+ <Box sx={{ flex: "none", width: 64, height: 64, borderRadius: "50%", bgcolor: "action.selected", color: "primary.main", display: "flex", alignItems: "center", justifyContent: "center", [PIX_WIDE]: { width: 56, height: 56 } }}>
91
+ <ScheduleIcon sx={{ fontSize: 30 }} />
92
+ </Box>
93
+ <Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
94
+ {/* The screen's title outranks the checklist: 22px, 28px wide (the prototype's scale). */}
95
+ <Box sx={{ "& > *": { fontSize: "1.375rem", lineHeight: 1.25 }, [PIX_WIDE]: { "& > *": { fontSize: "1.75rem" } } }}>
96
+ <Text variant="heading" size="md" weight="bold" as="h2" style={{ margin: 0 }} data-testid="pix-after-copy-title">
97
+ {words.title}
98
+ </Text>
99
+ </Box>
100
+ <Text variant="body" size="sm" color="secondary" as="p" style={{ margin: 0 }}>
101
+ <Box component="span" sx={{ [PIX_WIDE]: { display: "none" } }}>{words.body(totalLabel, false)}</Box>
102
+ <Box component="span" sx={{ display: "none", [PIX_WIDE]: { display: "inline" } }}>
103
+ <Emphasized text={words.body(totalLabel, true)} />
104
+ </Box>
105
+ </Text>
106
+ </Box>
107
+ </Box>
108
+ );
109
+ }
110
+
111
+ interface PixAfterCopyProps {
112
+ pix: PixCharge;
113
+ words: PixAfterCopyCopy;
114
+ totalLabel: string;
115
+ deadline: string;
116
+ justCopied: boolean;
117
+ onCopyAgain: () => void;
118
+ onShowQr: () => void;
119
+ onPreferCard?: () => void;
120
+ /** The poll's line ("Verificando…", or the stalled panel that replaces it). */
121
+ footer: ReactNode;
122
+ }
123
+
124
+ /** The narrow layout's two secondaries side by side; wide, the card link joins the row. */
125
+ function Actions({ justCopied, onCopyAgain, onShowQr, onPreferCard }: Pick<PixAfterCopyProps, "justCopied" | "onCopyAgain" | "onShowQr" | "onPreferCard">): JSX.Element {
126
+ const { Button } = useCheckoutComponents();
127
+ const copy = useCheckoutCopy().screens.pix;
128
+ return (
129
+ <Box sx={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: 1.25, "& button": { minHeight: 48, height: "100%" }, [PIX_WIDE]: { display: "flex", flexWrap: "wrap", alignItems: "center", gap: 1.5, "& button": { height: "auto" } } }}>
130
+ {/* Narrow, a grid cell; wide, its own width beside the link (the prototype's row). */}
131
+ <Box sx={{ minWidth: 0, [PIX_WIDE]: { flex: "none", "& > *": { width: "auto" } } }}>
132
+ <Button variant="outline" color="neutral" size="md" fullWidth icon={<ContentCopyIcon fontSize="small" />} onClick={onCopyAgain} dataTestId="pix-copy-again">
133
+ {justCopied ? copy.copiedAction : copy.copyAgainAction}
134
+ </Button>
135
+ </Box>
136
+ <Box sx={{ display: "contents", [PIX_WIDE]: { display: "none" } }}>
137
+ <Button variant="outline" color="neutral" size="md" fullWidth icon={<QrCodeIcon fontSize="small" />} onClick={onShowQr} dataTestId="pix-show-qr">
138
+ {copy.showQrAction}
139
+ </Button>
140
+ </Box>
141
+ {onPreferCard ? (
142
+ <Box sx={{ display: "none", [PIX_WIDE]: { display: "block" } }}>
143
+ <Button variant="text" color="primary" size="md" icon={<CreditCardIcon fontSize="small" />} onClick={onPreferCard} dataTestId="pix-prefer-card-wide">
144
+ {copy.preferCardAction}
145
+ </Button>
146
+ </Box>
147
+ ) : null}
148
+ </Box>
149
+ );
150
+ }
151
+
152
+ export function PixAfterCopy(props: PixAfterCopyProps): JSX.Element {
153
+ const { Button, Text } = useCheckoutComponents();
154
+ const copy = useCheckoutCopy().screens.pix;
155
+ const { pix, words, totalLabel, deadline, onPreferCard, footer } = props;
156
+ return (
157
+ <Box data-testid="pix-after-copy" sx={{ display: "flex", flexWrap: "wrap", gap: 4, alignItems: "flex-start" }}>
158
+ <Box sx={{ flex: "999 1 420px", minWidth: 0, display: "flex", flexDirection: "column", gap: 2.5 }}>
159
+ <Heading words={words} totalLabel={totalLabel} />
160
+ <Checklist words={words} deadline={deadline} />
161
+ <Actions {...props} />
162
+ {footer}
163
+ {onPreferCard ? (
164
+ <Box sx={{ display: "flex", justifyContent: "center", [PIX_WIDE]: { display: "none" } }}>
165
+ <Button variant="text" color="primary" size="md" icon={<CreditCardIcon fontSize="small" />} onClick={onPreferCard} dataTestId="pix-prefer-card">
166
+ {copy.preferCardAction}
167
+ </Button>
168
+ </Box>
169
+ ) : null}
170
+ </Box>
171
+ <Box sx={{ display: "none", [PIX_WIDE]: { display: "flex" }, flex: "1 1 240px", minWidth: 0, flexDirection: "column", alignItems: "center", gap: 1.5, p: 2.5, borderRadius: 1.75, border: "1px solid", borderColor: "divider", bgcolor: "action.hover" }}>
172
+ <Text variant="body" size="xs" weight="bold" color="secondary" as="p" style={{ margin: 0, textTransform: "uppercase", letterSpacing: "0.04em" }}>
173
+ {copy.notPaidYet}
174
+ </Text>
175
+ <PixQr payload={pix.copyPaste} size={168} alt={copy.qrAlt} />
176
+ <Text variant="body" size="xs" color="secondary" as="p" style={{ margin: 0, textAlign: "center" }}>
177
+ {copy.scanFromPhone}
178
+ </Text>
179
+ </Box>
180
+ </Box>
181
+ );
182
+ }