@12-apps/payments-frontend 3.43.1 → 3.45.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 (34) 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 +25 -7
  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 +55 -32
  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 +89 -13
  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/flows/create-payment-flows.tsx +11 -2
  33. package/src/flows/types.ts +7 -2
  34. package/src/index.ts +1 -1
@@ -1,4 +1,4 @@
1
- import { Box, Radio } from "@mui/material";
1
+ import { Box, Radio, type SxProps } from "@mui/material";
2
2
  import { alpha, type Theme } from "@mui/material/styles";
3
3
  import type { JSX, ReactNode } from "react";
4
4
 
@@ -35,6 +35,48 @@ const FIELDS_ON_SURFACE = {
35
35
  },
36
36
  } as const;
37
37
 
38
+ /** The row's label and its muted hint, beside the radio. */
39
+ function RowWords({ label, hint }: { label: string; hint?: string }): JSX.Element {
40
+ const { Text } = useCheckoutComponents();
41
+ return (
42
+ <Box sx={{ display: "flex", flexDirection: "column", minWidth: 0, flex: 1 }}>
43
+ <Text variant="body" size="md" as="span">
44
+ {label}
45
+ </Text>
46
+ {hint ? (
47
+ <Text variant="body" size="sm" color="secondary" as="span">
48
+ {hint}
49
+ </Text>
50
+ ) : null}
51
+ </Box>
52
+ );
53
+ }
54
+
55
+ /** What opens inside a row, its fields standing on the surface. */
56
+ function RowContent({ testId, children }: { testId: string; children: ReactNode }): JSX.Element {
57
+ return (
58
+ <Box data-testid={`${testId}-content`} sx={{ display: "flex", flexDirection: "column", gap: 1.25, pb: 1.5, ...FIELDS_ON_SURFACE }}>
59
+ {children}
60
+ </Box>
61
+ );
62
+ }
63
+
64
+ /** The row's frame: tinted and outlined while chosen, dimmed while disabled. */
65
+ function rowFrameSx(chosen: boolean, disabled: boolean): SxProps<Theme> {
66
+ return {
67
+ border: "1px solid",
68
+ borderColor: chosen ? "primary.main" : "divider",
69
+ bgcolor: chosen ? chosenTint : "background.paper",
70
+ borderRadius: 2.5,
71
+ px: 1.5,
72
+ opacity: disabled ? 0.6 : 1,
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) },
77
+ };
78
+ }
79
+
38
80
  export function OptionRow({
39
81
  name,
40
82
  value,
@@ -44,6 +86,7 @@ export function OptionRow({
44
86
  hint,
45
87
  onChoose,
46
88
  testId,
89
+ open = false,
47
90
  children,
48
91
  }: {
49
92
  /** The group's name, shared by every row. */
@@ -55,31 +98,24 @@ export function OptionRow({
55
98
  hint?: string;
56
99
  onChoose: () => void;
57
100
  testId: string;
58
- /** Rendered only while chosen. */
101
+ /** Render `children` while NOT chosen too (a door option's `showContentWhileIdle`). */
102
+ open?: boolean;
103
+ /** Rendered only while chosen, unless `open`. */
59
104
  children?: ReactNode;
60
105
  }): JSX.Element {
61
- const { Text } = useCheckoutComponents();
62
106
  return (
63
107
  <Box
64
108
  data-testid={testId}
65
109
  data-chosen={chosen ? "true" : "false"}
66
- sx={{
67
- border: "1px solid",
68
- borderColor: chosen ? "primary.main" : "divider",
69
- bgcolor: chosen ? chosenTint : "background.paper",
70
- borderRadius: 2.5,
71
- px: 1.5,
72
- opacity: disabled ? 0.6 : 1,
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) },
77
- }}
110
+ sx={rowFrameSx(chosen, disabled)}
78
111
  >
79
112
  <Box
80
113
  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" }}
114
+ // The prototype's `.opt` padding, 12px above and below the label and its
115
+ // hint (FUT-3685 review): a row with a hint is ~62px, and the hint never
116
+ // touches — or is clipped by — the row's border. 48px is the floor, for
117
+ // a row with no hint.
118
+ sx={{ display: "flex", alignItems: "center", gap: 1.5, minHeight: (t) => t.spacing(6), py: 1.5, cursor: disabled ? "default" : "pointer" }}
83
119
  >
84
120
  <Radio
85
121
  name={name}
@@ -91,23 +127,10 @@ export function OptionRow({
91
127
  sx={{ p: 0, flex: "none" }}
92
128
  inputProps={{ "data-testid": `${testId}-radio` } as Record<string, string>}
93
129
  />
94
- <Box sx={{ display: "flex", flexDirection: "column", minWidth: 0, flex: 1 }}>
95
- <Text variant="body" size="md" as="span">
96
- {label}
97
- </Text>
98
- {hint ? (
99
- <Text variant="body" size="sm" color="secondary" as="span">
100
- {hint}
101
- </Text>
102
- ) : null}
103
- </Box>
130
+ <RowWords label={label} hint={hint} />
104
131
  </Box>
105
132
  {/* Only a row with something to open draws the box for it — no empty band. */}
106
- {chosen && children ? (
107
- <Box data-testid={`${testId}-content`} sx={{ display: "flex", flexDirection: "column", gap: 1.25, pb: 1.5, ...FIELDS_ON_SURFACE }}>
108
- {children}
109
- </Box>
110
- ) : null}
133
+ {(chosen || open) && children ? <RowContent testId={testId}>{children}</RowContent> : null}
111
134
  </Box>
112
135
  );
113
136
  }
@@ -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
+ }
@@ -1,8 +1,9 @@
1
- import { useCallback, useEffect, useMemo, useState, type JSX } from "react";
1
+ import { useCallback, useEffect, useMemo, useState, type JSX, type ReactNode } from "react";
2
2
 
3
3
  import type { CheckoutBasketIdentity } from "../basket";
4
4
  import { CartChangedBoundary } from "../cart-changed";
5
5
  import { displayTotals } from "../checkout-totals";
6
+ import type { CheckoutSummaryLine } from "../payment-summary";
6
7
  import { usePaymentPixStage } from "../payment-step-layout";
7
8
  import { PixStageProvider } from "../pix-stage";
8
9
  import { PixView } from "../pix-view";
@@ -13,6 +14,7 @@ import type { useCheckoutController } from "../use-checkout-controller";
13
14
  import { PaymentListProvider } from "./context";
14
15
  import { ListBar, WaitingLine } from "./list-bar";
15
16
  import { ListFace } from "./list-face";
17
+ import { ListLayout } from "./summary-column";
16
18
  import type { ListCapability } from "./use-capability";
17
19
  import type { ListCharge } from "./use-list-charge";
18
20
  import type { ListState } from "./use-list-state";
@@ -26,7 +28,15 @@ type Controller = ReturnType<typeof useCheckoutController>;
26
28
  interface PaymentListStepProps {
27
29
  c: Controller;
28
30
  list: CheckoutPaymentList;
29
- cart: { totalLabel: string; totalItems: number; identity?: CheckoutBasketIdentity };
31
+ cart: {
32
+ totalLabel: string;
33
+ totalItems: number;
34
+ identity?: CheckoutBasketIdentity;
35
+ /** The cart's lines, for the "Resumo do pedido" column on a computer (L4). */
36
+ lines?: CheckoutSummaryLine[];
37
+ /** The host's fee and discount rows, for the same column. */
38
+ discountLines?: ReactNode;
39
+ };
30
40
  /** Settling a balance: its totals win, and its basket is no signal. */
31
41
  totalOverride?: { label: string; items: number };
32
42
  providerConfig: CheckoutProviderConfig | null | undefined;
@@ -38,6 +48,11 @@ interface PaymentListStepProps {
38
48
  /** The Dados step's consent sentence — the list asks it beside the details it asks. */
39
49
  consentLabel: string;
40
50
  pollIntervalMs?: number;
51
+ /**
52
+ * The host's Apple Pay merchant-validation port (FUT-472), server-side.
53
+ * Without it the list offers no Apple Pay: its session could only abort.
54
+ */
55
+ validateApplePayMerchant?: (validationURL: string) => Promise<unknown>;
41
56
  }
42
57
 
43
58
  /** The store's provider name, as its chain head published it — `null` when it did not. */
@@ -80,13 +95,29 @@ function usePreferMethod(c: Controller, choose: ListState["choose"]): (method: P
80
95
  );
81
96
  }
82
97
 
98
+ /**
99
+ * "Alterar" on the payer line opens the CPF at the top (E2 → E1); "Manter o
100
+ * CPF salvo" closes it, putting back the CPF the buyer arrived with — a CPF
101
+ * typed in between is dropped, never sent.
102
+ */
103
+ function useReveal(c: Controller, arrivedWith: BuyerInfo | undefined) {
104
+ const [revealed, setRevealed] = useState(false);
105
+ const { buyer, setBuyer } = c;
106
+ const onKeepSavedCpf = useCallback(() => {
107
+ setBuyer({ ...buyer, taxId: arrivedWith?.taxId });
108
+ setRevealed(false);
109
+ }, [buyer, setBuyer, arrivedWith]);
110
+ return useMemo(() => ({ revealed, onRevealCpf: () => setRevealed(true), onKeepSavedCpf }), [revealed, onKeepSavedCpf]);
111
+ }
112
+
83
113
  /** The list face, with what the step gathered for it. */
84
- function ListFaceFor({ props, state, charge, cap, onHandOff }: {
114
+ function ListFaceFor({ props, state, charge, cap, onHandOff, reveal }: {
85
115
  props: PaymentListStepProps;
86
116
  state: ListState;
87
117
  charge: ListCharge;
88
118
  cap: ListCapability;
89
119
  onHandOff: () => void;
120
+ reveal: { revealed: boolean; onRevealCpf: () => void; onKeepSavedCpf: () => void };
90
121
  }): JSX.Element {
91
122
  const { c, cart, totalOverride } = props;
92
123
  const config = props.providerConfig ?? null;
@@ -106,6 +137,9 @@ function ListFaceFor({ props, state, charge, cap, onHandOff }: {
106
137
  pollIntervalMs={props.pollIntervalMs ?? 2500}
107
138
  saveBuyerContact={props.saveBuyerContact}
108
139
  onHandOff={onHandOff}
140
+ revealed={reveal.revealed}
141
+ onRevealCpf={reveal.onRevealCpf}
142
+ onKeepSavedCpf={reveal.onKeepSavedCpf}
109
143
  />
110
144
  );
111
145
  }
@@ -122,7 +156,9 @@ function ListFaceFor({ props, state, charge, cap, onHandOff }: {
122
156
  export function PaymentListStep(props: PaymentListStepProps): JSX.Element {
123
157
  const { c, list } = props;
124
158
  const config = props.providerConfig ?? null;
125
- const cap = useListCapability(config, list.totalCents);
159
+ const cap = useListCapability(config, list.totalCents, props.validateApplePayMerchant);
160
+ const reveal = useReveal(c, props.defaultBuyer);
161
+ const { revealed } = reveal;
126
162
  const state = useListState({
127
163
  config,
128
164
  capability: cap,
@@ -132,6 +168,8 @@ export function PaymentListStep(props: PaymentListStepProps): JSX.Element {
132
168
  taxIdOnFile: props.taxIdOnFile,
133
169
  lastMethod: list.lastMethod,
134
170
  onChoiceChange: list.onChoiceChange,
171
+ wallets: cap.wallets,
172
+ revealed,
135
173
  });
136
174
  const charge = useListCharge({ ...c, cartTotal: displayTotals(props.totalOverride, props.cart).label, dueCents: list.totalCents });
137
175
  const [handingOff, startHandOff] = useHandingOff(c.createError);
@@ -146,7 +184,14 @@ export function PaymentListStep(props: PaymentListStepProps): JSX.Element {
146
184
  ) : handingOff ? (
147
185
  <HandOffFace props={props} method={c.method ?? "PIX"} />
148
186
  ) : (
149
- <ListFaceFor props={props} state={state} charge={charge} cap={cap} onHandOff={startHandOff} />
187
+ <ListLayout
188
+ hostTarget={list.summaryTarget}
189
+ lines={props.totalOverride ? undefined : props.cart.lines}
190
+ moneyLines={props.cart.discountLines}
191
+ totalLabel={displayTotals(props.totalOverride, props.cart).label}
192
+ >
193
+ <ListFaceFor props={props} state={state} charge={charge} cap={cap} onHandOff={startHandOff} reveal={reveal} />
194
+ </ListLayout>
150
195
  );
151
196
  return (
152
197
  <PaymentListProvider list={list} onLeavePix={c.dropOrder} consent={consent}>
@@ -28,6 +28,12 @@ export const PT_BR_PAYMENT_LIST_COPY: PaymentListCopy = {
28
28
  hintNew: "Digite os dados do cartão",
29
29
  },
30
30
  googlePay: { label: "Google Pay", hint: "Com o cartão salvo no seu Google" },
31
+ wallets: {
32
+ heading: "Pagamento rápido",
33
+ googlePay: "Pagar com Google Pay",
34
+ applePay: "Pagar com Apple Pay",
35
+ or: "ou escolha como pagar",
36
+ },
31
37
  handOff: {
32
38
  label: "Pix ou cartão",
33
39
  hint: (provider) =>
@@ -41,6 +47,8 @@ export const PT_BR_PAYMENT_LIST_COPY: PaymentListCopy = {
41
47
  name: "Nome",
42
48
  email: "E-mail",
43
49
  why: (provider) => (provider ? `(${the(provider)} pede para pagar pelo app)` : "(pedido para pagar pelo app)"),
50
+ cpfHint: (provider) => (provider ? `${the(provider)} pede para pagar pelo app` : "pedido para pagar pelo app"),
51
+ keepSavedCpf: "Manter o CPF salvo",
44
52
  cpfIncomplete: "O CPF tem 11 dígitos.",
45
53
  phoneIncomplete: "O celular precisa de DDD e 9 dígitos.",
46
54
  },
@@ -0,0 +1,120 @@
1
+ import { Box } from "@mui/material";
2
+ import { createContext, useContext, useMemo, useState, type JSX, type ReactNode } from "react";
3
+ import { createPortal } from "react-dom";
4
+
5
+ import { useCheckoutCopy } from "../copy-context";
6
+ import { SummaryLines, SummaryTotal, type CheckoutSummaryLine } from "../payment-summary";
7
+ import { useCheckoutLayout } from "../payment-step-layout";
8
+ import { useCheckoutComponents } from "../ui";
9
+
10
+ /**
11
+ * Where the bar's content goes on a computer (FUT-3685, L4): into the
12
+ * "Resumo do pedido" column — an element the column draws, which the bar's
13
+ * content is portalled into — or `null` on a phone, where the bar stays the
14
+ * host's sticky `ActionBar` (L2).
15
+ */
16
+ const ColumnTarget = createContext<{ element: HTMLElement | null } | null>(null);
17
+
18
+ const SummaryColumnTarget = ColumnTarget.Provider;
19
+
20
+ /**
21
+ * `null` for the bar (a phone); otherwise the column's element to portal the
22
+ * bar's content into — itself `null` for the one render before the column
23
+ * mounts, when nothing is drawn rather than a bar that would flash.
24
+ */
25
+ export function useSummaryColumnTarget(): { element: HTMLElement | null } | null {
26
+ return useContext(ColumnTarget);
27
+ }
28
+
29
+ /** The prototype's 680px steps column and 360px summary column, on the theme's spacing scale. */
30
+ const STEPS_MAX = 85;
31
+ const COLUMN_WIDTH = 45;
32
+
33
+ /**
34
+ * The computer's two columns (L4): the steps on the left, at most ~680px, and
35
+ * the "Resumo do pedido" column on the right, ~360px, holding the lines, the
36
+ * fees, "Total", the host's context line and the pay button — so no bar sits
37
+ * at the bottom of the screen.
38
+ */
39
+ function TwoColumns({ steps, column }: { steps: ReactNode; column: ReactNode }): JSX.Element {
40
+ return (
41
+ <Box data-testid="list-columns" sx={{ display: "flex", gap: 4, alignItems: "flex-start", justifyContent: "center", width: "100%" }}>
42
+ <Box sx={{ flex: 1, minWidth: 0, maxWidth: (t) => t.spacing(STEPS_MAX) }}>{steps}</Box>
43
+ <Box sx={{ flex: "none", width: (t) => t.spacing(COLUMN_WIDTH), position: "sticky", top: (t) => t.spacing(3) }}>{column}</Box>
44
+ </Box>
45
+ );
46
+ }
47
+
48
+ /**
49
+ * "Resumo do pedido" (T20): the cart's lines and the host's money rows (fees,
50
+ * discounts) as the host words them, a hairline, "Total" and the amount — and
51
+ * under them `slot`, the element the bar's content is portalled into.
52
+ */
53
+ function SummaryColumn({ lines, moneyLines, totalLabel, slot }: {
54
+ lines?: CheckoutSummaryLine[];
55
+ /** The host's fee and discount rows (`CheckoutCartView.discountLines`). */
56
+ moneyLines?: ReactNode;
57
+ totalLabel: string;
58
+ slot: (element: HTMLElement | null) => void;
59
+ }): JSX.Element {
60
+ const { Text } = useCheckoutComponents();
61
+ const copy = useCheckoutCopy().screens.layout;
62
+ const shown = lines ?? [];
63
+ return (
64
+ <Box
65
+ component="aside"
66
+ aria-label={copy.summaryTitle}
67
+ data-testid="list-summary-column"
68
+ sx={{ display: "flex", flexDirection: "column", gap: 1.75, p: 2.75, bgcolor: "background.paper", border: "1px solid", borderColor: "divider", borderRadius: 3.5 }}
69
+ >
70
+ <Text variant="heading" size="md" weight="bold" as="h2" style={{ margin: 0 }}>
71
+ {copy.summaryTitle}
72
+ </Text>
73
+ {shown.length > 0 ? <SummaryLines lines={shown} /> : null}
74
+ {moneyLines ? <Box data-testid="list-summary-money">{moneyLines}</Box> : null}
75
+ <Box sx={{ height: "1px", bgcolor: "divider" }} />
76
+ <SummaryTotal totalLabel={totalLabel} />
77
+ <Box ref={slot} data-testid="list-summary-action" sx={{ display: "flex", flexDirection: "column" }} />
78
+ </Box>
79
+ );
80
+ }
81
+
82
+ /**
83
+ * The list face's layout (FUT-3685, L4), in one of three shapes:
84
+ *
85
+ * - the HOST lays out the page (`hostTarget` given — `paymentList.summaryTarget`):
86
+ * the "Resumo do pedido" column is portalled into the host's element, beside
87
+ * the host's own step cards, and the bar's action lands in it. While the
88
+ * element is `null` (not mounted yet) the bar draws nothing rather than flash;
89
+ * - a computer, the host silent: the step and the column side by side, here;
90
+ * - a phone: the step alone, over the host's bar (L2).
91
+ *
92
+ * Only the list face is laid out this way: the Pix screen and the hand-off keep
93
+ * their bar (K9).
94
+ */
95
+ export function ListLayout({ hostTarget, lines, moneyLines, totalLabel, children }: {
96
+ hostTarget: HTMLElement | null | undefined;
97
+ lines?: CheckoutSummaryLine[];
98
+ moneyLines?: ReactNode;
99
+ totalLabel: string;
100
+ children: ReactNode;
101
+ }): JSX.Element {
102
+ const wide = useCheckoutLayout()?.wide ?? false;
103
+ const [slot, setSlot] = useState<HTMLElement | null>(null);
104
+ const target = useMemo(() => ({ element: slot }), [slot]);
105
+ const column = <SummaryColumn lines={lines} moneyLines={moneyLines} totalLabel={totalLabel} slot={setSlot} />;
106
+ if (hostTarget !== undefined) {
107
+ return (
108
+ <SummaryColumnTarget value={target}>
109
+ {children}
110
+ {hostTarget ? createPortal(column, hostTarget) : null}
111
+ </SummaryColumnTarget>
112
+ );
113
+ }
114
+ if (!wide) return <>{children}</>;
115
+ return (
116
+ <SummaryColumnTarget value={target}>
117
+ <TwoColumns steps={children} column={column} />
118
+ </SummaryColumnTarget>
119
+ );
120
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The CPF asked ONCE, at the top of the Pagamento step (FUT-3685, contract E1,
3
+ * E2, E3, R2, R3).
4
+ *
5
+ * PagBank charges a wallet as a card and requires the buyer's tax id on every
6
+ * order, and neither wallet's sheet can supply one. So the CPF cannot wait
7
+ * inside the chosen row any more: a wallet button is pressed with no row
8
+ * chosen at all. It is asked above the wallets, when ANY way to pay the list
9
+ * offers online needs it under the chain rule (FUT-526) — the union over the
10
+ * offered methods, never the chosen row (R2) — and it gates the online ways to
11
+ * pay only: a door row never needs it (R3).
12
+ *
13
+ * Every other field a method asks for (the hand-off provider's mobile, K4)
14
+ * stays inside its own row.
15
+ */
16
+ import { buyerFieldsFor, fieldSatisfied } from "../buyer-fields";
17
+ import type { BuyerInfo, CheckoutChainLink, CheckoutCustomerField, PaymentMethod } from "../types";
18
+
19
+ /** Whether a field asks for the buyer's CPF. */
20
+ export function isTaxId(field: Pick<CheckoutCustomerField, "key">): boolean {
21
+ return field.key === "taxId";
22
+ }
23
+
24
+ /** The required CPF the chain asks for under any of `methods`, or `null` when none does. */
25
+ function requiredTaxId(
26
+ chain: readonly CheckoutChainLink[] | undefined,
27
+ methods: readonly PaymentMethod[],
28
+ ): CheckoutCustomerField | null {
29
+ for (const method of methods) {
30
+ const field = buyerFieldsFor(chain, method).find((candidate) => isTaxId(candidate) && candidate.required);
31
+ if (field) return field;
32
+ }
33
+ return null;
34
+ }
35
+
36
+ /**
37
+ * The CPF field the top of Pagamento shows, or `null` for none (E1, E2).
38
+ *
39
+ * - Decided against what the buyer ARRIVED with, never what they are typing,
40
+ * so the field does not vanish under their thumb as it completes.
41
+ * - A buyer with a CPF on file sees no field — until they press "Alterar" on
42
+ * the payer line (`revealed`, E2 → E1). That field opens holding the CPF
43
+ * the host passed (masked, editable) and is OPTIONAL: empty means "keep the
44
+ * CPF the store holds" — a host that does not pass it (the field opens
45
+ * empty) must not strand the buyer — and only a non-empty CPF that is not
46
+ * valid holds the online ways to pay, with the reason (E3, K6). "Manter o
47
+ * CPF salvo" closes it again.
48
+ */
49
+ export function topCpfField(input: {
50
+ chain: readonly CheckoutChainLink[] | undefined;
51
+ /** Every online method the list offers, wallets included (they raise a CARD order). */
52
+ methods: readonly PaymentMethod[];
53
+ arrivedWith: BuyerInfo | undefined;
54
+ taxIdOnFile: boolean;
55
+ revealed: boolean;
56
+ }): CheckoutCustomerField | null {
57
+ const field = requiredTaxId(input.chain, input.methods);
58
+ if (!field) return null;
59
+ if (input.taxIdOnFile) return input.revealed ? { ...field, required: false } : null;
60
+ if (fieldSatisfied(field, input.arrivedWith?.taxId)) return null;
61
+ return field;
62
+ }
63
+
64
+ /**
65
+ * Whether the top CPF holds a payment raised with `method`: the method's own
66
+ * chain needs it and it is not satisfied — or the buyer typed one that is not
67
+ * valid, which would go out with any online payment.
68
+ */
69
+ export function cpfHolds(
70
+ top: CheckoutCustomerField | null,
71
+ buyer: BuyerInfo,
72
+ chain: readonly CheckoutChainLink[] | undefined,
73
+ method: PaymentMethod,
74
+ ): boolean {
75
+ if (!top || fieldSatisfied(top, buyer.taxId)) return false;
76
+ const typed = (buyer.taxId ?? "").trim() !== "";
77
+ return typed || requiredTaxId(chain, [method]) !== null;
78
+ }
79
+
80
+ /**
81
+ * The provider the hint names — "o PagBank pede para pagar pelo app" (T3): the
82
+ * first chain member that asks for the CPF under one of `methods` (or declares
83
+ * nothing, which reads as asking for it), else the chain head. `null` when the
84
+ * member published no name.
85
+ */
86
+ export function cpfProvider(
87
+ chain: readonly CheckoutChainLink[] | undefined,
88
+ methods: readonly PaymentMethod[],
89
+ ): string | null {
90
+ const asks = (link: CheckoutChainLink): boolean =>
91
+ link.customerSchema === undefined || requiredTaxId([link], methods) !== null;
92
+ const link = chain?.find(asks) ?? chain?.[0];
93
+ return link?.displayName?.trim() || null;
94
+ }