@12-apps/payments-frontend 3.37.0 → 3.38.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 (33) hide show
  1. package/package.json +1 -1
  2. package/src/components/checkout/card-sources.ts +131 -0
  3. package/src/components/checkout/card-view.tsx +6 -2
  4. package/src/components/checkout/cart-changed.tsx +26 -15
  5. package/src/components/checkout/checkout-actions.ts +18 -0
  6. package/src/components/checkout/checkout-flow.tsx +24 -77
  7. package/src/components/checkout/google-pay-button.tsx +4 -4
  8. package/src/components/checkout/list/buyer-inline.tsx +125 -0
  9. package/src/components/checkout/list/charge-runners.tsx +156 -0
  10. package/src/components/checkout/list/choice-list.tsx +156 -0
  11. package/src/components/checkout/list/choice.ts +193 -0
  12. package/src/components/checkout/list/context.tsx +55 -0
  13. package/src/components/checkout/list/en-US.ts +61 -0
  14. package/src/components/checkout/list/list-bar.tsx +186 -0
  15. package/src/components/checkout/list/list-face.tsx +315 -0
  16. package/src/components/checkout/list/list-pix.tsx +206 -0
  17. package/src/components/checkout/list/online-content.tsx +137 -0
  18. package/src/components/checkout/list/option-row.tsx +123 -0
  19. package/src/components/checkout/list/payment-list-step.tsx +166 -0
  20. package/src/components/checkout/list/pt-BR.ts +77 -0
  21. package/src/components/checkout/list/types.ts +187 -0
  22. package/src/components/checkout/list/use-capability.ts +53 -0
  23. package/src/components/checkout/list/use-list-charge.ts +161 -0
  24. package/src/components/checkout/list/use-list-state.ts +152 -0
  25. package/src/components/checkout/payment-phase.tsx +119 -0
  26. package/src/components/checkout/pix-parts.tsx +3 -0
  27. package/src/components/checkout/pix-view.tsx +53 -20
  28. package/src/components/checkout/use-card-checkout.ts +11 -78
  29. package/src/components/checkout/use-checkout-controller.ts +3 -1
  30. package/src/flows/create-payment-flows.tsx +7 -0
  31. package/src/flows/types.ts +7 -0
  32. package/src/index.ts +4 -5
  33. package/src/locales.ts +13 -0
@@ -0,0 +1,156 @@
1
+ /**
2
+ * The two charges the one list's button starts once an order is held
3
+ * (FUT-3592): the CARD — the buyer's saved card or the one they typed — and
4
+ * GOOGLE PAY's sheet. Each is the existing money path (`useCardSubmit`,
5
+ * `useWalletCharge`), run with no button of its own: the list's bar already
6
+ * was the button, and its 5-second window the buyer's chance to stop it.
7
+ *
8
+ * Mounted only while a charge is under way, so a runner never outlives the
9
+ * charge it was started for; what it learned on the way out — a refusal, an
10
+ * accepted charge — goes back to the list through its callbacks.
11
+ */
12
+ import { useEffect, useRef, type JSX } from "react";
13
+
14
+ import type { CardChainLink } from "../method-capability";
15
+ import { NEW_CARD, type CardTokenizationConfig } from "../../../card";
16
+ import { useRefreshOnCartChanged } from "../cart-changed";
17
+ import { SubmittedState } from "../card-view";
18
+ import type { ChallengeScope } from "../card-outcome";
19
+ import { useCheckoutCopy } from "../copy-context";
20
+ import type { BuyerInfo, CheckoutOrder, OnCheckoutResolved } from "../types";
21
+ import { useCardSubmit } from "../use-card-checkout";
22
+ import { useWalletCharge } from "../use-wallet-charge";
23
+
24
+ import type { ListCard } from "./use-list-state";
25
+
26
+ /** A charge that came back refused or unresolved — what the list shows beside the card. */
27
+ export interface ListChargeFailure {
28
+ message: string;
29
+ code: string | null;
30
+ }
31
+
32
+ interface RunnerCallbacks {
33
+ /** Accepted, and now awaited — the list gives way to the wait. */
34
+ onAccepted: () => void;
35
+ /** Refused, unresolved, or never sent — back to the list, saying why (or not, for a dismissal). */
36
+ onFailed: (failure: ListChargeFailure | null) => void;
37
+ }
38
+
39
+ /** Press "Pagar" once, as soon as the order's card key is in hand. */
40
+ function useFireOnce(ready: boolean, fire: () => void): void {
41
+ const fired = useRef(false);
42
+ const latest = useRef(fire);
43
+ latest.current = fire;
44
+ useEffect(() => {
45
+ if (fired.current || !ready) return;
46
+ fired.current = true;
47
+ latest.current();
48
+ }, [ready]);
49
+ }
50
+
51
+ /** The card, charged against the held order. Renders the wait once the charge is accepted. */
52
+ export function CardRun({
53
+ order,
54
+ buyer,
55
+ card,
56
+ config,
57
+ chain,
58
+ scope,
59
+ onResolved,
60
+ pollIntervalMs,
61
+ onAccepted,
62
+ onFailed,
63
+ }: RunnerCallbacks & {
64
+ order: CheckoutOrder;
65
+ buyer: BuyerInfo;
66
+ card: ListCard;
67
+ config: CardTokenizationConfig;
68
+ chain: readonly CardChainLink[];
69
+ scope: ChallengeScope;
70
+ onResolved: OnCheckoutResolved;
71
+ pollIntervalMs: number;
72
+ }): JSX.Element | null {
73
+ const fieldCopy = useCheckoutCopy().card.fields;
74
+ const usingNewCard = card.selection === NEW_CARD;
75
+ const submit = useCardSubmit(
76
+ order,
77
+ buyer,
78
+ config,
79
+ onResolved,
80
+ pollIntervalMs,
81
+ {
82
+ card: card.card,
83
+ usingNewCard,
84
+ selection: card.selection ?? NEW_CARD,
85
+ saveCard: card.saveCard,
86
+ // The button was only live with every field valid; re-checked here
87
+ // because a submit that skipped it would tokenize garbage.
88
+ validate: () => ({
89
+ number: card.complete ? undefined : fieldCopy.numberInvalid,
90
+ }),
91
+ setFieldErrors: card.setFieldErrors,
92
+ },
93
+ chain,
94
+ scope,
95
+ );
96
+ useFireOnce(!submit.keyPending, () => void submit.handlePay());
97
+ // Re-priced by the host when the server says the cart moved (FUT-1139).
98
+ useRefreshOnCartChanged(submit.errorCode);
99
+ const { error, errorCode, submitted } = submit;
100
+ useEffect(() => {
101
+ if (error) onFailed({ message: error, code: errorCode });
102
+ }, [error, errorCode, onFailed]);
103
+ useEffect(() => {
104
+ if (submitted) onAccepted();
105
+ }, [submitted, onAccepted]);
106
+ return submitted ? <SubmittedState card={submit} /> : null;
107
+ }
108
+
109
+ /**
110
+ * Google Pay: the sheet was opened INSIDE the buyer's tap (`sheet`, priced at
111
+ * `sheetCents`) while the order was raised; its key is charged against that
112
+ * order only if the order costs what the sheet showed. A closed sheet sends
113
+ * nothing more.
114
+ */
115
+ export function WalletRun({
116
+ order,
117
+ buyer,
118
+ sheet,
119
+ sheetCents,
120
+ scope,
121
+ onResolved,
122
+ pollIntervalMs,
123
+ onAccepted,
124
+ onFailed,
125
+ }: RunnerCallbacks & {
126
+ order: CheckoutOrder;
127
+ buyer: BuyerInfo;
128
+ /** The sheet's answer: the wallet key, or `null` when the buyer closed it. */
129
+ sheet: Promise<string | null>;
130
+ sheetCents: number;
131
+ scope: ChallengeScope;
132
+ onResolved: OnCheckoutResolved;
133
+ pollIntervalMs: number;
134
+ }): JSX.Element | null {
135
+ const words = useCheckoutCopy().screens.wallet.googlePay;
136
+ const wallet = useWalletCharge(order, buyer, onResolved, pollIntervalMs, scope);
137
+ useFireOnce(true, () => {
138
+ const refused = { message: words.cannotComplete, code: null };
139
+ sheet
140
+ .then((key) => {
141
+ if (key === null) return onFailed(null);
142
+ // The sheet showed one amount; never charge another under it.
143
+ if (order.totalCents !== sheetCents) return onFailed(refused);
144
+ return void wallet.payWithKey("GOOGLE_PAY", key);
145
+ })
146
+ .catch(() => onFailed(refused));
147
+ });
148
+ const { error, errorCode, phase } = wallet;
149
+ useEffect(() => {
150
+ if (error) onFailed({ message: error, code: errorCode });
151
+ }, [error, errorCode, onFailed]);
152
+ useEffect(() => {
153
+ if (phase === "polling") onAccepted();
154
+ }, [phase, onAccepted]);
155
+ return phase === "idle" ? null : <SubmittedState card={wallet} />;
156
+ }
@@ -0,0 +1,156 @@
1
+ import { Box } from "@mui/material";
2
+ import { useId, type JSX, type ReactNode } from "react";
3
+
4
+ import { useCheckoutCopy } from "../copy-context";
5
+ import type { BuyerInfo } from "../types";
6
+ import { useCheckoutComponents } from "../ui";
7
+
8
+ import { cardName, type OnlineRow } from "./choice";
9
+ import type { ListChargeFailure } from "./charge-runners";
10
+ import { OnlineContent, opensContent } from "./online-content";
11
+ import { GroupHeading, OptionRow } from "./option-row";
12
+ import type { CheckoutDoorOption, CheckoutOnlineChoice, PaymentListCopy } from "./types";
13
+ import type { ListState } from "./use-list-state";
14
+
15
+ /** An online row's label and hint, worded for what the store and the buyer hold. */
16
+ function onlineWords(
17
+ method: CheckoutOnlineChoice,
18
+ copy: PaymentListCopy,
19
+ state: ListState,
20
+ provider: string | null,
21
+ ): { label: string; hint: string } {
22
+ if (method === "PIX") return copy.pix;
23
+ if (method === "GOOGLE_PAY") return copy.googlePay;
24
+ if (method === "HAND_OFF") return { label: copy.handOff.label, hint: copy.handOff.hint(provider) };
25
+ const head = state.card.saved[0];
26
+ return {
27
+ label: copy.card.label,
28
+ hint: head ? copy.card.hintSaved(cardName(copy, head)) : copy.card.hintNew,
29
+ };
30
+ }
31
+
32
+ /** The hint of a card the store declares and this browser cannot take — today's words (N9). */
33
+ function useUnavailableHint(): string {
34
+ return useCheckoutCopy().screens.method.unavailableHere;
35
+ }
36
+
37
+ /** What every row of the list is drawn from. */
38
+ interface RowProps {
39
+ name: string;
40
+ copy: PaymentListCopy;
41
+ state: ListState;
42
+ buyer: BuyerInfo;
43
+ onBuyerChange: (buyer: BuyerInfo) => void;
44
+ provider: string | null;
45
+ failure: ListChargeFailure | null;
46
+ }
47
+
48
+ /** One of the store's online options, with what opens inside it. */
49
+ function OnlineOptionRow({ row, props }: { row: OnlineRow; props: RowProps }): JSX.Element {
50
+ const { copy, state, provider } = props;
51
+ const unavailable = useUnavailableHint();
52
+ const words = onlineWords(row.method, copy, state, provider);
53
+ const chosen = state.choice?.kind === "online" && state.choice.method === row.method;
54
+ return (
55
+ <OptionRow
56
+ name={props.name}
57
+ value={row.method}
58
+ chosen={chosen}
59
+ disabled={row.disabled}
60
+ label={words.label}
61
+ hint={row.disabled ? unavailable : words.hint}
62
+ onChoose={() => state.choose({ kind: "online", method: row.method })}
63
+ testId={`list-option-${row.method}`}
64
+ >
65
+ {opensContent(row.method, state.asked, props.failure) ? (
66
+ <OnlineContent
67
+ method={row.method}
68
+ asked={state.asked}
69
+ buyer={props.buyer}
70
+ onBuyerChange={props.onBuyerChange}
71
+ card={state.card}
72
+ copy={copy}
73
+ provider={provider}
74
+ failure={props.failure}
75
+ />
76
+ ) : null}
77
+ </OptionRow>
78
+ );
79
+ }
80
+
81
+ /** One of the host's door options: its words, and its content opened inside it. */
82
+ function DoorOptionRow({ option, props }: { option: CheckoutDoorOption; props: RowProps }): JSX.Element {
83
+ const { choice } = props.state;
84
+ const chosen = choice?.kind === "door" && choice.id === option.id;
85
+ return (
86
+ <OptionRow
87
+ name={props.name}
88
+ value={`door:${option.id}`}
89
+ chosen={chosen}
90
+ label={option.label}
91
+ hint={chosen && option.hideHintWhileChosen ? undefined : option.hint}
92
+ onChoose={() => props.state.choose({ kind: "door", id: option.id })}
93
+ testId={`list-door-${option.id}`}
94
+ >
95
+ {option.content}
96
+ </OptionRow>
97
+ );
98
+ }
99
+
100
+ /** One heading and the rows under it, as a group named by the heading. */
101
+ function Group({ id, heading, children }: { id: string; heading: string; children: ReactNode }): JSX.Element {
102
+ return (
103
+ <Box role="group" aria-labelledby={id} sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
104
+ <GroupHeading id={id}>{heading}</GroupHeading>
105
+ {children}
106
+ </Box>
107
+ );
108
+ }
109
+
110
+ /**
111
+ * "Como você quer pagar?" — every way to pay as ONE radio group under two
112
+ * headings (FUT-3591, M6): the store's online options, then the host's door
113
+ * options. Choosing a row only chooses it; the bar's button acts.
114
+ *
115
+ * One radio `name` across both groups keeps them one choice for the keyboard;
116
+ * each heading names its own `role="group"`, and what opens inside a row sits
117
+ * beside its radio's label, never inside it.
118
+ */
119
+ export function ChoiceList({
120
+ door,
121
+ locked,
122
+ ...rest
123
+ }: Omit<RowProps, "name"> & {
124
+ door: readonly CheckoutDoorOption[];
125
+ /** A charge or a send is under way: the list is inert. */
126
+ locked: boolean;
127
+ }): JSX.Element {
128
+ const { Text } = useCheckoutComponents();
129
+ const base = useId();
130
+ const props: RowProps = { ...rest, name: `${base}-choice` };
131
+ const { copy, state } = props;
132
+ return (
133
+ <Box
134
+ data-testid="payment-list"
135
+ data-locked={locked ? "true" : "false"}
136
+ aria-disabled={locked || undefined}
137
+ inert={locked || undefined}
138
+ sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}
139
+ >
140
+ <Text variant="body" size="md" weight="bold" as="p" style={{ margin: 0 }}>
141
+ {copy.title}
142
+ </Text>
143
+ {/* Each heading only over a group that has rows (a door-only store shows the door one alone). */}
144
+ {state.rows.length > 0 ? (
145
+ <Group id={`${base}-online`} heading={copy.onlineHeading}>
146
+ {state.rows.map((row) => <OnlineOptionRow key={row.method} row={row} props={props} />)}
147
+ </Group>
148
+ ) : null}
149
+ {door.length > 0 ? (
150
+ <Group id={`${base}-door`} heading={copy.doorHeading}>
151
+ {door.map((option) => <DoorOptionRow key={option.id} option={option} props={props} />)}
152
+ </Group>
153
+ ) : null}
154
+ </Box>
155
+ );
156
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * The one list's decisions, as pure functions (FUT-3591): which online rows
3
+ * the store's chain offers, what is chosen before the buyer touches anything,
4
+ * which of the buyer's own details must be asked inside the option, and why
5
+ * the bar's button may not act yet. Nothing here renders or fetches, so every
6
+ * rule is a unit test away.
7
+ */
8
+ import {
9
+ NEW_CARD,
10
+ validateCardNumber,
11
+ validateCvv,
12
+ validateExpiry,
13
+ validateHolder,
14
+ detectBrand,
15
+ onlyDigits,
16
+ type CardDetails,
17
+ type SavedCard,
18
+ } from "../../../card";
19
+ import type { CardFieldCopy } from "../../../card/copy";
20
+ import { buyerFieldsFor, fieldSatisfied } from "../buyer-fields";
21
+ import { handOffMethod } from "../method-capability";
22
+ import type { BuyerInfo, CheckoutChainLink, CheckoutCustomerField, PaymentMethod } from "../types";
23
+
24
+ import type { CheckoutDoorOption, CheckoutListChoice, CheckoutOnlineChoice, PaymentListCopy } from "./types";
25
+
26
+ /** One online row: what it is, and whether this browser can take it. */
27
+ export interface OnlineRow {
28
+ method: CheckoutOnlineChoice;
29
+ /** A card the store declares but this browser cannot mint for — drawn, not choosable. */
30
+ disabled: boolean;
31
+ }
32
+
33
+ /** What the chain lets this browser offer, as the list needs it. */
34
+ export interface OnlineCapability {
35
+ /** The store finishes on the provider's own page. */
36
+ handOff: boolean;
37
+ /** The methods the chain declares, or `null` while unknown (fails OPEN). */
38
+ offered: PaymentMethod[] | null;
39
+ cardUnavailable: boolean;
40
+ /** Google Pay is declared AND this browser's `isReadyToPay` said yes. */
41
+ googlePayReady: boolean;
42
+ }
43
+
44
+ /**
45
+ * The "PAGAR AGORA, PELO APP" rows, in the prototype's order: Pix, card,
46
+ * Google Pay — or the single hand-off row for a store that takes the method
47
+ * on its own page (N18).
48
+ */
49
+ export function onlineRows(cap: OnlineCapability): OnlineRow[] {
50
+ if (cap.handOff) return [{ method: "HAND_OFF", disabled: false }];
51
+ const offers = (method: PaymentMethod): boolean => cap.offered === null || cap.offered.includes(method);
52
+ const rows: OnlineRow[] = [];
53
+ if (offers("PIX")) rows.push({ method: "PIX", disabled: false });
54
+ if (offers("CARD")) rows.push({ method: "CARD", disabled: cap.cardUnavailable });
55
+ if (offers("CARD") && !cap.cardUnavailable && cap.googlePayReady) rows.push({ method: "GOOGLE_PAY", disabled: false });
56
+ return rows;
57
+ }
58
+
59
+ /** The chain method an online choice raises — what `createOrder` is asked for. */
60
+ export function raisedMethod(choice: CheckoutOnlineChoice, offered: PaymentMethod[] | null): PaymentMethod {
61
+ if (choice === "PIX") return "PIX";
62
+ if (choice === "HAND_OFF") return handOffMethod(offered);
63
+ return "CARD";
64
+ }
65
+
66
+ /**
67
+ * What is chosen before the buyer touches anything (M7, owner 2026-10-15): a
68
+ * saved card makes "Cartão de crédito" the choice with that card selected —
69
+ * the list's head, which the host orders last-used first. Nothing else is ever
70
+ * preselected: never Pix, never a door tender (FUT-1478), and not a store's
71
+ * only method either (that pick used to raise an order with no tap at all).
72
+ *
73
+ * A buyer back here because a card was REFUSED (`fresh`) keeps the card
74
+ * chosen, with no saved card selected for them — the one this would pick is
75
+ * the one that failed (FUT-1145).
76
+ */
77
+ export function preselection(
78
+ rows: readonly OnlineRow[],
79
+ saved: readonly SavedCard[],
80
+ fresh: boolean,
81
+ ): { choice: CheckoutListChoice; selection: string | null } | null {
82
+ const card = rows.find((row) => row.method === "CARD" && !row.disabled);
83
+ if (!card) return null;
84
+ if (fresh) return { choice: { kind: "online", method: "CARD" }, selection: saved.length > 0 ? null : NEW_CARD };
85
+ const head = saved[0];
86
+ if (!head) return null;
87
+ return { choice: { kind: "online", method: "CARD" }, selection: head.id };
88
+ }
89
+
90
+ /**
91
+ * The buyer's own details to ask INSIDE the chosen online option (M11): the
92
+ * fields the chain requires for that method which the buyer arrived without.
93
+ * Decided against what the buyer ARRIVED with, never against what they are
94
+ * typing, so the field does not vanish under their thumb as it completes.
95
+ * With a CPF on file the CPF is never asked — the server holds it.
96
+ */
97
+ export function askedFields(input: {
98
+ chain: readonly CheckoutChainLink[] | undefined;
99
+ method: PaymentMethod;
100
+ arrivedWith: BuyerInfo | undefined;
101
+ taxIdOnFile: boolean;
102
+ }): CheckoutCustomerField[] {
103
+ return buyerFieldsFor(input.chain, input.method).filter(
104
+ (field) =>
105
+ field.required &&
106
+ !(field.key === "taxId" && input.taxIdOnFile) &&
107
+ !fieldSatisfied(field, input.arrivedWith?.[field.key]),
108
+ );
109
+ }
110
+
111
+ /** Every one of the four card fields is valid — the button's question. */
112
+ export function cardComplete(card: CardDetails, copy: CardFieldCopy): boolean {
113
+ const brand = detectBrand(onlyDigits(card.number));
114
+ return (
115
+ !validateCardNumber(card.number, copy) &&
116
+ !validateHolder(card.holder, copy) &&
117
+ !validateExpiry(card.expiry, copy) &&
118
+ !validateCvv(card.cvv, copy, brand)
119
+ );
120
+ }
121
+
122
+ /** The reason for the first of the asked fields the buyer has not satisfied yet. */
123
+ function buyerReason(
124
+ asked: readonly CheckoutCustomerField[],
125
+ buyer: BuyerInfo,
126
+ reasons: PaymentListCopy["reasons"],
127
+ ): string | null {
128
+ const missing = asked.find((field) => !fieldSatisfied(field, buyer[field.key]));
129
+ if (!missing) return null;
130
+ if (missing.key === "taxId") return reasons.cpf;
131
+ if (missing.key === "phone") return reasons.phone;
132
+ return reasons.details;
133
+ }
134
+
135
+ /** What the card option still needs: a card chosen, and a typed one complete. */
136
+ function cardReason(
137
+ selection: string | null,
138
+ complete: boolean,
139
+ reasons: PaymentListCopy["reasons"],
140
+ ): string | null {
141
+ if (selection === null) return reasons.chooseCard;
142
+ if (selection === NEW_CARD && !complete) return reasons.cardDetails;
143
+ return null;
144
+ }
145
+
146
+ /**
147
+ * Why the bar's button cannot act, in the order the prototype checks
148
+ * (FN:447-456): the host's hold, nothing chosen, the buyer's details, the
149
+ * card — and for a door option, the host's own reason. `null` ⇒ it can act.
150
+ */
151
+ export function blockedReason(input: {
152
+ copy: PaymentListCopy;
153
+ hold: string | null;
154
+ choice: CheckoutListChoice | null;
155
+ door: CheckoutDoorOption | undefined;
156
+ asked: readonly CheckoutCustomerField[];
157
+ buyer: BuyerInfo;
158
+ selection: string | null;
159
+ cardIsComplete: boolean;
160
+ }): string | null {
161
+ const { copy, choice } = input;
162
+ if (input.hold) return input.hold;
163
+ if (!choice) return copy.reasons.choose;
164
+ if (choice.kind === "door") return input.door ? input.door.blocked : copy.reasons.choose;
165
+ const buyer = buyerReason(input.asked, input.buyer, copy.reasons);
166
+ if (buyer) return buyer;
167
+ if (choice.method !== "CARD") return null;
168
+ return cardReason(input.selection, input.cardIsComplete, copy.reasons);
169
+ }
170
+
171
+ /** The bar button's verb for what is chosen. */
172
+ export function actionLabel(
173
+ copy: PaymentListCopy,
174
+ choice: CheckoutListChoice | null,
175
+ door: CheckoutDoorOption | undefined,
176
+ provider: string | null,
177
+ ): string {
178
+ if (!choice) return copy.actions.choose;
179
+ if (choice.kind === "door") return door?.action ?? copy.actions.choose;
180
+ const verbs: Record<CheckoutOnlineChoice, string> = {
181
+ PIX: copy.actions.pix,
182
+ CARD: copy.actions.card,
183
+ GOOGLE_PAY: copy.actions.googlePay,
184
+ HAND_OFF: copy.actions.handOff(provider),
185
+ };
186
+ return verbs[choice.method];
187
+ }
188
+
189
+ /** A saved card's name — "Visa final 4242", or "Cartão final 4242" when the network is unknown. */
190
+ export function cardName(copy: PaymentListCopy, card: Pick<SavedCard, "brand" | "last4">): string {
191
+ const brand = card.brand.trim();
192
+ return copy.savedCard(brand && brand.toLowerCase() !== "unknown" ? brand : null, card.last4);
193
+ }
@@ -0,0 +1,55 @@
1
+ import { createContext, useContext, useMemo, useState, type JSX, type ReactNode } from "react";
2
+
3
+ import type { CheckoutPaymentList } from "./types";
4
+
5
+ /**
6
+ * What the one list shares with the panes it mounts (FUT-3591/3592): its
7
+ * config — the words and the host's bar line — and the one piece of UI state a
8
+ * pane and the list both touch, whether the Pix QR is open on a phone.
9
+ *
10
+ * `null` outside list mode, which is how a pane composed for the tabs layout
11
+ * (or by hand) knows to keep the face it always had.
12
+ */
13
+ export interface PaymentListMode {
14
+ list: CheckoutPaymentList;
15
+ /** The QR is open on a narrow screen ("Ver QR code (pagar de outro celular)"). */
16
+ qrOpen: boolean;
17
+ setQrOpen: (open: boolean) => void;
18
+ /** "Trocar a forma de pagamento": leave the raised Pix for the list. */
19
+ onLeavePix: () => void;
20
+ /**
21
+ * The Dados step's "Salvar meus dados para a próxima compra" (LGPD), offered
22
+ * wherever the list asks the buyer's details instead — same words, same
23
+ * default — because the list skips that step.
24
+ */
25
+ consent: ListConsent;
26
+ }
27
+
28
+ export interface ListConsent {
29
+ label: string;
30
+ checked: boolean;
31
+ onChange: (checked: boolean) => void;
32
+ }
33
+
34
+ const PaymentListContext = createContext<PaymentListMode | null>(null);
35
+
36
+ export function PaymentListProvider({
37
+ list,
38
+ onLeavePix,
39
+ consent,
40
+ children,
41
+ }: {
42
+ list: CheckoutPaymentList;
43
+ onLeavePix: () => void;
44
+ consent: ListConsent;
45
+ children: ReactNode;
46
+ }): JSX.Element {
47
+ const [qrOpen, setQrOpen] = useState(false);
48
+ const value = useMemo(() => ({ list, qrOpen, setQrOpen, onLeavePix, consent }), [list, qrOpen, onLeavePix, consent]);
49
+ return <PaymentListContext.Provider value={value}>{children}</PaymentListContext.Provider>;
50
+ }
51
+
52
+ /** The list mode a pane is rendered in, or `null` for the tabs layout. */
53
+ export function usePaymentListMode(): PaymentListMode | null {
54
+ return useContext(PaymentListContext);
55
+ }
@@ -0,0 +1,61 @@
1
+ import type { PaymentListCopy } from "./types";
2
+
3
+ /** The one-list Pagamento step's words, en-US (FUT-3591, FUT-3592). */
4
+ export const EN_US_PAYMENT_LIST_COPY: PaymentListCopy = {
5
+ title: "How would you like to pay?",
6
+ onlineHeading: "Pay now, in the app",
7
+ doorHeading: "Pay on delivery",
8
+ pix: { label: "Pix", hint: "Get a code to copy and paste" },
9
+ card: {
10
+ label: "Credit card",
11
+ hintSaved: (card) => `${card} or another card`,
12
+ hintNew: "Enter your card details",
13
+ },
14
+ googlePay: { label: "Google Pay", hint: "With the card saved in your Google account" },
15
+ handOff: {
16
+ label: "Pix or card",
17
+ hint: (provider) =>
18
+ provider ? `You pay on ${provider}'s page and come back` : "You pay on the payment page and come back",
19
+ },
20
+ savedCard: (brand, last4) => `${brand ?? "Card"} ending ${last4}`,
21
+ otherCard: "Another card",
22
+ buyer: {
23
+ cpf: "CPF",
24
+ phone: "Mobile",
25
+ name: "Name",
26
+ email: "Email",
27
+ why: (provider) => (provider ? `(${provider} asks for it to pay in the app)` : "(needed to pay in the app)"),
28
+ cpfIncomplete: "A CPF has 11 digits.",
29
+ phoneIncomplete: "The mobile number needs the area code and 9 digits.",
30
+ },
31
+ reasons: {
32
+ choose: "Choose how to pay.",
33
+ cpf: "Enter your CPF to pay in the app.",
34
+ phone: "Enter your mobile number to pay in the app.",
35
+ details: "Fill in your details to pay in the app.",
36
+ chooseCard: "Choose the card.",
37
+ cardDetails: "Fill in the card details.",
38
+ },
39
+ actions: {
40
+ choose: "Choose how to pay",
41
+ card: "Pay",
42
+ pix: "Create Pix",
43
+ googlePay: "Pay with Google Pay",
44
+ handOff: (provider) => (provider ? `Go to ${provider}` : "Go to payment"),
45
+ },
46
+ payer: (name) => `Paying as ${name} · CPF ✓`,
47
+ payerChange: "Change",
48
+ charging: (total, card) => `Paying ${total} with ${card ?? "the card entered"}…`,
49
+ cancel: "Cancel",
50
+ pixFace: {
51
+ title: (total) => `Pay ${total} with Pix`,
52
+ validUntil: (time) =>
53
+ `Valid until ${time}. Copy the code and paste it in your bank app, under Pix copy and paste.`,
54
+ showQr: "Show QR code (pay from another phone)",
55
+ hideQr: "Hide QR code",
56
+ changeMethod: "Change payment method",
57
+ copyCode: "Copy Pix code",
58
+ waiting: "Waiting for the Pix payment…",
59
+ },
60
+ handOffWaiting: (provider) => (provider ? `Waiting for ${provider}…` : "Waiting for the payment…"),
61
+ };