@12-apps/payments-frontend 3.43.0 → 3.44.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 (35) hide show
  1. package/package.json +2 -2
  2. package/src/components/checkout/apple-pay-button.tsx +84 -36
  3. package/src/components/checkout/checkout-flow.tsx +3 -2
  4. package/src/components/checkout/google-pay-button.tsx +6 -0
  5. package/src/components/checkout/list/buyer-inline.tsx +22 -14
  6. package/src/components/checkout/list/charge-runners.tsx +47 -23
  7. package/src/components/checkout/list/choice-list.tsx +16 -5
  8. package/src/components/checkout/list/choice.ts +4 -6
  9. package/src/components/checkout/list/cpf-top.tsx +98 -0
  10. package/src/components/checkout/list/en-US.ts +8 -0
  11. package/src/components/checkout/list/list-bar.tsx +29 -44
  12. package/src/components/checkout/list/list-face.tsx +110 -104
  13. package/src/components/checkout/list/list-top.tsx +99 -0
  14. package/src/components/checkout/list/online-content.tsx +4 -0
  15. package/src/components/checkout/list/option-row.tsx +5 -2
  16. package/src/components/checkout/list/payer-line.tsx +39 -0
  17. package/src/components/checkout/list/payment-list-step.tsx +50 -5
  18. package/src/components/checkout/list/pt-BR.ts +8 -0
  19. package/src/components/checkout/list/summary-column.tsx +120 -0
  20. package/src/components/checkout/list/top-cpf.ts +94 -0
  21. package/src/components/checkout/list/types.ts +55 -10
  22. package/src/components/checkout/list/use-capability.ts +113 -35
  23. package/src/components/checkout/list/use-list-charge.ts +11 -9
  24. package/src/components/checkout/list/use-list-state.ts +37 -4
  25. package/src/components/checkout/list/wallet-block.tsx +235 -0
  26. package/src/components/checkout/list/wallet-press.ts +129 -0
  27. package/src/components/checkout/payment-phase.tsx +1 -0
  28. package/src/components/checkout/payment-summary.tsx +2 -2
  29. package/src/components/checkout/screens-copy.ts +6 -0
  30. package/src/components/checkout/screens-en-US.ts +1 -0
  31. package/src/components/checkout/screens-pt-BR.ts +2 -0
  32. package/src/components/platform/en-US.ts +6 -5
  33. package/src/components/platform/pt-BR.ts +8 -7
  34. package/src/flows/create-payment-flows.tsx +11 -2
  35. package/src/flows/types.ts +7 -2
@@ -1,11 +1,11 @@
1
1
  import { Box } from "@mui/material";
2
2
  import { keyframes } from "@mui/material/styles";
3
3
  import { useEffect, useRef, type JSX, type ReactNode } from "react";
4
+ import { createPortal } from "react-dom";
4
5
 
5
- import { useCheckoutCopy } from "../copy-context";
6
- import type { GooglePaymentsClient } from "../google-pay-button";
7
6
  import { useCheckoutComponents } from "../ui";
8
7
 
8
+ import { useSummaryColumnTarget } from "./summary-column";
9
9
  import { CHARGE_WINDOW_MS } from "./use-list-charge";
10
10
 
11
11
  /** The prototype's 52px action and 44px secondary, on the theme's spacing scale. */
@@ -23,7 +23,9 @@ function focusButtonIn(scope: HTMLElement | null): void {
23
23
  * is paying, the ONE action — or what replaced it — and why it is held.
24
24
  *
25
25
  * Drawn through the host's `ActionBar` slot, which is what pins it to the
26
- * bottom of the screen: the storefront passes its sticky action bar.
26
+ * bottom of the screen: the storefront passes its sticky action bar. On a
27
+ * computer (FUT-3685, L4) the same content goes into the "Resumo do pedido"
28
+ * column instead, and there is no bar at the bottom.
27
29
  *
28
30
  * ONE live region, always mounted, says what changes under the button — the
29
31
  * reason it is held, the charge under way, the wait — because a region that
@@ -39,29 +41,30 @@ export function ListBar({ summary, extras, payer, reason, live, children }: {
39
41
  live?: string | null;
40
42
  /** The action, or the charge bar, or the wait. */
41
43
  children: ReactNode;
42
- }): JSX.Element {
44
+ }): JSX.Element | null {
43
45
  const { ActionBar, Text } = useCheckoutComponents();
44
- return (
45
- <ActionBar dataTestId="list-pay-bar">
46
- <Box sx={{ display: "flex", flexDirection: "column", gap: 1, width: "100%", minWidth: 0 }}>
47
- {summary ? <Box data-testid="list-bar-summary">{summary}</Box> : null}
48
- {extras ? <Box data-testid="list-bar-extras">{extras}</Box> : null}
49
- {payer}
50
- {children}
51
- <Box role="status" aria-live="polite" data-testid="list-bar-live">
52
- {reason ? (
53
- <Text variant="body" size="sm" color="danger" as="p" style={{ margin: 0 }} data-testid="list-blocked-reason">
54
- {reason}
55
- </Text>
56
- ) : (
57
- <Box component="span" sx={{ position: "absolute", width: "1px", height: "1px", overflow: "hidden", clip: "rect(0 0 0 0)", whiteSpace: "nowrap" }}>
58
- {live ?? ""}
59
- </Box>
60
- )}
61
- </Box>
46
+ const column = useSummaryColumnTarget();
47
+ const content = (
48
+ <Box data-testid={column ? "list-pay-bar" : undefined} sx={{ display: "flex", flexDirection: "column", gap: 1, width: "100%", minWidth: 0 }}>
49
+ {summary ? <Box data-testid="list-bar-summary">{summary}</Box> : null}
50
+ {extras ? <Box data-testid="list-bar-extras">{extras}</Box> : null}
51
+ {payer}
52
+ {children}
53
+ <Box role="status" aria-live="polite" data-testid="list-bar-live">
54
+ {reason ? (
55
+ <Text variant="body" size="sm" color="danger" as="p" style={{ margin: 0 }} data-testid="list-blocked-reason">
56
+ {reason}
57
+ </Text>
58
+ ) : (
59
+ <Box component="span" sx={{ position: "absolute", width: "1px", height: "1px", overflow: "hidden", clip: "rect(0 0 0 0)", whiteSpace: "nowrap" }}>
60
+ {live ?? ""}
61
+ </Box>
62
+ )}
62
63
  </Box>
63
- </ActionBar>
64
+ </Box>
64
65
  );
66
+ if (column) return column.element ? createPortal(content, column.element) : null;
67
+ return <ActionBar dataTestId="list-pay-bar">{content}</ActionBar>;
65
68
  }
66
69
 
67
70
  /** "Pagar" on the left, "R$ 22,00" on the right — the prototype's 52px bar of a button. */
@@ -135,7 +138,9 @@ export function ChargeBar({ text, full, cancelLabel, onCancel }: {
135
138
  animation: full ? "none" : `${fill} ${CHARGE_WINDOW_MS}ms linear forwards`,
136
139
  }}
137
140
  />
138
- <Box component="span" sx={{ position: "relative", color: "common.white", typography: "body1", fontWeight: 600 }}>
141
+ {/* One line, whatever the column's width: a sentence that wrapped inside the
142
+ 52px bar ran over its edges (FUT-3685 review). The live region reads it whole. */}
143
+ <Box component="span" sx={{ position: "relative", minWidth: 0, color: "common.white", typography: "body1", fontWeight: 600, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
139
144
  {text}
140
145
  </Box>
141
146
  </Box>
@@ -164,23 +169,3 @@ export function WaitingLine({ text }: { text: string }): JSX.Element {
164
169
  </Box>
165
170
  );
166
171
  }
167
-
168
- /**
169
- * Google Pay's own button in the action's place (FUT-471's brand rule: the
170
- * buyer presses Google's button, drawn by `createButton`, never a look-alike).
171
- * Its press opens the sheet SYNCHRONOUSLY — inside the tap, as the browser
172
- * requires — through `onPress`.
173
- */
174
- export function GooglePayAction({ client, onPress }: { client: GooglePaymentsClient; onPress: () => void }): JSX.Element {
175
- const mount = useRef<HTMLDivElement | null>(null);
176
- const latest = useRef(onPress);
177
- latest.current = onPress;
178
- const locale = useCheckoutCopy().screens.wallet.googlePay.buttonLocale;
179
- useEffect(() => {
180
- const element = mount.current;
181
- if (!element) return undefined;
182
- element.replaceChildren(client.createButton({ onClick: () => latest.current(), buttonSizeMode: "fill", buttonLocale: locale }));
183
- return () => element.replaceChildren();
184
- }, [client, locale]);
185
- return <Box ref={mount} data-testid="list-google-pay" sx={{ minHeight: (t) => t.spacing(ACTION_HEIGHT), "& > *": { width: "100%" } }} />;
186
- }
@@ -2,25 +2,25 @@ import { Box } from "@mui/material";
2
2
  import { useCallback, useEffect, useRef, useState, type JSX } from "react";
3
3
 
4
4
  import { cardChain, cardTokenization, handOffMethod } from "../method-capability";
5
- import { paymentDataRequest, sheetDismissed } from "../google-pay-button";
6
5
  import { PaymentErrorPanel } from "../payment-error-panel";
7
6
  import { UNRESOLVED_CODE } from "../failure-codes";
8
7
  import type { CheckoutBasketIdentity } from "../basket";
9
- import type { BuyerContact, CheckoutProviderConfig } from "../types";
8
+ import type { BuyerContact, CheckoutProviderConfig, PaymentMethod } from "../types";
10
9
  import type { useCheckoutController } from "../use-checkout-controller";
11
- import { useCheckoutComponents } from "../ui";
12
10
 
13
-
14
- import { actionLabel, blockedReason, cardName } from "./choice";
11
+ import { actionLabel, blockedReason, cardName, raisedMethod } from "./choice";
15
12
  import { usePaymentListMode, type PaymentListMode } from "./context";
16
13
  import { armPixCopy, armedReady, otherBankShown, pixBankLabel, useArmedCharge, useDropRefusedCopy } from "./list-memory";
17
14
  import { OtherBankLink } from "./list-pix";
18
15
  import { ChoiceList } from "./choice-list";
19
- import { CardRun, WalletRun, type ListChargeFailure } from "./charge-runners";
20
- import { ActionButton, ChargeBar, GooglePayAction, ListBar } from "./list-bar";
16
+ import { CardRun, WalletRun, type ListChargeFailure, type WalletSheet } from "./charge-runners";
17
+ import { ActionButton, ChargeBar, ListBar } from "./list-bar";
18
+ import { ListTop, gateFields, walletsHeld } from "./list-top";
19
+ import { PayerLine } from "./payer-line";
21
20
  import type { ListCapability } from "./use-capability";
22
21
  import type { ListCharge } from "./use-list-charge";
23
22
  import type { ListState } from "./use-list-state";
23
+ import { useApplePayPress, useCloseOrphanSheet, useGooglePayPress, type WalletPressInput } from "./wallet-press";
24
24
  import type { CheckoutDoorOption, CheckoutOnlineChoice, CheckoutPaymentList } from "./types";
25
25
 
26
26
  type Controller = ReturnType<typeof useCheckoutController>;
@@ -43,12 +43,11 @@ interface ListFaceProps {
43
43
  saveBuyerContact?: (contact: BuyerContact) => void;
44
44
  /** The hand-off was started: the step shows where the buyer is going. */
45
45
  onHandOff: () => void;
46
- }
47
-
48
- /** Google Pay's sheet, opened in the tap: its answer and the amount it showed. */
49
- interface OpenSheet {
50
- answer: Promise<string | null>;
51
- cents: number;
46
+ /** "Alterar" on the payer line opened the CPF at the top (E2 → E1). */
47
+ revealed: boolean;
48
+ onRevealCpf: () => void;
49
+ /** "Manter o CPF salvo": close the revealed CPF, keeping the one on file. */
50
+ onKeepSavedCpf: () => void;
52
51
  }
53
52
 
54
53
  function chosenDoor(props: ListFaceProps): CheckoutDoorOption | undefined {
@@ -66,7 +65,10 @@ function inFlight(props: ListFaceProps): boolean {
66
65
  /** Remember the asked details for next time, as the Dados step's "Continuar" did — only with the buyer's consent. */
67
66
  function saveAsked(props: ListFaceProps): void {
68
67
  const { c, state, saveBuyerContact } = props;
69
- if (state.asked.length === 0 || !c.saveProfile) return;
68
+ // The CPF at the top counts once the buyer typed one: a field revealed over a
69
+ // CPF on file and left empty asks the host to remember nothing new.
70
+ const typedCpf = state.top !== null && (c.buyer.taxId ?? "").trim() !== "";
71
+ if ((state.asked.length === 0 && !typedCpf) || !c.saveProfile) return;
70
72
  saveBuyerContact?.({ name: c.buyer.name, phone: c.buyer.phone, taxId: c.buyer.taxId });
71
73
  }
72
74
 
@@ -106,58 +108,47 @@ function useAct(props: ListFaceProps, clearFailure: () => void): (pick?: boolean
106
108
  };
107
109
  }
108
110
 
111
+ /** The press facts both wallets share — see `./wallet-press.ts`. */
112
+ function walletInput(
113
+ props: ListFaceProps,
114
+ held: string | null,
115
+ clearFailures: () => void,
116
+ onFailed: (failure: ListChargeFailure) => void,
117
+ pressed: WalletPressInput["pressed"],
118
+ ): WalletPressInput {
119
+ return {
120
+ pressed,
121
+ cap: props.cap,
122
+ charge: props.charge,
123
+ cents: props.list.totalCents,
124
+ total: onlineTotal(props),
125
+ blocked: held !== null || inFlight(props),
126
+ before: () => {
127
+ clearFailures();
128
+ saveAsked(props);
129
+ },
130
+ onFailed,
131
+ };
132
+ }
133
+
109
134
  /**
110
- * Google Pay's press, from Google's own button: the sheet opens HERE, inside
111
- * the tap, priced with the host's amount due — then the order is raised behind it.
135
+ * A payment made HERE is still possible: the list offers an online way to pay
136
+ * (a row or a ready wallet) and no door row is chosen — nothing chosen counts,
137
+ * since a wallet pays with no row chosen at all (FUT-3685 review). What the
138
+ * host offers for a payment made here (store credit) and who is paying are
139
+ * shown while this holds.
112
140
  */
113
- function useGooglePayPress(props: ListFaceProps, clearFailure: () => void, sheet: { current: OpenSheet | null }): () => void {
114
- return () => {
115
- const { cap, list, charge } = props;
116
- const cents = list.totalCents;
117
- if (!cap.googlePay || cents === undefined || inFlight(props)) return;
118
- clearFailure();
119
- saveAsked(props);
120
- const answer = cap.googlePay.client
121
- .loadPaymentData(paymentDataRequest(cap.googlePay.params, { totalCents: cents }))
122
- .then(
123
- (data) => data.paymentMethodData.tokenizationData.token,
124
- (error: unknown) => {
125
- if (sheetDismissed(error)) return null;
126
- throw error;
127
- },
128
- );
129
- sheet.current = { answer, cents };
130
- charge.start("GOOGLE_PAY", onlineTotal(props));
131
- };
141
+ function payingHere(props: ListFaceProps): boolean {
142
+ const offersOnline = props.state.rows.length > 0 || props.cap.wallets.length > 0;
143
+ return offersOnline && props.state.choice?.kind !== "door";
132
144
  }
133
145
 
134
- /** Who is paying, when the store already holds their CPF (FUT-465): "Pagando como … · CPF ✓ Alterar". */
135
- function PayerLine({ props }: { props: ListFaceProps }): JSX.Element | null {
136
- const { Button, Text } = useCheckoutComponents();
137
- const { c, list, taxIdOnFile, state } = props;
146
+ /** "Pagando como … · CPF ✓ Alterar" — while a payment here is possible and the CPF is not open. */
147
+ function Payer({ props }: { props: ListFaceProps }): JSX.Element | null {
148
+ const { c, list, taxIdOnFile } = props;
138
149
  const name = c.buyer.name?.trim();
139
- if (!taxIdOnFile || !name || state.choice?.kind !== "online") return null;
140
- return (
141
- // Left-aligned, "Alterar" ~4px after "CPF ✓" and wrapping under it when
142
- // the name is long; the link keeps a 40px target and a visible focus ring.
143
- <Box data-testid="list-payer" sx={{ display: "flex", alignItems: "center", justifyContent: "flex-start", flexWrap: "wrap", columnGap: 0 }}>
144
- <Text variant="body" size="sm" color="secondary" as="span">
145
- {list.copy.payer(name)}
146
- </Text>
147
- {c.editBuyer ? (
148
- <Box
149
- sx={{
150
- "& button": { textDecoration: "underline", minHeight: (t) => t.spacing(5), minWidth: 0, px: 0.5, py: 0 },
151
- "& button:focus-visible": { outline: (t) => `${t.spacing(0.25)} solid ${t.palette.primary.main}`, outlineOffset: (t) => t.spacing(0.25) },
152
- }}
153
- >
154
- <Button variant="text" color="primary" size="sm" onClick={c.editBuyer} dataTestId="list-payer-change">
155
- {list.copy.payerChange}
156
- </Button>
157
- </Box>
158
- ) : null}
159
- </Box>
160
- );
150
+ if (!taxIdOnFile || !name || props.revealed || !payingHere(props)) return null;
151
+ return <PayerLine copy={list.copy} name={name} onChange={props.onRevealCpf} />;
161
152
  }
162
153
 
163
154
  /** The card's charge is past the button: counting down, or going out. */
@@ -188,22 +179,6 @@ function buttonFacts(props: ListFaceProps): { label: string; total: string; busy
188
179
  };
189
180
  }
190
181
 
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 {
197
- const { choice } = props.state;
198
- return choice?.kind === "online" && choice.method === "GOOGLE_PAY" && !props.cap.googlePay;
199
- }
200
-
201
- /** Google Pay is chosen and nothing holds it: Google's own button takes the action's place. */
202
- function googlePayLive(props: ListFaceProps, reason: string | null, busy: boolean): boolean {
203
- const { choice } = props.state;
204
- return choice?.kind === "online" && choice.method === "GOOGLE_PAY" && props.cap.googlePay !== null && reason === null && !busy;
205
- }
206
-
207
182
  /** The 5-second bar (or the charge going out), and the host's footer while it counts. */
208
183
  function ChargingBar({ props, onCancel }: { props: ListFaceProps; onCancel: () => void }): JSX.Element {
209
184
  const { charge, list } = props;
@@ -235,34 +210,29 @@ function PressButton({ props, act, reason, returnFocus }: {
235
210
  );
236
211
  }
237
212
 
238
- /** The button, the 5-second bar, Google's own button, or nothing for a charge nobody can confirm yet. */
239
- function BarAction({ props, failure, act, onGooglePay, onCancel, reason, returnFocus }: {
213
+ /** The button, the 5-second bar, or nothing for a charge nobody can confirm yet. */
214
+ function BarAction({ props, failure, act, onCancel, reason, returnFocus }: {
240
215
  props: ListFaceProps;
241
216
  failure: ListChargeFailure | null;
242
217
  act: (pick?: boolean) => void;
243
- onGooglePay: () => void;
244
218
  onCancel: () => void;
245
219
  reason: string | null;
246
220
  returnFocus: boolean;
247
221
  }): JSX.Element | null {
248
- const { charge, cap } = props;
249
- if (cardCharging(charge)) return <ChargingBar props={props} onCancel={onCancel} />;
222
+ if (cardCharging(props.charge)) return <ChargingBar props={props} onCancel={onCancel} />;
250
223
  // An UNRESOLVED charge may be holding the buyer's money: no pay control at
251
224
  // all under "não pague de novo" (FUT-563), as the card pane does today.
252
225
  if (failure?.code === UNRESOLVED_CODE) return null;
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
226
  return <PressButton props={props} act={act} reason={reason} returnFocus={returnFocus} />;
259
227
  }
260
228
 
261
229
  /** The charge under way, once there is an order to run it against. */
262
- function Runners({ props, sheet, onFailed }: {
230
+ function Runners({ props, sheet, onFailed, onWalletFailed }: {
263
231
  props: ListFaceProps;
264
- sheet: OpenSheet | null;
232
+ sheet: WalletSheet | null;
265
233
  onFailed: (failure: ListChargeFailure | null) => void;
234
+ /** A wallet's refusal is said under the wallet buttons, not in a row. */
235
+ onWalletFailed: (failure: ListChargeFailure | null) => void;
266
236
  }): JSX.Element | null {
267
237
  const { c, charge, config } = props;
268
238
  const order = charge.chargeable;
@@ -274,7 +244,7 @@ function Runners({ props, sheet, onFailed }: {
274
244
  if (charge.kind === "CARD") {
275
245
  return <CardRun {...common} card={props.state.card} config={cardTokenization(config)} chain={cardChain(config)} />;
276
246
  }
277
- if (charge.kind === "GOOGLE_PAY" && sheet) return <WalletRun {...common} sheet={sheet.answer} sheetCents={sheet.cents} />;
247
+ if (charge.kind !== null && charge.kind === sheet?.type) return <WalletRun {...common} onFailed={onWalletFailed} sheet={sheet} />;
278
248
  return null;
279
249
  }
280
250
 
@@ -289,14 +259,24 @@ function coveredByHost(list: CheckoutPaymentList): boolean {
289
259
  return list.totalCents === 0;
290
260
  }
291
261
 
292
- /** Why the button is held: the host's hold, then the list's own reasons, in the prototype's order. */
262
+ /**
263
+ * Why the button is held: the host's hold, then the list's own reasons, in the
264
+ * prototype's order — the CPF at the top first, for an online choice that needs
265
+ * it (E3); a door choice never does (R3).
266
+ */
293
267
  function reasonOf(props: ListFaceProps, door: CheckoutDoorOption | undefined): string | null {
294
- const { c, list, state } = props;
295
- const reason = blockedReason({
296
- copy: list.copy, hold: list.hold?.reason ?? null, choice: state.choice, door, asked: state.asked,
268
+ const { c, list, state, config } = props;
269
+ return blockedReason({
270
+ copy: list.copy, hold: list.hold?.reason ?? null, choice: state.choice, door,
271
+ asked: gateFields(state, c.buyer, config, raisedOf(props)),
297
272
  buyer: c.buyer, selection: state.card.selection, cardIsComplete: state.card.complete, covered: coveredByHost(list),
298
273
  });
299
- return reason ?? (googlePayUnpriced(props) ? (list.copy.reasons.repricing ?? null) : null);
274
+ }
275
+
276
+ /** The chain method the chosen online option would raise, or `null`. */
277
+ function raisedOf(props: ListFaceProps): PaymentMethod | null {
278
+ const { choice } = props.state;
279
+ return choice?.kind === "online" ? raisedMethod(choice.method, props.cap.offered) : null;
300
280
  }
301
281
 
302
282
  /**
@@ -306,11 +286,10 @@ function reasonOf(props: ListFaceProps, door: CheckoutDoorOption | undefined): s
306
286
  */
307
287
  function barFacts(props: ListFaceProps, door: CheckoutDoorOption | undefined, reason: string | null) {
308
288
  const charging = props.charge.phase !== "idle";
309
- const online = props.state.choice?.kind === "online";
310
289
  return {
311
290
  charging,
312
291
  locked: inFlight(props),
313
- extras: online && !charging ? props.list.onlineExtras : undefined,
292
+ extras: payingHere(props) && !charging ? props.list.onlineExtras : undefined,
314
293
  underButton: charging ? null : (door?.error ?? reason),
315
294
  live: cardCharging(props.charge) ? chargingText(props) : null,
316
295
  };
@@ -342,6 +321,29 @@ function useCancelWithFocus(charge: ListCharge): { returnFocus: boolean; onCance
342
321
  return { returnFocus, onCancel };
343
322
  }
344
323
 
324
+ /**
325
+ * The wallets' sheet, their refusal and the presses that open one. A refusal
326
+ * (or a sheet that could not open) is said under the wallet buttons; a closed
327
+ * sheet (`null`) never clears one the wallet already gave.
328
+ */
329
+ function useWallets(props: ListFaceProps, clearFailure: () => void) {
330
+ const sheet = useRef<WalletSheet | null>(null);
331
+ const [failure, setFailure] = useState<ListChargeFailure | null>(null);
332
+ const { settle } = props.charge;
333
+ const onFailed = useCallback((next: ListChargeFailure | null) => { setFailure((previous) => next ?? previous); settle(); }, [settle]);
334
+ const held = walletsHeld({ state: props.state, buyer: props.c.buyer, list: props.list, cap: props.cap, config: props.config });
335
+ const before = () => {
336
+ setFailure(null);
337
+ clearFailure();
338
+ };
339
+ const pressed = useRef(false);
340
+ const input = walletInput(props, held, before, onFailed, pressed);
341
+ const onGoogle = useGooglePayPress(input, sheet);
342
+ const onApple = useApplePayPress(input, sheet);
343
+ useCloseOrphanSheet(props.charge.phase, sheet, pressed);
344
+ return { sheet, held, failure, onFailed, clearWalletFailure: () => setFailure(null), onGoogle, onApple };
345
+ }
346
+
345
347
  /** The list, the charge under way, and the bar. */
346
348
  export function ListFace(props: ListFaceProps): JSX.Element {
347
349
  const { c, list, state, charge } = props;
@@ -349,9 +351,12 @@ export function ListFace(props: ListFaceProps): JSX.Element {
349
351
  const clearFailure = useCallback(() => setFailure(null), []);
350
352
  const { settle } = charge;
351
353
  const onFailed = useCallback((next: ListChargeFailure | null) => { setFailure(next); settle(); }, [settle]);
352
- const sheet = useRef<OpenSheet | null>(null);
353
- const act = useAct(props, clearFailure);
354
- const onGooglePay = useGooglePayPress(props, clearFailure, sheet);
354
+ const wallets = useWallets(props, clearFailure);
355
+ const { clearWalletFailure } = wallets;
356
+ const act = useAct(props, () => {
357
+ clearFailure();
358
+ clearWalletFailure();
359
+ });
355
360
  const door = chosenDoor(props);
356
361
  const reason = reasonOf(props, door);
357
362
  useChargeGuard(props, reason);
@@ -361,16 +366,17 @@ export function ListFace(props: ListFaceProps): JSX.Element {
361
366
  const { returnFocus, onCancel } = useCancelWithFocus(charge);
362
367
  const shown = charge.phase !== "waiting";
363
368
  const facts = barFacts(props, door, reason);
369
+ const top = <ListTop props={props} held={wallets.held} failure={wallets.failure} onGoogle={wallets.onGoogle} onApple={wallets.onApple} />;
364
370
  return (
365
371
  <Box data-testid="payment-list-step" sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
366
372
  {shown ? (
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)} />
373
+ <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)} top={top} />
368
374
  ) : null}
369
- <Runners props={props} sheet={sheet.current} onFailed={onFailed} />
375
+ <Runners props={props} sheet={wallets.sheet.current} onFailed={onFailed} onWalletFailed={wallets.onFailed} />
370
376
  <RaiseError c={c} onRetry={() => act()} />
371
377
  {shown ? (
372
- <ListBar summary={list.summary} extras={facts.extras} payer={facts.charging ? null : <PayerLine props={props} />} reason={facts.underButton} live={facts.live}>
373
- <BarAction props={props} failure={failure} act={act} onGooglePay={onGooglePay} onCancel={onCancel} reason={reason} returnFocus={returnFocus} />
378
+ <ListBar summary={list.summary} extras={facts.extras} payer={facts.charging ? null : <Payer props={props} />} reason={facts.underButton} live={facts.live}>
379
+ <BarAction props={props} failure={failure ?? wallets.failure} act={act} onCancel={onCancel} reason={reason} returnFocus={returnFocus} />
374
380
  </ListBar>
375
381
  ) : null}
376
382
  </Box>
@@ -0,0 +1,99 @@
1
+ import { Box } from "@mui/material";
2
+ import { useRef, type JSX } from "react";
3
+
4
+ import type { BuyerInfo, CheckoutCustomerField, CheckoutProviderConfig, PaymentMethod } from "../types";
5
+
6
+ import type { ListChargeFailure } from "./charge-runners";
7
+ import { CpfTop } from "./cpf-top";
8
+ import { cpfHolds, cpfProvider } from "./top-cpf";
9
+ import type { CheckoutPaymentList } from "./types";
10
+ import type { ListCapability } from "./use-capability";
11
+ import type { ListState } from "./use-list-state";
12
+ import { WalletBlock } from "./wallet-block";
13
+
14
+ /**
15
+ * The fields that hold the bar's button for the chosen online option, in the
16
+ * order they are asked: the CPF at the top when this method's chain needs it
17
+ * (or a typed one is not valid), then the row's own (E3). `method` is `null`
18
+ * for a door choice or none — a door row never needs the CPF (R3).
19
+ */
20
+ export function gateFields(
21
+ state: Pick<ListState, "top" | "asked">,
22
+ buyer: BuyerInfo,
23
+ config: CheckoutProviderConfig | null,
24
+ method: PaymentMethod | null,
25
+ ): CheckoutCustomerField[] {
26
+ if (method === null || !state.top) return state.asked;
27
+ return cpfHolds(state.top, buyer, config?.chain, method) ? [state.top, ...state.asked] : state.asked;
28
+ }
29
+
30
+ /**
31
+ * Why the wallet buttons cannot act yet, or `null`: the host's hold (a step
32
+ * before this one is open), the CPF the provider needs (E3 — a wallet raises a
33
+ * card order), the host re-pricing the sheet.
34
+ */
35
+ export function walletsHeld(input: {
36
+ state: Pick<ListState, "top">;
37
+ buyer: BuyerInfo;
38
+ list: CheckoutPaymentList;
39
+ cap: ListCapability;
40
+ config: CheckoutProviderConfig | null;
41
+ }): string | null {
42
+ const { list, cap } = input;
43
+ if (list.hold) return list.hold.reason;
44
+ if (cpfHolds(input.state.top, input.buyer, input.config?.chain, "CARD")) return list.copy.reasons.cpf;
45
+ const priced = cap.wallets.every((wallet) => (wallet === "GOOGLE_PAY" ? cap.googlePay : cap.applePay) !== null);
46
+ return priced ? null : (list.copy.reasons.repricing ?? list.copy.reasons.choose);
47
+ }
48
+
49
+ /** Move the focus to "Número do cartão" — the next thing to type once the CPF is complete. */
50
+ function focusCardNumber(scope: HTMLElement | null): void {
51
+ scope?.closest('[data-testid="payment-list"]')?.querySelector<HTMLInputElement>('input[data-testid="card-number"]')?.focus();
52
+ }
53
+
54
+ /** What the top of the list reads, gathered by the face. */
55
+ interface ListTopFacts {
56
+ list: CheckoutPaymentList;
57
+ state: ListState;
58
+ cap: ListCapability;
59
+ config: CheckoutProviderConfig | null;
60
+ c: { buyer: BuyerInfo; setBuyer: (buyer: BuyerInfo) => void };
61
+ revealed: boolean;
62
+ /** Close a CPF opened over the one on file, keeping that one. */
63
+ onKeepSavedCpf: () => void;
64
+ }
65
+
66
+ /**
67
+ * The top of Pagamento (FUT-3685): the CPF, asked once (E1, L7), then the
68
+ * "PAGAMENTO RÁPIDO" block (C5, C6) — both above the rows.
69
+ */
70
+ export function ListTop({ props, held, failure, onGoogle, onApple }: {
71
+ props: ListTopFacts;
72
+ held: string | null;
73
+ /** The last wallet charge's refusal, said under the buttons. */
74
+ failure: ListChargeFailure | null;
75
+ onGoogle: () => void;
76
+ onApple: () => void;
77
+ }): JSX.Element | null {
78
+ const scope = useRef<HTMLDivElement | null>(null);
79
+ const { list, state, cap, config, c } = props;
80
+ if (!state.top && cap.wallets.length === 0) return null;
81
+ const typingCard = state.choice?.kind === "online" && state.choice.method === "CARD";
82
+ return (
83
+ <Box ref={scope} data-testid="list-top" sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
84
+ {state.top ? (
85
+ <CpfTop
86
+ field={state.top}
87
+ buyer={c.buyer}
88
+ onBuyerChange={c.setBuyer}
89
+ copy={list.copy.buyer}
90
+ provider={cpfProvider(config?.chain, state.methods)}
91
+ onComplete={typingCard ? () => focusCardNumber(scope.current) : undefined}
92
+ focusOnMount={props.revealed}
93
+ onKeepSaved={props.revealed ? props.onKeepSavedCpf : undefined}
94
+ />
95
+ ) : null}
96
+ <WalletBlock cap={cap} copy={list.copy} held={held} failure={failure} onGoogle={onGoogle} onApple={onApple} />
97
+ </Box>
98
+ );
99
+ }
@@ -102,6 +102,7 @@ export function OnlineContent({
102
102
  provider,
103
103
  failure,
104
104
  covered = false,
105
+ withConsent = true,
105
106
  }: {
106
107
  method: "PIX" | "CARD" | "GOOGLE_PAY" | "HAND_OFF";
107
108
  asked: readonly CheckoutCustomerField[];
@@ -113,6 +114,8 @@ export function OnlineContent({
113
114
  failure: ListChargeFailure | null;
114
115
  /** The host's balance pays the whole amount — no chips, no card fields. */
115
116
  covered?: boolean;
117
+ /** Offer "Salvar meus dados…" beside the asked fields — not when the top of Pagamento does. */
118
+ withConsent?: boolean;
116
119
  }): JSX.Element {
117
120
  const scope = useRef<HTMLDivElement | null>(null);
118
121
  const isCard = method === "CARD" && !covered;
@@ -125,6 +128,7 @@ export function OnlineContent({
125
128
  copy={copy.buyer}
126
129
  provider={provider}
127
130
  onComplete={isCard ? () => focusCardNumber(scope.current) : undefined}
131
+ withConsent={withConsent}
128
132
  />
129
133
  {isCard ? <SavedChips card={card} copy={copy} /> : null}
130
134
  {isCard && card.selection === NEW_CARD ? (
@@ -78,8 +78,11 @@ export function OptionRow({
78
78
  >
79
79
  <Box
80
80
  component="label"
81
- // 48 + the border = the prototype's 50px row, with or without a hint (L3).
82
- sx={{ display: "flex", alignItems: "center", gap: 1.5, minHeight: (t) => t.spacing(6), py: 0, cursor: disabled ? "default" : "pointer" }}
81
+ // The prototype's `.opt` padding, 12px above and below the label and its
82
+ // hint (FUT-3685 review): a row with a hint is ~62px, and the hint never
83
+ // touches — or is clipped by — the row's border. 48px is the floor, for
84
+ // a row with no hint.
85
+ sx={{ display: "flex", alignItems: "center", gap: 1.5, minHeight: (t) => t.spacing(6), py: 1.5, cursor: disabled ? "default" : "pointer" }}
83
86
  >
84
87
  <Radio
85
88
  name={name}
@@ -0,0 +1,39 @@
1
+ import { Box } from "@mui/material";
2
+ import type { JSX } from "react";
3
+
4
+ import { useCheckoutComponents } from "../ui";
5
+
6
+ import type { PaymentListCopy } from "./types";
7
+
8
+ /**
9
+ * Who is paying, when the store already holds their CPF (FUT-465): "Pagando
10
+ * como … · CPF ✓ Alterar". "Alterar" reveals the CPF field at the top of
11
+ * Pagamento (FUT-3685, E2 → E1) — never a Dados step: the list has none
12
+ * (R9). Shown only while an online option is chosen and the CPF is not open.
13
+ */
14
+ export function PayerLine({ copy, name, onChange }: {
15
+ copy: PaymentListCopy;
16
+ name: string;
17
+ onChange: () => void;
18
+ }): JSX.Element {
19
+ const { Button, Text } = useCheckoutComponents();
20
+ return (
21
+ // Left-aligned, "Alterar" ~4px after "CPF ✓" and wrapping under it when
22
+ // the name is long; the link keeps a 40px target and a visible focus ring.
23
+ <Box data-testid="list-payer" sx={{ display: "flex", alignItems: "center", justifyContent: "flex-start", flexWrap: "wrap", columnGap: 0 }}>
24
+ <Text variant="body" size="sm" color="secondary" as="span">
25
+ {copy.payer(name)}
26
+ </Text>
27
+ <Box
28
+ sx={{
29
+ "& button": { textDecoration: "underline", minHeight: (t) => t.spacing(5), minWidth: 0, px: 0.5, py: 0 },
30
+ "& button:focus-visible": { outline: (t) => `${t.spacing(0.25)} solid ${t.palette.primary.main}`, outlineOffset: (t) => t.spacing(0.25) },
31
+ }}
32
+ >
33
+ <Button variant="text" color="primary" size="sm" onClick={onChange} dataTestId="list-payer-change">
34
+ {copy.payerChange}
35
+ </Button>
36
+ </Box>
37
+ </Box>
38
+ );
39
+ }