@12-apps/payments-frontend 3.38.0 → 3.40.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.
Files changed (31) hide show
  1. package/package.json +1 -1
  2. package/src/card/types.ts +6 -0
  3. package/src/components/checkout/card-sources.ts +11 -2
  4. package/src/components/checkout/list/bank-apps.ts +113 -0
  5. package/src/components/checkout/list/bank-en-US.ts +26 -0
  6. package/src/components/checkout/list/bank-logos.tsx +35 -0
  7. package/src/components/checkout/list/bank-pt-BR.ts +26 -0
  8. package/src/components/checkout/list/charge-runners.tsx +22 -3
  9. package/src/components/checkout/list/choice-list.tsx +4 -1
  10. package/src/components/checkout/list/choice.ts +30 -5
  11. package/src/components/checkout/list/context.tsx +27 -3
  12. package/src/components/checkout/list/en-US.ts +2 -0
  13. package/src/components/checkout/list/list-face.tsx +92 -29
  14. package/src/components/checkout/list/list-memory.ts +96 -0
  15. package/src/components/checkout/list/list-pix.tsx +153 -17
  16. package/src/components/checkout/list/online-content.tsx +11 -4
  17. package/src/components/checkout/list/option-row.tsx +4 -2
  18. package/src/components/checkout/list/payment-list-step.tsx +2 -1
  19. package/src/components/checkout/list/pix-banks.tsx +178 -0
  20. package/src/components/checkout/list/pt-BR.ts +2 -0
  21. package/src/components/checkout/list/types.ts +126 -4
  22. package/src/components/checkout/list/use-capability.ts +15 -10
  23. package/src/components/checkout/list/use-list-charge.ts +37 -12
  24. package/src/components/checkout/list/use-list-state.ts +11 -8
  25. package/src/components/checkout/pix-after-copy.tsx +8 -2
  26. package/src/components/checkout/pix-parts.tsx +56 -0
  27. package/src/components/checkout/pix-view.tsx +2 -0
  28. package/src/components/checkout/transport.ts +3 -1
  29. package/src/components/checkout/types.ts +9 -9
  30. package/src/index.ts +1 -1
  31. package/src/locales.ts +12 -1
@@ -10,7 +10,11 @@ import type { BuyerContact, CheckoutProviderConfig } from "../types";
10
10
  import type { useCheckoutController } from "../use-checkout-controller";
11
11
  import { useCheckoutComponents } from "../ui";
12
12
 
13
+
13
14
  import { actionLabel, blockedReason, cardName } from "./choice";
15
+ import { usePaymentListMode, type PaymentListMode } from "./context";
16
+ import { armPixCopy, armedReady, otherBankShown, pixBankLabel, useArmedCharge, useDropRefusedCopy } from "./list-memory";
17
+ import { OtherBankLink } from "./list-pix";
14
18
  import { ChoiceList } from "./choice-list";
15
19
  import { CardRun, WalletRun, type ListChargeFailure } from "./charge-runners";
16
20
  import { ActionButton, ChargeBar, GooglePayAction, ListBar } from "./list-bar";
@@ -66,11 +70,20 @@ function saveAsked(props: ListFaceProps): void {
66
70
  saveBuyerContact?.({ name: c.buyer.name, phone: c.buyer.phone, taxId: c.buyer.taxId });
67
71
  }
68
72
 
73
+ /**
74
+ * What an online payment asks for: the host's amount due when a balance it
75
+ * holds pays part of the cart, else the cart's total (P3).
76
+ */
77
+ function onlineTotal(props: ListFaceProps): string {
78
+ return props.list.amountDueLabel ?? props.total;
79
+ }
80
+
69
81
  /** An online choice's press: the card goes through the bar, Pix and the hand-off raise at once. */
70
- function actOnline(props: ListFaceProps, method: CheckoutOnlineChoice): void {
82
+ function actOnline(props: ListFaceProps, method: CheckoutOnlineChoice, mode: PaymentListMode | null, pick = false): void {
71
83
  const { c, charge, cap } = props;
72
84
  saveAsked(props);
73
- if (method === "CARD") return charge.start("CARD", props.total);
85
+ if (method === "PIX" && mode) armPixCopy(props.list, mode, pick);
86
+ if (method === "CARD") return charge.start("CARD", onlineTotal(props));
74
87
  const raised = method === "PIX" ? "PIX" : handOffMethod(cap.offered);
75
88
  if (method === "HAND_OFF") props.onHandOff();
76
89
  c.setMethod(raised);
@@ -82,19 +95,20 @@ function actOnline(props: ListFaceProps, method: CheckoutOnlineChoice): void {
82
95
  * or a charge. A door option hands the press to the host; the card counts five
83
96
  * seconds first; everything else raises at once.
84
97
  */
85
- function useAct(props: ListFaceProps, clearFailure: () => void): () => void {
86
- return () => {
98
+ function useAct(props: ListFaceProps, clearFailure: () => void): (pick?: boolean) => void {
99
+ const mode = usePaymentListMode();
100
+ return (pick = false) => {
87
101
  const { choice } = props.state;
88
102
  if (!choice || inFlight(props)) return;
89
103
  if (choice.kind === "door") return chosenDoor(props)?.onSend();
90
104
  clearFailure();
91
- actOnline(props, choice.method);
105
+ actOnline(props, choice.method, mode, pick);
92
106
  };
93
107
  }
94
108
 
95
109
  /**
96
110
  * Google Pay's press, from Google's own button: the sheet opens HERE, inside
97
- * the tap, priced with the host's total — then the order is raised behind it.
111
+ * the tap, priced with the host's amount due — then the order is raised behind it.
98
112
  */
99
113
  function useGooglePayPress(props: ListFaceProps, clearFailure: () => void, sheet: { current: OpenSheet | null }): () => void {
100
114
  return () => {
@@ -113,7 +127,7 @@ function useGooglePayPress(props: ListFaceProps, clearFailure: () => void, sheet
113
127
  },
114
128
  );
115
129
  sheet.current = { answer, cents };
116
- charge.start("GOOGLE_PAY", props.total);
130
+ charge.start("GOOGLE_PAY", onlineTotal(props));
117
131
  };
118
132
  }
119
133
 
@@ -151,26 +165,35 @@ function cardCharging(charge: ListCharge): boolean {
151
165
  return charge.phase === "counting" || (charge.phase === "sending" && charge.kind === "CARD");
152
166
  }
153
167
 
154
- /** The bar's sentence: the amount frozen at the press, and the card it goes on. */
168
+ /**
169
+ * The bar's sentence: the amount frozen at the press, and the card it goes on
170
+ * — or, when the host's balance pays everything, no card at all.
171
+ */
155
172
  function chargingText(props: ListFaceProps): string {
156
- const { charge, list, state, total } = props;
173
+ const { charge, list, state } = props;
174
+ if (coveredByHost(list) && list.copy.chargingCovered) return list.copy.chargingCovered;
157
175
  const selected = state.card.saved.find((card) => card.id === state.card.selection);
158
- return list.copy.charging(charge.pressedTotal ?? total, selected ? cardName(list.copy, selected) : null);
176
+ return list.copy.charging(charge.pressedTotal ?? onlineTotal(props), selected ? cardName(list.copy, selected) : null);
159
177
  }
160
178
 
161
179
  /** The verb, the amount and the busy state of the button, for what is chosen. */
162
180
  function buttonFacts(props: ListFaceProps): { label: string; total: string; busy: boolean } {
163
181
  const { list, state } = props;
164
182
  const door = chosenDoor(props);
183
+ const online = state.choice?.kind === "online";
165
184
  return {
166
- label: list.hold ? list.hold.action : actionLabel(list.copy, state.choice, door, props.provider),
167
- total: door?.totalLabel ?? props.total,
185
+ label: list.hold ? list.hold.action : pixBankLabel(list, state) ?? actionLabel(list.copy, state.choice, door, props.provider),
186
+ total: door ? (door.totalLabel ?? props.total) : online ? onlineTotal(props) : props.total,
168
187
  busy: inFlight(props),
169
188
  };
170
189
  }
171
190
 
172
- /** Google Pay chosen while the host re-prices: the row stays, its button waits for the price. */
173
- function googlePayRepricing(props: ListFaceProps): boolean {
191
+ /**
192
+ * Google Pay chosen while the host re-prices (or for the one render before the
193
+ * list drops a choice nobody prices any more): a plain disabled button, never
194
+ * a spinner, with `copy.reasons.repricing` under it.
195
+ */
196
+ function googlePayUnpriced(props: ListFaceProps): boolean {
174
197
  const { choice } = props.state;
175
198
  return choice?.kind === "online" && choice.method === "GOOGLE_PAY" && !props.cap.googlePay;
176
199
  }
@@ -181,27 +204,58 @@ function googlePayLive(props: ListFaceProps, reason: string | null, busy: boolea
181
204
  return choice?.kind === "online" && choice.method === "GOOGLE_PAY" && props.cap.googlePay !== null && reason === null && !busy;
182
205
  }
183
206
 
207
+ /** The 5-second bar (or the charge going out), and the host's footer while it counts. */
208
+ function ChargingBar({ props, onCancel }: { props: ListFaceProps; onCancel: () => void }): JSX.Element {
209
+ const { charge, list } = props;
210
+ const counting = charge.phase === "counting";
211
+ const footer = counting ? list.chargeFooter?.(charge.cancel) : null;
212
+ return (
213
+ <>
214
+ <ChargeBar text={chargingText(props)} full={!counting} cancelLabel={list.copy.cancel} onCancel={onCancel} />
215
+ {footer ? <Box data-testid="list-charge-footer">{footer}</Box> : null}
216
+ </>
217
+ );
218
+ }
219
+
220
+ /** The button, and "Abrir outro banco" under it when a remembered bank would open. */
221
+ function PressButton({ props, act, reason, returnFocus }: {
222
+ props: ListFaceProps;
223
+ act: (pick?: boolean) => void;
224
+ reason: string | null;
225
+ returnFocus: boolean;
226
+ }): JSX.Element {
227
+ const { list, state } = props;
228
+ const { label, total, busy } = buttonFacts(props);
229
+ const other = list.pixBank && otherBankShown(list, state, reason === null && !busy) ? list.pixBank.copy.openOther : null;
230
+ return (
231
+ <>
232
+ <ActionButton label={label} total={total} disabled={reason !== null || busy} loading={busy} onClick={() => act()} focusOnMount={returnFocus} />
233
+ {other ? <OtherBankLink label={other} onClick={() => act(true)} /> : null}
234
+ </>
235
+ );
236
+ }
237
+
184
238
  /** The button, the 5-second bar, Google's own button, or nothing for a charge nobody can confirm yet. */
185
239
  function BarAction({ props, failure, act, onGooglePay, onCancel, reason, returnFocus }: {
186
240
  props: ListFaceProps;
187
241
  failure: ListChargeFailure | null;
188
- act: () => void;
242
+ act: (pick?: boolean) => void;
189
243
  onGooglePay: () => void;
190
244
  onCancel: () => void;
191
245
  reason: string | null;
192
246
  returnFocus: boolean;
193
247
  }): JSX.Element | null {
194
- const { charge, list, cap } = props;
195
- if (cardCharging(charge)) {
196
- return <ChargeBar text={chargingText(props)} full={charge.phase !== "counting"} cancelLabel={list.copy.cancel} onCancel={onCancel} />;
197
- }
248
+ const { charge, cap } = props;
249
+ if (cardCharging(charge)) return <ChargingBar props={props} onCancel={onCancel} />;
198
250
  // An UNRESOLVED charge may be holding the buyer's money: no pay control at
199
251
  // all under "não pague de novo" (FUT-563), as the card pane does today.
200
252
  if (failure?.code === UNRESOLVED_CODE) return null;
201
- const { label, total, busy } = buttonFacts(props);
202
- if (googlePayRepricing(props)) return <ActionButton label={label} total={total} disabled loading onClick={act} />;
203
- if (googlePayLive(props, reason, busy) && cap.googlePay) return <GooglePayAction client={cap.googlePay.client} onPress={onGooglePay} />;
204
- return <ActionButton label={label} total={total} disabled={reason !== null || busy} loading={busy} onClick={act} focusOnMount={returnFocus} />;
253
+ if (googlePayUnpriced(props)) {
254
+ const { label, total } = buttonFacts(props);
255
+ return <ActionButton label={label} total={total} disabled loading={false} onClick={() => act()} />;
256
+ }
257
+ if (googlePayLive(props, reason, inFlight(props)) && cap.googlePay) return <GooglePayAction client={cap.googlePay.client} onPress={onGooglePay} />;
258
+ return <PressButton props={props} act={act} reason={reason} returnFocus={returnFocus} />;
205
259
  }
206
260
 
207
261
  /** The charge under way, once there is an order to run it against. */
@@ -230,13 +284,19 @@ function RaiseError({ c, onRetry }: { c: Controller; onRetry: () => void }): JSX
230
284
  return <PaymentErrorPanel message={c.createError} emailFlagged={c.errorField === "email"} code={c.errorCode} onUseEmail={c.payWithEmail} onRetry={onRetry} />;
231
285
  }
232
286
 
287
+ /** The host's balance pays the whole amount (`totalCents: 0`): no card is asked for or charged. */
288
+ function coveredByHost(list: CheckoutPaymentList): boolean {
289
+ return list.totalCents === 0;
290
+ }
291
+
233
292
  /** Why the button is held: the host's hold, then the list's own reasons, in the prototype's order. */
234
293
  function reasonOf(props: ListFaceProps, door: CheckoutDoorOption | undefined): string | null {
235
294
  const { c, list, state } = props;
236
- return blockedReason({
295
+ const reason = blockedReason({
237
296
  copy: list.copy, hold: list.hold?.reason ?? null, choice: state.choice, door, asked: state.asked,
238
- buyer: c.buyer, selection: state.card.selection, cardIsComplete: state.card.complete,
297
+ buyer: c.buyer, selection: state.card.selection, cardIsComplete: state.card.complete, covered: coveredByHost(list),
239
298
  });
299
+ return reason ?? (googlePayUnpriced(props) ? (list.copy.reasons.repricing ?? null) : null);
240
300
  }
241
301
 
242
302
  /**
@@ -262,8 +322,8 @@ function barFacts(props: ListFaceProps, door: CheckoutDoorOption | undefined, re
262
322
  * appears or a total that moves mid-bar stops it, and `send` checks again.
263
323
  */
264
324
  function useChargeGuard(props: ListFaceProps, reason: string | null): void {
265
- const { charge, total } = props;
266
- const allowed = reason === null && (charge.pressedTotal === null || charge.pressedTotal === total);
325
+ const { charge } = props;
326
+ const allowed = reason === null && (charge.pressedTotal === null || charge.pressedTotal === onlineTotal(props));
267
327
  const { setAllowed } = charge;
268
328
  useEffect(() => setAllowed(allowed), [allowed, setAllowed]);
269
329
  }
@@ -295,16 +355,19 @@ export function ListFace(props: ListFaceProps): JSX.Element {
295
355
  const door = chosenDoor(props);
296
356
  const reason = reasonOf(props, door);
297
357
  useChargeGuard(props, reason);
358
+ const free = reason === null && props.charge.phase === "idle" && !c.creating;
359
+ useArmedCharge(armedReady(list, state, free), act);
360
+ useDropRefusedCopy(c.createError);
298
361
  const { returnFocus, onCancel } = useCancelWithFocus(charge);
299
362
  const shown = charge.phase !== "waiting";
300
363
  const facts = barFacts(props, door, reason);
301
364
  return (
302
365
  <Box data-testid="payment-list-step" sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
303
366
  {shown ? (
304
- <ChoiceList copy={list.copy} state={state} door={list.door ?? []} buyer={c.buyer} onBuyerChange={c.setBuyer} provider={props.provider} failure={failure} locked={facts.locked} />
367
+ <ChoiceList copy={list.copy} state={state} door={list.door ?? []} buyer={c.buyer} onBuyerChange={c.setBuyer} provider={props.provider} failure={failure} locked={facts.locked} covered={coveredByHost(list)} />
305
368
  ) : null}
306
369
  <Runners props={props} sheet={sheet.current} onFailed={onFailed} />
307
- <RaiseError c={c} onRetry={act} />
370
+ <RaiseError c={c} onRetry={() => act()} />
308
371
  {shown ? (
309
372
  <ListBar summary={list.summary} extras={facts.extras} payer={facts.charging ? null : <PayerLine props={props} />} reason={facts.underButton} live={facts.live}>
310
373
  <BarAction props={props} failure={failure} act={act} onGooglePay={onGooglePay} onCancel={onCancel} reason={reason} returnFocus={returnFocus} />
@@ -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,54 +192,151 @@ 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">
183
- {copy.title(order.totalLabel)}
318
+ {copy.title(mode.list.amountDueLabel ?? order.totalLabel)}
184
319
  </Text>
185
320
  {afterCopy ? (
186
321
  <PixAfterCopy
187
322
  pix={pix}
188
323
  words={props.words}
189
- totalLabel={order.totalLabel}
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
  }
@@ -72,9 +72,13 @@ function SavedChips({ card, copy }: { card: ListCard; copy: PaymentListCopy }):
72
72
  );
73
73
  }
74
74
 
75
- /** Whether a chosen online row has anything to open — a row with nothing opens no box (L5). */
76
- export function opensContent(method: string, asked: readonly CheckoutCustomerField[], failure: ListChargeFailure | null): boolean {
77
- return method === "CARD" || asked.length > 0 || failure !== null;
75
+ /**
76
+ * Whether a chosen online row has anything to open — a row with nothing opens
77
+ * no box (L5). The card opens nothing of its own when the host's balance pays
78
+ * the whole amount: no card is charged, so none is asked for.
79
+ */
80
+ export function opensContent(method: string, asked: readonly CheckoutCustomerField[], failure: ListChargeFailure | null, covered = false): boolean {
81
+ return (method === "CARD" && !covered) || asked.length > 0 || failure !== null;
78
82
  }
79
83
 
80
84
  /** Move the focus to the card number — the next thing to type once the CPF is complete. */
@@ -97,6 +101,7 @@ export function OnlineContent({
97
101
  copy,
98
102
  provider,
99
103
  failure,
104
+ covered = false,
100
105
  }: {
101
106
  method: "PIX" | "CARD" | "GOOGLE_PAY" | "HAND_OFF";
102
107
  asked: readonly CheckoutCustomerField[];
@@ -106,9 +111,11 @@ export function OnlineContent({
106
111
  copy: PaymentListCopy;
107
112
  provider: string | null;
108
113
  failure: ListChargeFailure | null;
114
+ /** The host's balance pays the whole amount — no chips, no card fields. */
115
+ covered?: boolean;
109
116
  }): JSX.Element {
110
117
  const scope = useRef<HTMLDivElement | null>(null);
111
- const isCard = method === "CARD";
118
+ const isCard = method === "CARD" && !covered;
112
119
  return (
113
120
  <Box ref={scope} sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
114
121
  <BuyerInline
@@ -70,8 +70,10 @@ export function OptionRow({
70
70
  borderRadius: 2.5,
71
71
  px: 1.5,
72
72
  opacity: disabled ? 0.6 : 1,
73
- // The keyboard's place in the group, drawn on the whole row (5.1 item 6).
74
- "&:has(input:focus-visible)": { outline: (t) => `${t.spacing(0.25)} solid ${t.palette.primary.main}`, outlineOffset: (t) => t.spacing(0.25) },
73
+ // The keyboard's place in the group, drawn on the whole row (5.1 item 6)
74
+ // — for the row's own radio only: a field opened inside draws its own
75
+ // ring, and a second one around the row doubled it.
76
+ "&:has(> label input[type='radio']:focus-visible)": { outline: (t) => `${t.spacing(0.25)} solid ${t.palette.primary.main}`, outlineOffset: (t) => t.spacing(0.25) },
75
77
  }}
76
78
  >
77
79
  <Box
@@ -130,9 +130,10 @@ 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
- const charge = useListCharge({ ...c, cartTotal: displayTotals(props.totalOverride, props.cart).label });
136
+ const charge = useListCharge({ ...c, cartTotal: displayTotals(props.totalOverride, props.cart).label, dueCents: list.totalCents });
136
137
  const [handingOff, startHandOff] = useHandingOff(c.createError);
137
138
  const pixStage = usePaymentPixStage(cap, usePreferMethod(c, state.choose));
138
139
  const pix = c.order?.method === "PIX" ? c.order : null;