@12-apps/payments-frontend 3.39.0 → 3.41.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.
@@ -0,0 +1,96 @@
1
+ import { useEffect } from "react";
2
+
3
+ import { NEW_CARD } from "../../../card";
4
+ import { deferredCopy } from "../pix-parts";
5
+
6
+ import { bankFlowOs } from "./bank-apps";
7
+ import { usePaymentListMode, type PaymentListMode } from "./context";
8
+ import type { ListState } from "./use-list-state";
9
+ import type { CheckoutPaymentList, PixBankId } from "./types";
10
+
11
+ /**
12
+ * What the list does with the host's memory of the buyer (FUT-3611, FUT-3612):
13
+ * the bank a Pix opens in, and the charge "Pedir em 1 toque" armed.
14
+ */
15
+
16
+ /** How long after the step opens an armed charge may still start by itself. */
17
+ export const ARMED_WINDOW_MS = 15_000;
18
+
19
+ /** The bank flow's remembered bank, on a phone — what the Pix button opens. */
20
+ function rememberedBank(list: CheckoutPaymentList): PixBankId | null {
21
+ const bank = list.pixBank;
22
+ if (!bank?.remembered || !bankFlowOs(bank.phone)) return null;
23
+ return bank.remembered;
24
+ }
25
+
26
+ /**
27
+ * Pix with the host's bank flow (FUT-3612): the clipboard is opened HERE,
28
+ * inside the tap, for a code the server has not answered yet — the Pix face
29
+ * writes it the moment it arrives, then opens the remembered bank (or, for
30
+ * "Abrir outro banco", shows the six).
31
+ */
32
+ export function armPixCopy(list: CheckoutPaymentList, mode: PaymentListMode, pick: boolean): void {
33
+ if (!list.pixBank) return;
34
+ mode.pixIntent.current?.copy.fail();
35
+ const os = bankFlowOs(list.pixBank.phone);
36
+ mode.pixIntent.current = { copy: deferredCopy(), bank: os && !pick ? list.pixBank.remembered : null, pick };
37
+ }
38
+
39
+ function pixChosen(state: ListState): boolean {
40
+ return state.choice?.kind === "online" && state.choice.method === "PIX";
41
+ }
42
+
43
+ /** Pix with the bank flow: "Gerar e copiar o Pix", or "Gerar, copiar e abrir o Nubank". */
44
+ export function pixBankLabel(list: CheckoutPaymentList, state: ListState): string | null {
45
+ const bank = list.pixBank;
46
+ if (!bank || !pixChosen(state)) return null;
47
+ const remembered = rememberedBank(list);
48
+ return remembered ? bank.copy.generateCopyAndOpen(bank.copy.names[remembered]) : bank.copy.generateAndCopy;
49
+ }
50
+
51
+ /** "Abrir outro banco" under the button: Pix chosen, a bank remembered, a phone, and the button free to act. */
52
+ export function otherBankShown(list: CheckoutPaymentList, state: ListState, free: boolean): boolean {
53
+ return free && pixChosen(state) && rememberedBank(list) !== null;
54
+ }
55
+
56
+ /** A saved card — not the fields — is what the card choice will charge. */
57
+ function savedCardChosen(state: ListState): boolean {
58
+ const { choice, card } = state;
59
+ if (choice?.kind !== "online" || choice.method !== "CARD") return false;
60
+ return card.selection !== null && card.selection !== NEW_CARD;
61
+ }
62
+
63
+ /**
64
+ * The armed charge may start now: the host armed it, the saved card the
65
+ * consent named is the one chosen, and nothing (`free`) holds the button.
66
+ */
67
+ export function armedReady(list: CheckoutPaymentList, state: ListState, free: boolean): boolean {
68
+ if (!list.armedCharge || !free || !savedCardChosen(state)) return false;
69
+ return list.armedCardId === undefined || state.card.selection === list.armedCardId;
70
+ }
71
+
72
+ /**
73
+ * "Pedir em 1 toque" opened this checkout (FUT-3611): start the card's bar by
74
+ * itself, ONCE per step — as soon as {@link armedReady} holds, within
75
+ * {@link ARMED_WINDOW_MS}. Anything after Cancelar never charges by itself.
76
+ */
77
+ export function useArmedCharge(ready: boolean, act: () => void): void {
78
+ const mode = usePaymentListMode();
79
+ useEffect(() => {
80
+ const armed = mode?.armed.current;
81
+ if (!ready || !armed || armed.spent) return;
82
+ // Spent either way: fired now, or too late to be the tap's own charge.
83
+ armed.spent = true;
84
+ if (Date.now() - armed.since <= ARMED_WINDOW_MS) act();
85
+ }, [ready, act, mode]);
86
+ }
87
+
88
+ /** A Pix press whose code never came (the raise was refused) gives the clipboard up. */
89
+ export function useDropRefusedCopy(createError: unknown): void {
90
+ const mode = usePaymentListMode();
91
+ useEffect(() => {
92
+ if (!createError || !mode?.pixIntent.current) return;
93
+ mode.pixIntent.current.copy.fail();
94
+ mode.pixIntent.current = null;
95
+ }, [createError, mode]);
96
+ }
@@ -1,5 +1,5 @@
1
1
  import { Box, useTheme } from "@mui/material";
2
- import type { JSX, KeyboardEvent, MouseEvent, ReactNode } from "react";
2
+ import { useEffect, useRef, useState, type JSX, type KeyboardEvent, type MouseEvent, type ReactNode } from "react";
3
3
 
4
4
  import { usePaymentRefresh, usePaymentStale } from "../cart-changed";
5
5
  import { useCheckoutCopy } from "../copy-context";
@@ -9,8 +9,11 @@ import type { PixAfterCopyCopy } from "../screens-copy";
9
9
  import type { CheckoutOrder, PixCharge } from "../types";
10
10
  import { useCheckoutComponents } from "../ui";
11
11
 
12
+ import { bankFlowOs, type PhoneOs } from "./bank-apps";
12
13
  import type { PaymentListMode } from "./context";
13
14
  import { ListBar, ActionButton, WaitingLine } from "./list-bar";
15
+ import { PixBanks, usePixBankState, type PixBankState } from "./pix-banks";
16
+ import type { CheckoutPixBank, PixBankId } from "./types";
14
17
 
15
18
  /**
16
19
  * The Pix, in the one list (FUT-3592 #3): "Gerar Pix" already created the
@@ -146,6 +149,42 @@ interface ListPixFaceProps {
146
149
  onPreferCard?: () => void;
147
150
  /** The poll's stalled panel, when it has one. */
148
151
  footer: ReactNode;
152
+ /** The clipboard alone, saying whether it took the code — the bank flow's copy. */
153
+ writeClipboard: () => Promise<boolean>;
154
+ setAfterCopy: (afterCopy: boolean) => void;
155
+ }
156
+
157
+ /** A phone with a bank remembered: the copy opens it too, and keeps the way to another (FUT-3612). */
158
+ function rememberedOnPhone(flow: BankFlow | null): PixBankId | null {
159
+ return flow?.os ? flow.state.chosen : null;
160
+ }
161
+
162
+ /** "Copiar e abrir no Nubank" with a remembered bank on a phone, else the plain copy. */
163
+ function copyLabel(plain: string, flow: BankFlow | null): string {
164
+ const remembered = rememberedOnPhone(flow);
165
+ return remembered && flow ? flow.bank.copy.copyAndOpen(flow.bank.copy.names[remembered]) : plain;
166
+ }
167
+
168
+ /** "Abrir outro banco", when a remembered bank on a phone is what the copy opens. */
169
+ function otherBankLabel(flow: BankFlow | null): string | null {
170
+ return flow && rememberedOnPhone(flow) ? flow.bank.copy.openOther : null;
171
+ }
172
+
173
+ /** The bar's one control: held, "Atualizar pagamento", the wait after the copy, or the copy. */
174
+ function PixBarAction({ mode, afterCopy, onCopy, copyLabel }: {
175
+ mode: PaymentListMode;
176
+ afterCopy: boolean;
177
+ onCopy: () => void;
178
+ copyLabel: string;
179
+ }): JSX.Element {
180
+ const stale = usePaymentStale();
181
+ const refresh = usePaymentRefresh();
182
+ const refreshLabel = useCheckoutCopy().screens.cartChanged.refreshAction;
183
+ const { hold } = mode.list;
184
+ if (hold) return <ActionButton label={hold.action} total={null} disabled onClick={() => undefined} />;
185
+ if (stale && refresh) return <ActionButton label={refreshLabel} total={null} disabled={false} onClick={refresh} />;
186
+ if (afterCopy) return <WaitingLine text={mode.list.copy.pixFace.waiting} />;
187
+ return <ActionButton label={copyLabel} total={null} disabled={stale} onClick={onCopy} />;
149
188
  }
150
189
 
151
190
  /**
@@ -153,30 +192,126 @@ interface ListPixFaceProps {
153
192
  * code the cart has outgrown — "Atualizar pagamento" (FUT-3535, E20), then
154
193
  * the copy before it and the wait after it.
155
194
  */
156
- function ListPixBar({ mode, afterCopy, onCopy }: { mode: PaymentListMode; afterCopy: boolean; onCopy: () => void }): JSX.Element {
195
+ function ListPixBar({ mode, afterCopy, onCopy, bankFlow, onOtherBank }: {
196
+ mode: PaymentListMode;
197
+ afterCopy: boolean;
198
+ onCopy: () => void;
199
+ bankFlow: BankFlow | null;
200
+ onOtherBank: () => void;
201
+ }): JSX.Element {
157
202
  const copy = mode.list.copy.pixFace;
158
203
  const stale = usePaymentStale();
159
- const refresh = usePaymentRefresh();
160
- const refreshLabel = useCheckoutCopy().screens.cartChanged.refreshAction;
161
204
  const { hold } = mode.list;
162
- const action = (): JSX.Element => {
163
- if (hold) return <ActionButton label={hold.action} total={null} disabled onClick={() => undefined} />;
164
- if (stale && refresh) return <ActionButton label={refreshLabel} total={null} disabled={false} onClick={refresh} />;
165
- if (afterCopy) return <WaitingLine text={copy.waiting} />;
166
- return <ActionButton label={copy.copyCode} total={null} disabled={stale} onClick={onCopy} />;
167
- };
205
+ const settled = !hold && !stale;
206
+ const other = settled && !afterCopy ? otherBankLabel(bankFlow) : null;
168
207
  return (
169
- <ListBar summary={mode.list.summary} reason={hold?.reason ?? null} live={afterCopy && !hold && !stale ? copy.waiting : null}>
170
- {action()}
208
+ <ListBar summary={mode.list.summary} reason={hold?.reason ?? null} live={afterCopy && settled ? copy.waiting : null}>
209
+ <PixBarAction mode={mode} afterCopy={afterCopy} onCopy={onCopy} copyLabel={copyLabel(copy.copyCode, bankFlow)} />
210
+ {other ? <OtherBankLink label={other} onClick={onOtherBank} /> : null}
171
211
  </ListBar>
172
212
  );
173
213
  }
174
214
 
215
+ /** "Abrir outro banco" — a centred text link under the bar's button (FUT-3612). */
216
+ export function OtherBankLink({ label, onClick }: { label: string; onClick: () => void }): JSX.Element {
217
+ const { Button } = useCheckoutComponents();
218
+ return (
219
+ <Box sx={{ display: "flex", justifyContent: "center", "& button": { textDecoration: "underline", minHeight: (t) => t.spacing(5.5) } }}>
220
+ <Button variant="text" color="primary" size="sm" onClick={onClick} dataTestId="pix-open-other-bank">
221
+ {label}
222
+ </Button>
223
+ </Box>
224
+ );
225
+ }
226
+
227
+ /** The Pix-to-bank flow, when the host offers it (FUT-3612). */
228
+ interface BankFlow {
229
+ bank: CheckoutPixBank;
230
+ /** `null` on a computer: copy-and-say, no banks. */
231
+ os: PhoneOs | null;
232
+ state: PixBankState;
233
+ }
234
+
235
+ /** The toast after a copy: which bank opens, or that one is to be chosen. */
236
+ function copiedSentence(flow: BankFlow, opening: PixBankId | null): string {
237
+ const { copy } = flow.bank;
238
+ if (!flow.os) return copy.copied;
239
+ return opening ? copy.copiedOpening(copy.names[opening]) : copy.copiedPick;
240
+ }
241
+
242
+ /** A copy the clipboard took: the after-copy face, the grid when asked for, the toast, and the bank opened. */
243
+ function landCopy(at: { mode: PaymentListMode; flow: BankFlow; ports: CopyPorts; stale: boolean }, opening: PixBankId | null, grid: boolean): void {
244
+ const { mode, flow, ports } = at;
245
+ ports.setAfterCopy(true);
246
+ if (grid) flow.state.showGrid();
247
+ const target = flow.os ? opening : null;
248
+ mode.list.notify?.(copiedSentence(flow, target));
249
+ if (target && !at.stale) flow.state.open(target, false);
250
+ }
251
+
252
+ /**
253
+ * Every copy on the bank flow ends the same way: the after-copy face, the
254
+ * toast, and the bank opened — the one chosen, or none when the grid is asked
255
+ * for. A copy the clipboard refused does none of it (the code stays to copy by
256
+ * hand).
257
+ */
258
+ function useBankCopies(mode: PaymentListMode, flow: BankFlow | null, pix: PixCharge, ports: CopyPorts) {
259
+ const stale = usePaymentStale();
260
+ const live = useRef(true);
261
+ useEffect(() => {
262
+ live.current = true;
263
+ return () => {
264
+ live.current = false;
265
+ };
266
+ }, []);
267
+ const land = (ok: boolean, grid: boolean, target: PixBankId | null): void => {
268
+ if (ok && live.current && flow) landCopy({ mode, flow, ports, stale }, grid ? null : target, grid);
269
+ };
270
+ // The bar's press already opened the clipboard, waiting for this code.
271
+ useEffect(() => {
272
+ const intent = mode.pixIntent.current;
273
+ if (!intent) return;
274
+ mode.pixIntent.current = null;
275
+ intent.copy.resolve(pix.copyPaste);
276
+ void intent.copy.done.then((ok) => land(ok, intent.pick, intent.pick ? null : intent.bank));
277
+ // Once, as the code arrives.
278
+ }, []);
279
+ const copyThen = (grid: boolean) => () =>
280
+ void ports.writeClipboard().then((ok) => land(ok, grid, flow?.state.chosen ?? null));
281
+ return { copy: copyThen(false), other: copyThen(true) };
282
+ }
283
+
284
+ interface CopyPorts {
285
+ writeClipboard: () => Promise<boolean>;
286
+ setAfterCopy: (afterCopy: boolean) => void;
287
+ }
288
+
289
+ /** The bank flow's state, or `null` when the host does not offer it. */
290
+ function useBankFlow(mode: PaymentListMode): BankFlow | null {
291
+ const bank = mode.list.pixBank;
292
+ const [os] = useState(() => bankFlowOs(bank?.phone));
293
+ const stale = usePaymentStale();
294
+ const startOnGrid = mode.pixIntent.current?.pick ?? false;
295
+ const state = usePixBankState(bank?.remembered ?? null, bank?.onPick ?? noPick, os, stale, startOnGrid);
296
+ return bank ? { bank, os, state } : null;
297
+ }
298
+
299
+ function noPick(): void {
300
+ // No bank flow: nothing to remember.
301
+ }
302
+
175
303
  /** Both faces of the list's Pix, and the bar under them. */
176
304
  export function ListPixFace(props: ListPixFaceProps & { mode: PaymentListMode }): JSX.Element {
177
305
  const { Text } = useCheckoutComponents();
178
306
  const { mode, order, pix, afterCopy } = props;
179
307
  const copy = mode.list.copy.pixFace;
308
+ const flow = useBankFlow(mode);
309
+ const copies = useBankCopies(mode, flow, pix, props);
310
+ const stale = usePaymentStale();
311
+ // On a phone the bank flow takes the copy: "Copiar e abrir o Nubank", the
312
+ // toast, the app. Without it — or on a computer — the copy is today's.
313
+ const onCopy = flow ? copies.copy : props.onCopy;
314
+ const banks = flow?.os ? { node: <PixBanks bank={flow.bank} state={flow.state} stale={stale} />, replacesChecklist: flow.state.chosen === null } : undefined;
180
315
  return (
181
316
  <Box data-testid="list-pix" sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
182
317
  <Text variant="body" size="md" weight="bold" as="p" style={{ margin: 0 }} data-testid="list-pix-title">
@@ -189,18 +324,19 @@ export function ListPixFace(props: ListPixFaceProps & { mode: PaymentListMode })
189
324
  totalLabel={mode.list.amountDueLabel ?? order.totalLabel}
190
325
  deadline={props.time}
191
326
  justCopied={props.justCopied}
192
- onCopyAgain={props.onCopyAgain}
327
+ onCopyAgain={flow ? copies.copy : props.onCopyAgain}
193
328
  onShowQr={() => {
194
329
  mode.setQrOpen(true);
195
330
  props.onShowQr();
196
331
  }}
197
332
  onPreferCard={props.onPreferCard}
198
333
  footer={props.footer}
334
+ banks={banks}
199
335
  />
200
336
  ) : (
201
- <BeforeCopy mode={mode} pix={pix} time={props.time} onCopy={props.onCopy} onChangeMethod={mode.onLeavePix} footer={props.footer} />
337
+ <BeforeCopy mode={mode} pix={pix} time={props.time} onCopy={onCopy} onChangeMethod={mode.onLeavePix} footer={props.footer} />
202
338
  )}
203
- <ListPixBar mode={mode} afterCopy={afterCopy} onCopy={props.onCopy} />
339
+ <ListPixBar mode={mode} afterCopy={afterCopy} onCopy={onCopy} bankFlow={flow} onOtherBank={copies.other} />
204
340
  </Box>
205
341
  );
206
342
  }
@@ -130,6 +130,7 @@ export function PaymentListStep(props: PaymentListStepProps): JSX.Element {
130
130
  fresh: c.freshInstrument,
131
131
  arrivedWith: props.defaultBuyer,
132
132
  taxIdOnFile: props.taxIdOnFile,
133
+ lastMethod: list.lastMethod,
133
134
  onChoiceChange: list.onChoiceChange,
134
135
  });
135
136
  const charge = useListCharge({ ...c, cartTotal: displayTotals(props.totalOverride, props.cart).label, dueCents: list.totalCents });
@@ -0,0 +1,178 @@
1
+ import { Box } from "@mui/material";
2
+ import { useCallback, useEffect, useRef, useState, type JSX } from "react";
3
+
4
+ import { Emphasized } from "../pix-parts";
5
+ import { useCheckoutComponents } from "../ui";
6
+
7
+ import { BankLogo } from "./bank-logos";
8
+ import { openBankApp, PIX_BANKS, type PhoneOs } from "./bank-apps";
9
+ import type { CheckoutPixBank, PixBankId } from "./types";
10
+
11
+ /**
12
+ * The after-copy face's way to the bank app (FUT-3612), on a phone: with no
13
+ * bank chosen, the six as a 2×3 grid of large buttons under "Abra seu banco e
14
+ * cole em Pix copia e cola"; with one, "Abrindo o **Nubank**…" and "Outro
15
+ * banco" in one row — or, when the app did not open, the sentence that says so
16
+ * with "Tentar de novo".
17
+ */
18
+
19
+ export type BankStatus = "opening" | "failed";
20
+
21
+ export interface PixBankState {
22
+ /** The bank this face opens, or `null` while the grid is shown. */
23
+ chosen: PixBankId | null;
24
+ status: BankStatus | null;
25
+ /** Open `bank` (a pick from the grid remembers it). */
26
+ open: (bank: PixBankId, remember: boolean) => void;
27
+ /** "Outro banco": the grid again. The stored bank is replaced only by the next pick. */
28
+ showGrid: () => void;
29
+ }
30
+
31
+ /**
32
+ * The bank face's state. It starts on the remembered bank — unless the press
33
+ * asked for the grid ("Abrir outro banco") — and never opens anything while
34
+ * the code is veiled (FUT-3535: an app opened then would paste the old amount).
35
+ */
36
+ export function usePixBankState(
37
+ remembered: PixBankId | null,
38
+ onPick: (bank: PixBankId) => void,
39
+ os: PhoneOs | null,
40
+ stale: boolean,
41
+ startOnGrid: boolean,
42
+ ): PixBankState {
43
+ const [chosen, setChosen] = useState<PixBankId | null>(startOnGrid ? null : remembered);
44
+ const [status, setStatus] = useState<BankStatus | null>(null);
45
+ const live = useRef(true);
46
+ useEffect(() => {
47
+ // Set in the body too: a remount (StrictMode's, or a parent's) runs the
48
+ // cleanup first, and a flag left false would swallow every "não abriu".
49
+ live.current = true;
50
+ return () => {
51
+ live.current = false;
52
+ };
53
+ }, []);
54
+ const open = useCallback(
55
+ (next: PixBankId, remember: boolean) => {
56
+ if (stale || !os) return;
57
+ setChosen(next);
58
+ setStatus("opening");
59
+ if (remember) onPick(next);
60
+ void openBankApp(next, os).then((opened) => {
61
+ if (live.current && !opened) setStatus("failed");
62
+ });
63
+ },
64
+ [os, onPick, stale],
65
+ );
66
+ const showGrid = useCallback(() => {
67
+ setChosen(null);
68
+ setStatus(null);
69
+ }, []);
70
+ return { chosen, status, open, showGrid };
71
+ }
72
+
73
+ function OtherLink({ label, onClick, testId }: { label: string; onClick: () => void; testId: string }): JSX.Element {
74
+ const { Button } = useCheckoutComponents();
75
+ return (
76
+ <Box sx={{ ml: "auto", flex: "none", "& button": { textDecoration: "underline", minHeight: (t) => t.spacing(5.5), px: 0.5 } }}>
77
+ <Button variant="text" color="primary" size="sm" onClick={onClick} dataTestId={testId}>
78
+ {label}
79
+ </Button>
80
+ </Box>
81
+ );
82
+ }
83
+
84
+ /** "Abrindo o **Nubank**…" · "Outro banco" — or "Não conseguimos abrir…" · "Tentar de novo" · "Outro banco". */
85
+ function Opening({ bank, state, chosen }: { bank: CheckoutPixBank; state: PixBankState; chosen: PixBankId }): JSX.Element {
86
+ const { Button, Text } = useCheckoutComponents();
87
+ const name = bank.copy.names[chosen];
88
+ const failed = state.status === "failed";
89
+ return (
90
+ <Box data-testid="pix-bank-opening" data-status={state.status ?? "opening"} sx={{ display: "flex", alignItems: "center", flexWrap: "wrap", gap: 1 }}>
91
+ <Box component="span" role={failed ? "alert" : "status"} sx={{ minWidth: 0 }}>
92
+ <Text variant="body" size="sm" as="span">
93
+ {failed ? bank.copy.notOpened(name) : <Emphasized text={bank.copy.opening(name)} />}
94
+ </Text>
95
+ </Box>
96
+ {failed ? (
97
+ <Box sx={{ "& button": { minHeight: (t) => t.spacing(5.5) } }}>
98
+ <Button variant="outline" color="primary" size="sm" onClick={() => state.open(chosen, false)} dataTestId="pix-bank-retry">
99
+ {bank.copy.retry}
100
+ </Button>
101
+ </Box>
102
+ ) : null}
103
+ <OtherLink label={bank.copy.other} onClick={state.showGrid} testId="pix-bank-other" />
104
+ </Box>
105
+ );
106
+ }
107
+
108
+ /** One of the six: the bank's logo at 32px and its name, ≈56px tall. */
109
+ /** The grid's own width, never the window's: under it, a cell is too narrow for logo and name side by side. */
110
+ const NARROW_GRID = "@container pixbanks (max-width: 289.95px)";
111
+
112
+ function BankButton({ bank, id, onPick, disabled }: { bank: CheckoutPixBank; id: PixBankId; onPick: () => void; disabled: boolean }): JSX.Element {
113
+ const { Button } = useCheckoutComponents();
114
+ const name = bank.copy.names[id];
115
+ const label = bank.copy.open(name);
116
+ const lead = label.slice(0, label.lastIndexOf(name.name));
117
+ return (
118
+ <Box
119
+ sx={{
120
+ minWidth: 0,
121
+ "& button": {
122
+ width: "100%",
123
+ minHeight: (t) => t.spacing(7),
124
+ borderRadius: 1.5,
125
+ justifyContent: "flex-start",
126
+ textAlign: "left",
127
+ gap: 1.25,
128
+ px: 1.5,
129
+ color: "text.primary",
130
+ borderColor: "divider",
131
+ // A 320px phone gives the grid ~254px (each half ~123px): logo
132
+ // beside "Santander" no longer fits, so the logo goes above the name.
133
+ [NARROW_GRID]: {
134
+ flexDirection: "column",
135
+ justifyContent: "center",
136
+ textAlign: "center",
137
+ gap: 0.5,
138
+ px: 1,
139
+ py: 1,
140
+ "& .MuiButton-startIcon, & .MuiButton-start": { m: 0 },
141
+ },
142
+ },
143
+ "& button, & button *": { fontWeight: 600 },
144
+ }}
145
+ >
146
+ <Button variant="outline" color="neutral" size="md" fullWidth disabled={disabled} icon={<BankLogo bank={id} />} onClick={onPick} dataTestId={`pix-bank-${id}`}>
147
+ {/* The accessible name is "Abrir Nubank"; the eye reads the logo and "Nubank". */}
148
+ <Box component="span" sx={{ position: "absolute", width: "1px", height: "1px", overflow: "hidden", clip: "rect(0 0 0 0)", whiteSpace: "nowrap" }}>
149
+ {lead}
150
+ </Box>
151
+ {name.name}
152
+ </Button>
153
+ </Box>
154
+ );
155
+ }
156
+
157
+ function Grid({ bank, state, stale }: { bank: CheckoutPixBank; state: PixBankState; stale: boolean }): JSX.Element {
158
+ const { Text } = useCheckoutComponents();
159
+ return (
160
+ <Box data-testid="pix-bank-grid" sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
161
+ <Text variant="body" size="md" weight="semibold" as="p" style={{ margin: 0 }}>
162
+ {bank.copy.listTitle}
163
+ </Text>
164
+ <Box sx={{ containerType: "inline-size", containerName: "pixbanks" }}>
165
+ <Box sx={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: 1 }}>
166
+ {PIX_BANKS.map((id) => (
167
+ <BankButton key={id} bank={bank} id={id} disabled={stale} onPick={() => state.open(id, true)} />
168
+ ))}
169
+ </Box>
170
+ </Box>
171
+ </Box>
172
+ );
173
+ }
174
+
175
+ /** The grid, or the bank being opened. */
176
+ export function PixBanks({ bank, state, stale }: { bank: CheckoutPixBank; state: PixBankState; stale: boolean }): JSX.Element {
177
+ return state.chosen ? <Opening bank={bank} state={state} chosen={state.chosen} /> : <Grid bank={bank} state={state} stale={stale} />;
178
+ }
@@ -131,6 +131,97 @@ export interface CheckoutPaymentList {
131
131
  * the list returns to its rows.
132
132
  */
133
133
  amountDueLabel?: string;
134
+ /**
135
+ * The buyer's last ONLINE way to pay, as the host remembers it (FUT-3611) —
136
+ * chosen for them before they touch anything, when the store offers it: Pix
137
+ * as Pix; a card as the saved card used last. Absent or `null` ⇒ the saved
138
+ * card rule alone. A door tender is never chosen for the buyer.
139
+ */
140
+ lastMethod?: "PIX" | "CARD" | null;
141
+ /**
142
+ * "Pedir em 1 toque" opened this checkout (FUT-3611): the tap on the
143
+ * cardápio was the buyer's consent, so the card's 5-second bar starts by
144
+ * itself once the saved card is chosen and nothing holds the button — ONCE
145
+ * per step, and only within {@link ARMED_WINDOW_MS} of the step opening: a
146
+ * checkout that lands held (a field to fill) never charges by itself minutes
147
+ * later. Without a saved card it does nothing.
148
+ */
149
+ armedCharge?: boolean;
150
+ /**
151
+ * The saved card the host's consent named (the 1-tap offer's). Given, the
152
+ * armed charge fires only on THAT card; a different selection never charges
153
+ * by itself.
154
+ */
155
+ armedCardId?: string;
156
+ /**
157
+ * Host content under the 5-second bar's Cancelar while it counts — the
158
+ * storefront's "Cancelar e desativar a compra em 1 toque". It is handed the
159
+ * bar's own cancel, so a link there stops the charge before doing its own.
160
+ */
161
+ chargeFooter?: (cancel: () => void) => ReactNode;
162
+ /**
163
+ * Pix straight to the buyer's bank app (FUT-3612) — on a phone only: the
164
+ * button generates AND copies the code, a short message says so through
165
+ * `notify`, and the after-copy face offers the six banks (or opens the one
166
+ * remembered). Absent ⇒ today's Pix: "Gerar Pix", then a tap to copy.
167
+ */
168
+ pixBank?: CheckoutPixBank;
169
+ /** Says a short sentence in the host's own toast — "Código Pix copiado". */
170
+ notify?: (message: string) => void;
171
+ }
172
+
173
+ /** The six banks the Pix face offers to open, in the prototype's order. */
174
+ export type PixBankId = "nubank" | "itau" | "bradesco" | "bb" | "caixa" | "santander";
175
+
176
+ /** `CheckoutPaymentList.pixBank`. */
177
+ export interface CheckoutPixBank {
178
+ /** The bank the buyer picked last time, as the host stored it — or `null`. */
179
+ remembered: PixBankId | null;
180
+ /** A bank was picked: the host remembers it for next time (a new pick replaces it). */
181
+ onPick: (bank: PixBankId) => void;
182
+ /** The words; ship with the list's pack (`PIX_BANK_COPY`). */
183
+ copy: PixBankCopy;
184
+ /**
185
+ * Whether this device can open a bank app — a phone. Absent ⇒ read from the
186
+ * browser (Android or iOS). A computer keeps today's face and gets only the
187
+ * generate-and-copy.
188
+ */
189
+ phone?: boolean;
190
+ }
191
+
192
+ /** A bank's name and the article pt-BR puts before it ("o Nubank", "a Caixa"). */
193
+ export interface PixBankName {
194
+ name: string;
195
+ /** "Abrir o Nubank" / "Abrir a Caixa" — the article with its trailing space, or "" for none. */
196
+ the: string;
197
+ }
198
+
199
+ export interface PixBankCopy {
200
+ names: Record<PixBankId, PixBankName>;
201
+ /** The bar's button with no bank remembered — "Gerar e copiar o Pix". */
202
+ generateAndCopy: string;
203
+ /** With one remembered — "Gerar, copiar e abrir o Nubank". */
204
+ generateCopyAndOpen: (bank: PixBankName) => string;
205
+ /** The before-copy face's button with one remembered — "Copiar e abrir o Nubank". */
206
+ copyAndOpen: (bank: PixBankName) => string;
207
+ /** The link under the button — "Abrir outro banco". */
208
+ openOther: string;
209
+ /** Over the grid — "Abra seu banco e cole em Pix copia e cola". */
210
+ listTitle: string;
211
+ /** A bank button's accessible name — "Abrir Nubank". */
212
+ open: (bank: PixBankName) => string;
213
+ /** "Abrindo o **Nubank**…" — `**` marks the bold span. */
214
+ opening: (bank: PixBankName) => string;
215
+ /** "Outro banco". */
216
+ other: string;
217
+ /** "Não conseguimos abrir o Nubank. O app está instalado neste celular?" */
218
+ notOpened: (bank: PixBankName) => string;
219
+ /** "Tentar de novo". */
220
+ retry: string;
221
+ /** The toasts. */
222
+ copied: string;
223
+ copiedPick: string;
224
+ copiedOpening: (bank: PixBankName) => string;
134
225
  }
135
226
 
136
227
  /** The list's words — a pack per locale ships at `@12-apps/payments-frontend/locales`. */
@@ -59,6 +59,7 @@ function usePreselect(
59
59
  rows: readonly OnlineRow[],
60
60
  saved: readonly SavedCard[],
61
61
  fresh: boolean,
62
+ lastMethod: "PIX" | "CARD" | null,
62
63
  apply: (choice: CheckoutListChoice, selection: string | null) => void,
63
64
  touched: { current: boolean },
64
65
  ): void {
@@ -66,9 +67,9 @@ function usePreselect(
66
67
  useEffect(() => {
67
68
  if (done.current || touched.current || !loaded) return;
68
69
  done.current = true;
69
- const pick = preselection(rows, saved, fresh);
70
+ const pick = preselection(rows, saved, fresh, lastMethod);
70
71
  if (pick) apply(pick.choice, pick.selection);
71
- }, [loaded, rows, saved, fresh, apply, touched]);
72
+ }, [loaded, rows, saved, fresh, lastMethod, apply, touched]);
72
73
  }
73
74
 
74
75
  /**
@@ -96,6 +97,8 @@ export function useListState(input: {
96
97
  fresh: boolean;
97
98
  arrivedWith: BuyerInfo | undefined;
98
99
  taxIdOnFile: boolean;
100
+ /** The buyer's last online method, from the host (FUT-3611). */
101
+ lastMethod?: "PIX" | "CARD" | null;
99
102
  onChoiceChange?: (choice: CheckoutListChoice | null) => void;
100
103
  }): ListState {
101
104
  const { capability } = input;
@@ -112,7 +115,7 @@ export function useListState(input: {
112
115
  },
113
116
  [setSelection],
114
117
  );
115
- usePreselect(loaded, rows, cards, input.fresh, apply, touched);
118
+ usePreselect(loaded, rows, cards, input.fresh, input.lastMethod ?? null, apply, touched);
116
119
  const choose = useCallback(
117
120
  (next: CheckoutListChoice) => {
118
121
  touched.current = true;
@@ -123,6 +123,12 @@ interface PixAfterCopyProps {
123
123
  onPreferCard?: () => void;
124
124
  /** The poll's line ("Verificando…", or the stalled panel that replaces it). */
125
125
  footer: ReactNode;
126
+ /**
127
+ * The way to the bank app, on a phone (FUT-3612): drawn where the heading
128
+ * was — the toast already said the code was copied and the bar says what it
129
+ * waits for — and, while it is the six banks, in place of the checklist.
130
+ */
131
+ banks?: { node: ReactNode; replacesChecklist: boolean };
126
132
  }
127
133
 
128
134
  /** The narrow layout's two secondaries side by side; wide, the card link joins the row. */
@@ -164,8 +170,8 @@ export function PixAfterCopy(props: PixAfterCopyProps): JSX.Element {
164
170
  return (
165
171
  <Box data-testid="pix-after-copy" sx={{ display: "flex", flexWrap: "wrap", gap: 4, alignItems: "flex-start" }}>
166
172
  <Box sx={{ flex: "999 1 420px", minWidth: 0, display: "flex", flexDirection: "column", gap: 2.5 }}>
167
- <Heading words={words} totalLabel={totalLabel} />
168
- <Checklist words={words} deadline={deadline} />
173
+ {props.banks ? props.banks.node : <Heading words={words} totalLabel={totalLabel} />}
174
+ {props.banks?.replacesChecklist ? null : <Checklist words={words} deadline={deadline} />}
169
175
  <Actions {...props} />
170
176
  {footer}
171
177
  {onPreferCard ? (