@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/payments-frontend",
3
- "version": "3.43.1",
3
+ "version": "3.45.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Browser half of the vendor-agnostic payments platform: plug-and-play MUI components for the per-provider settings page (credential form from each provider's schema, masked hints, verify/enable) and the checkout page (PIX QR + polling, card tokenization, hosted-checkout redirect), plus the headless hooks and fetch clients they build on. Talks only to the host's payments HTTP surface — never to a provider directly. Microfrontend-ready: no app coupling, host injects theme and auth.",
@@ -23,7 +23,7 @@
23
23
  "storybook:build": "storybook build"
24
24
  },
25
25
  "dependencies": {
26
- "@12-apps/payments-backend": "^4.33.1",
26
+ "@12-apps/payments-backend": "^4.34.0",
27
27
  "react-qr-code": "^2.2.0"
28
28
  },
29
29
  "peerDependencies": {
@@ -68,7 +68,7 @@ export interface ApplePaySessionClass {
68
68
  }
69
69
 
70
70
  /** The native constructor, where Safari (or a harness) provides one. */
71
- function applePaySessionClass(): ApplePaySessionClass | null {
71
+ export function applePaySessionClass(): ApplePaySessionClass | null {
72
72
  if (typeof window === "undefined") return null;
73
73
  const scope = window as unknown as { ApplePaySession?: ApplePaySessionClass };
74
74
  return scope.ApplePaySession ?? null;
@@ -89,8 +89,8 @@ export function applePaySupported(): boolean {
89
89
  }
90
90
  }
91
91
 
92
- /** The sheet's payment request, from the server-authoritative order total. */
93
- function paymentRequest(order: CheckoutOrder, copy: ApplePayCopy): ApplePayPaymentRequest {
92
+ /** The sheet's payment request, priced at `cents` — the amount the charge will ask for. */
93
+ function paymentRequest(cents: number, copy: ApplePayCopy): ApplePayPaymentRequest {
94
94
  return {
95
95
  countryCode: "BR",
96
96
  currencyCode: "BRL",
@@ -99,12 +99,25 @@ function paymentRequest(order: CheckoutOrder, copy: ApplePayCopy): ApplePayPayme
99
99
  // The one line Apple's own sheet renders from us, so it is the host's
100
100
  // (FUT-760) — the rest of that sheet is Apple's, in the buyer's own
101
101
  // system language.
102
- total: { label: copy.orderTotal, amount: (order.totalCents / 100).toFixed(2) },
102
+ total: { label: copy.orderTotal, amount: (cents / 100).toFixed(2) },
103
103
  };
104
104
  }
105
105
 
106
- export interface ApplePayButtonProps {
107
- order: CheckoutOrder;
106
+ /**
107
+ * What the sheet is priced from — exactly one of the two:
108
+ *
109
+ * - `order`: the raised order (`order.totalCents`) — the tabs layout's case;
110
+ * - `totalCents`: the amount, in integer cents, when no order exists yet — the
111
+ * one list's case (FUT-3685): the session must start INSIDE the tap, so it
112
+ * is priced with the host's amount due and the order is raised in parallel.
113
+ */
114
+ type ApplePayButtonPrice = { order: CheckoutOrder; totalCents?: never } | { totalCents: number; order?: never };
115
+
116
+ /** {@link ApplePayButton}'s props: its price, and the handlers below. */
117
+ export type ApplePayButtonProps = ApplePayButtonPrice & ApplePayButtonHandlers;
118
+
119
+ /** The handlers every Apple Pay button takes, however it is priced. */
120
+ interface ApplePayButtonHandlers {
108
121
  /**
109
122
  * The payment authorized on the sheet — charge this key. Resolves `true`
110
123
  * when the charge was accepted (paid, or confirming), `false` on a refusal,
@@ -123,40 +136,75 @@ export interface ApplePayButtonProps {
123
136
  validateMerchant?: (validationURL: string) => Promise<unknown>;
124
137
  }
125
138
 
126
- /** Wire one session run. Split from the component for the size gate. */
127
- function runSession(
128
- Session: ApplePaySessionClass,
129
- order: CheckoutOrder,
130
- handlers: ApplePayButtonProps,
131
- copy: ApplePayCopy,
132
- ): void {
133
- const session = new Session(APPLE_PAY_VERSION, paymentRequest(order, copy));
139
+ /** What one started session hands back: the wallet key, and the way to close the sheet. */
140
+ interface ApplePaySheet {
141
+ /** Apple's `token.paymentData`, serialized verbatim — or `null` when the buyer closed the sheet. */
142
+ answer: Promise<string | null>;
143
+ /** Close the sheet with the charge's honest outcome. */
144
+ complete: (accepted: boolean) => void;
145
+ }
146
+
147
+ /**
148
+ * Start ONE Apple Pay session, SYNCHRONOUSLY — call it from inside the buyer's
149
+ * tap, which Safari requires for `new ApplePaySession(…).begin()`. Priced at
150
+ * `cents`; the merchant is validated through the host's port, server-side.
151
+ * A validation that cannot run aborts the session and reports why through
152
+ * `onError` (the answer then resolves `null`).
153
+ */
154
+ export function startApplePaySession(input: {
155
+ Session: ApplePaySessionClass;
156
+ cents: number;
157
+ copy: ApplePayCopy;
158
+ validateMerchant: ((validationURL: string) => Promise<unknown>) | undefined;
159
+ onError: (message: string) => void;
160
+ }): ApplePaySheet {
161
+ const { Session, copy } = input;
162
+ const session = new Session(APPLE_PAY_VERSION, paymentRequest(input.cents, copy));
163
+ let settle: (key: string | null) => void = () => undefined;
164
+ const answer = new Promise<string | null>((resolve) => {
165
+ settle = resolve;
166
+ });
167
+ const fail = (message: string): void => {
168
+ session.abort();
169
+ settle(null);
170
+ input.onError(message);
171
+ };
134
172
  session.onvalidatemerchant = (event) => {
135
- const validate = handlers.validateMerchant;
136
- if (!validate) {
137
- session.abort();
138
- handlers.onError(copy.cannotStart);
139
- return;
140
- }
173
+ const validate = input.validateMerchant;
174
+ if (!validate) return fail(copy.cannotStart);
141
175
  validate(event.validationURL)
142
176
  .then((merchantSession) => session.completeMerchantValidation(merchantSession))
143
- .catch(() => {
144
- session.abort();
145
- handlers.onError(copy.cannotComplete);
146
- });
147
- };
148
- session.onpaymentauthorized = (event) => {
149
- // Apple's `token.paymentData`, serialized VERBATIM — PagBank's `key`.
150
- const key = JSON.stringify(event.payment.token.paymentData);
151
- void handlers.onAuthorized(key).then((accepted) => {
152
- session.completePayment({
153
- status: accepted ? Session.STATUS_SUCCESS : Session.STATUS_FAILURE,
154
- });
155
- });
177
+ .catch(() => fail(copy.cannotComplete));
156
178
  };
179
+ // Apple's `token.paymentData`, serialized VERBATIM — PagBank's `key`.
180
+ session.onpaymentauthorized = (event) => settle(JSON.stringify(event.payment.token.paymentData));
157
181
  // Closing the sheet is a choice, not a failure to report.
158
- session.oncancel = () => undefined;
159
- session.begin();
182
+ session.oncancel = () => settle(null);
183
+ try {
184
+ session.begin();
185
+ } catch (error) {
186
+ // A session Safari would not begin must not stay open behind the next one.
187
+ try {
188
+ session.abort();
189
+ } catch {
190
+ // Nothing more to close.
191
+ }
192
+ throw error;
193
+ }
194
+ const complete = (accepted: boolean): void =>
195
+ session.completePayment({ status: accepted ? Session.STATUS_SUCCESS : Session.STATUS_FAILURE });
196
+ return { answer, complete };
197
+ }
198
+
199
+ /** One session run for the button: charge the authorized key, then close the sheet honestly. */
200
+ function runSession(Session: ApplePaySessionClass, handlers: ApplePayButtonProps, copy: ApplePayCopy): void {
201
+ const cents = handlers.totalCents ?? handlers.order?.totalCents;
202
+ if (cents === undefined) return;
203
+ const sheet = startApplePaySession({ Session, cents, copy, validateMerchant: handlers.validateMerchant, onError: handlers.onError });
204
+ void sheet.answer.then(async (key) => {
205
+ if (key === null) return;
206
+ sheet.complete(await handlers.onAuthorized(key));
207
+ });
160
208
  }
161
209
 
162
210
  /**
@@ -180,7 +228,7 @@ export function ApplePayButton(props: ApplePayButtonProps): JSX.Element | null {
180
228
  type="button"
181
229
  aria-label={copy.payAction}
182
230
  data-testid="apple-pay-button"
183
- onClick={() => runSession(Session, current.current.order, current.current, copy)}
231
+ onClick={() => runSession(Session, current.current, copy)}
184
232
  sx={{
185
233
  WebkitAppearance: "-apple-pay-button",
186
234
  // Apple draws the button; these only give it room. The fallback
@@ -97,7 +97,7 @@ export interface CheckoutFlowProps extends CheckoutHostPorts {
97
97
  * The host's Apple Pay merchant-validation port (FUT-472): exchange the
98
98
  * session's `validationURL` for an Apple merchant session, SERVER-SIDE.
99
99
  * Optional — without it the Apple Pay sheet cannot start, and the card form
100
- * remains the way to pay.
100
+ * remains the way to pay (the one list then offers no Apple Pay at all).
101
101
  */
102
102
  validateApplePayMerchant?: (validationURL: string) => Promise<unknown>;
103
103
  /** Host content shown on the paid confirmation (the storefront's install invite). */
@@ -118,7 +118,8 @@ export interface CheckoutFlowProps extends CheckoutHostPorts {
118
118
  * options and the host's door options in one radio group, nothing raised on
119
119
  * a pick, the bar's button the only way an order is made, and the card
120
120
  * charged after a 5-second bar the buyer can cancel (FUT-3592). The buyer's
121
- * missing CPF is asked inside the option instead of on a Dados step first.
121
+ * missing CPF is asked once at the top of the step, above the wallet buttons
122
+ * (FUT-3685), instead of on a Dados step first.
122
123
  * Absent: the method tabs, the order summary and raise-on-pick, unchanged.
123
124
  * See `./list/types.ts`.
124
125
  */
@@ -26,6 +26,12 @@ export interface GooglePaymentsClient {
26
26
  onClick: () => void;
27
27
  buttonSizeMode?: string;
28
28
  buttonLocale?: string;
29
+ /** "black" | "white" | "default" — Google's own palette for its button. */
30
+ buttonColor?: string;
31
+ /** "pay", "buy", "plain"… — which of Google's labels it draws. */
32
+ buttonType?: string;
33
+ /** The corner radius Google draws, in CSS pixels (Google's own parameter). */
34
+ buttonRadius?: number;
29
35
  }): HTMLElement;
30
36
  loadPaymentData(request: Record<string, unknown>): Promise<GooglePaymentData>;
31
37
  }
@@ -12,9 +12,10 @@ import type { PaymentListCopy } from "./types";
12
12
 
13
13
  /**
14
14
  * The buyer's own details the chosen option needs, asked INSIDE it and ABOVE
15
- * the card fields (M11, the owner's "maybe.. we should ask cpf first"): only
16
- * the ones the store's chain requires for this method and the buyer arrived
17
- * without. A buyer with a CPF on file never sees the field.
15
+ * the card fields (M11): only the ones the store's chain requires for this
16
+ * method and the buyer arrived without — the hand-off provider's mobile (K4).
17
+ * The CPF is no longer one of them: it is asked once, at the top of Pagamento
18
+ * (`./cpf-top.tsx`, FUT-3685).
18
19
  *
19
20
  * When the last of them is complete, `onComplete` fires once — the card
20
21
  * option moves the focus to "Número do cartão" with it, so the buyer's next
@@ -34,18 +35,29 @@ function nameOf(field: CheckoutCustomerField, copy: Copy): string {
34
35
  }
35
36
 
36
37
  /** The sentence under a field the buyer has started and not finished. */
37
- function complaintOf(field: CheckoutCustomerField, value: string, copy: Copy, cpfInvalid: (v: string) => string | undefined): string | undefined {
38
+ export function complaintOf(field: CheckoutCustomerField, value: string, copy: Copy, cpfInvalid: (v: string) => string | undefined): string | undefined {
38
39
  if (!value.trim() || fieldSatisfied(field, value)) return undefined;
39
40
  if (field.key === "taxId") return onlyDigits(value).length < 11 ? copy.cpfIncomplete : cpfInvalid(value);
40
41
  if (field.key === "phone") return copy.phoneIncomplete;
41
42
  return undefined;
42
43
  }
43
44
 
44
- const PLACEHOLDERS: Partial<Record<CheckoutCustomerField["key"], string>> = {
45
+ export const PLACEHOLDERS: Partial<Record<CheckoutCustomerField["key"], string>> = {
45
46
  taxId: "000.000.000-00",
46
47
  phone: "(00) 00000-0000",
47
48
  };
48
49
 
50
+ /** Call `onComplete` once each time `complete` turns true — the focus moves on to what is next. */
51
+ export function useOnComplete(complete: boolean, onComplete: (() => void) | undefined): void {
52
+ const was = useRef(complete);
53
+ const latest = useRef(onComplete);
54
+ latest.current = onComplete;
55
+ useEffect(() => {
56
+ if (complete && !was.current) latest.current?.();
57
+ was.current = complete;
58
+ }, [complete]);
59
+ }
60
+
49
61
  export function BuyerInline({
50
62
  asked,
51
63
  buyer,
@@ -53,6 +65,7 @@ export function BuyerInline({
53
65
  copy,
54
66
  provider,
55
67
  onComplete,
68
+ withConsent = true,
56
69
  }: {
57
70
  asked: readonly CheckoutCustomerField[];
58
71
  buyer: BuyerInfo;
@@ -61,18 +74,13 @@ export function BuyerInline({
61
74
  /** The store's provider, named in the "why" — `null` when it published no name. */
62
75
  provider: string | null;
63
76
  onComplete?: () => void;
77
+ /** Offer "Salvar meus dados…" here — not when the top of Pagamento already does (FUT-3685). */
78
+ withConsent?: boolean;
64
79
  }): JSX.Element | null {
65
80
  const { Checkbox, Input, Text } = useCheckoutComponents();
66
81
  const consent = usePaymentListMode()?.consent;
67
82
  const cardCopy = useCheckoutCopy().card;
68
- const complete = asked.every((field) => fieldSatisfied(field, buyer[field.key]));
69
- const was = useRef(complete);
70
- const latest = useRef(onComplete);
71
- latest.current = onComplete;
72
- useEffect(() => {
73
- if (complete && !was.current) latest.current?.();
74
- was.current = complete;
75
- }, [complete]);
83
+ useOnComplete(asked.every((field) => fieldSatisfied(field, buyer[field.key])), onComplete);
76
84
  if (asked.length === 0) return null;
77
85
  return (
78
86
  <Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
@@ -112,7 +120,7 @@ export function BuyerInline({
112
120
  </Box>
113
121
  );
114
122
  })}
115
- {consent ? (
123
+ {consent && withConsent ? (
116
124
  <Checkbox
117
125
  label={consent.label}
118
126
  checked={consent.checked}
@@ -1,7 +1,7 @@
1
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`,
2
+ * The charges the one list starts once an order is held (FUT-3592,
3
+ * FUT-3685): the CARD — the buyer's saved card or the one they typed — and a
4
+ * wallet's sheet, Google Pay's or Apple Pay's. Each is the existing money path (`useCardSubmit`,
5
5
  * `useWalletCharge`), run with no button of its own: the list's bar already
6
6
  * was the button, and its 5-second window the buyer's chance to stop it.
7
7
  *
@@ -17,7 +17,7 @@ import { useRefreshOnCartChanged } from "../cart-changed";
17
17
  import { SubmittedState } from "../card-view";
18
18
  import type { ChallengeScope } from "../card-outcome";
19
19
  import { useCheckoutCopy } from "../copy-context";
20
- import type { BuyerInfo, CheckoutOrder, OnCheckoutResolved } from "../types";
20
+ import type { BuyerInfo, CheckoutOrder, CheckoutWalletType, OnCheckoutResolved } from "../types";
21
21
  import { useCardSubmit } from "../use-card-checkout";
22
22
  import { useWalletCharge } from "../use-wallet-charge";
23
23
 
@@ -112,27 +112,45 @@ export function CardRun({
112
112
  * charge that the order view does not report. Said once in development; the
113
113
  * buyer still gets the refusal.
114
114
  */
115
- function warnMissingAmountDue(order: CheckoutOrder, sheetCents: number): void {
115
+ function warnMissingAmountDue(order: CheckoutOrder, sheetCents: number, type: CheckoutWalletType): void {
116
116
  const development = typeof process !== "undefined" && process.env?.NODE_ENV !== "production";
117
117
  if (order.amountDueCents !== undefined || !development) return;
118
+ const wallet = type === "APPLE_PAY" ? "Apple Pay" : "Google Pay";
118
119
  console.warn(
119
- `[payments-frontend] Google Pay refused: the order costs ${order.totalCents} cents and the sheet showed ${sheetCents}. ` +
120
+ `[payments-frontend] ${wallet} refused: the order costs ${order.totalCents} cents and the sheet showed ${sheetCents}. ` +
120
121
  "A host that takes a balance off the charge must answer CheckoutOrder.amountDueCents.",
121
122
  );
122
123
  }
123
124
 
125
+ /** A wallet's sheet, opened inside the tap: its answer, the amount it showed, and how to close it. */
126
+ export interface WalletSheet {
127
+ type: CheckoutWalletType;
128
+ /** The sheet's answer: the wallet key, or `null` when the buyer closed it. */
129
+ answer: Promise<string | null>;
130
+ cents: number;
131
+ /** Close the sheet with the charge's outcome — Apple's sheet waits for it; Google's closes itself. */
132
+ complete?: (accepted: boolean) => void;
133
+ /** A runner took the sheet: it, not the list, closes it. */
134
+ claimed?: boolean;
135
+ }
136
+
137
+ /** The sentence for a wallet charge that could not go out. */
138
+ function useRefusedWords(type: CheckoutWalletType): string {
139
+ const words = useCheckoutCopy().screens.wallet;
140
+ return type === "APPLE_PAY" ? (words.applePay.paymentFailed ?? words.applePay.cannotComplete) : words.googlePay.cannotComplete;
141
+ }
142
+
124
143
  /**
125
- * Google Pay: the sheet was opened INSIDE the buyer's tap (`sheet`, priced at
126
- * `sheetCents`) while the order was raised; its key is charged against that
127
- * order only if the order asks the provider for what the sheet showed — its
128
- * amount due, which a balance the host holds may have made less than its
129
- * total. A closed sheet sends nothing more.
144
+ * A wallet — Google Pay or Apple Pay (FUT-3685): the sheet was opened INSIDE
145
+ * the buyer's tap, priced at `sheet.cents`, while the order was raised; its
146
+ * key is charged against that order only if the order asks the provider for
147
+ * what the sheet showed — its amount due, which a balance the host holds may
148
+ * have made less than its total (R4). A closed sheet sends nothing more.
130
149
  */
131
150
  export function WalletRun({
132
151
  order,
133
152
  buyer,
134
153
  sheet,
135
- sheetCents,
136
154
  scope,
137
155
  onResolved,
138
156
  pollIntervalMs,
@@ -141,28 +159,34 @@ export function WalletRun({
141
159
  }: RunnerCallbacks & {
142
160
  order: CheckoutOrder;
143
161
  buyer: BuyerInfo;
144
- /** The sheet's answer: the wallet key, or `null` when the buyer closed it. */
145
- sheet: Promise<string | null>;
146
- sheetCents: number;
162
+ sheet: WalletSheet;
147
163
  scope: ChallengeScope;
148
164
  onResolved: OnCheckoutResolved;
149
165
  pollIntervalMs: number;
150
166
  }): JSX.Element | null {
151
- const words = useCheckoutCopy().screens.wallet.googlePay;
167
+ const refusedWords = useRefusedWords(sheet.type);
152
168
  const wallet = useWalletCharge(order, buyer, onResolved, pollIntervalMs, scope);
153
169
  useFireOnce(true, () => {
154
- const refused = { message: words.cannotComplete, code: null };
155
- sheet
156
- .then((key) => {
170
+ sheet.claimed = true;
171
+ const refused = { message: refusedWords, code: null };
172
+ sheet.answer
173
+ .then(async (key) => {
157
174
  if (key === null) return onFailed(null);
158
175
  // The sheet showed one amount; never charge another under it.
159
- if ((order.amountDueCents ?? order.totalCents) !== sheetCents) {
160
- warnMissingAmountDue(order, sheetCents);
176
+ if ((order.amountDueCents ?? order.totalCents) !== sheet.cents) {
177
+ warnMissingAmountDue(order, sheet.cents, sheet.type);
178
+ sheet.complete?.(false);
161
179
  return onFailed(refused);
162
180
  }
163
- return void wallet.payWithKey("GOOGLE_PAY", key);
181
+ // Charged FIRST, then the sheet told: `complete?.(await …)` would skip
182
+ // the charge itself for a sheet with nothing to complete (Google's).
183
+ const accepted = await wallet.payWithKey(sheet.type, key);
184
+ sheet.complete?.(accepted);
164
185
  })
165
- .catch(() => onFailed(refused));
186
+ .catch(() => {
187
+ sheet.complete?.(false);
188
+ onFailed(refused);
189
+ });
166
190
  });
167
191
  const { error, errorCode, phase } = wallet;
168
192
  useEffect(() => {
@@ -3,7 +3,6 @@ import { useId, type JSX, type ReactNode } from "react";
3
3
 
4
4
  import { useCheckoutCopy } from "../copy-context";
5
5
  import type { BuyerInfo } from "../types";
6
- import { useCheckoutComponents } from "../ui";
7
6
 
8
7
  import { cardName, type OnlineRow } from "./choice";
9
8
  import type { ListChargeFailure } from "./charge-runners";
@@ -66,6 +65,7 @@ function OnlineOptionRow({ row, props }: { row: OnlineRow; props: RowProps }): J
66
65
  >
67
66
  {opensContent(row.method, state.asked, props.failure, props.covered) ? (
68
67
  <OnlineContent
68
+ withConsent={!state.top}
69
69
  method={row.method}
70
70
  asked={state.asked}
71
71
  buyer={props.buyer}
@@ -85,6 +85,12 @@ function OnlineOptionRow({ row, props }: { row: OnlineRow; props: RowProps }): J
85
85
  function DoorOptionRow({ option, props }: { option: CheckoutDoorOption; props: RowProps }): JSX.Element {
86
86
  const { choice } = props.state;
87
87
  const chosen = choice?.kind === "door" && choice.id === option.id;
88
+ const choose = (): void => {
89
+ if (!chosen) props.state.choose({ kind: "door", id: option.id });
90
+ };
91
+ // A function content gets the row's own controls (FUT-3715): a chip inside
92
+ // it can choose the row in the tap that answers it.
93
+ const content = typeof option.content === "function" ? option.content({ chosen, choose }) : option.content;
88
94
  return (
89
95
  <OptionRow
90
96
  name={props.name}
@@ -92,14 +98,18 @@ function DoorOptionRow({ option, props }: { option: CheckoutDoorOption; props: R
92
98
  chosen={chosen}
93
99
  label={option.label}
94
100
  hint={chosen && option.hideHintWhileChosen ? undefined : option.hint}
95
- onChoose={() => props.state.choose({ kind: "door", id: option.id })}
101
+ onChoose={choose}
96
102
  testId={`list-door-${option.id}`}
103
+ open={option.showContentWhileIdle === true}
97
104
  >
98
- {option.content}
105
+ {content}
99
106
  </OptionRow>
100
107
  );
101
108
  }
102
109
 
110
+ /** Read by a screen reader, not drawn. */
111
+ const VISUALLY_HIDDEN = { position: "absolute", width: "1px", height: "1px", m: 0, overflow: "hidden", clip: "rect(0 0 0 0)", whiteSpace: "nowrap" } as const;
112
+
103
113
  /** One heading and the rows under it, as a group named by the heading. */
104
114
  function Group({ id, heading, children }: { id: string; heading: string; children: ReactNode }): JSX.Element {
105
115
  return (
@@ -113,7 +123,9 @@ function Group({ id, heading, children }: { id: string; heading: string; childre
113
123
  /**
114
124
  * "Como você quer pagar?" — every way to pay as ONE radio group under two
115
125
  * headings (FUT-3591, M6): the store's online options, then the host's door
116
- * options. Choosing a row only chooses it; the bar's button acts.
126
+ * options. Choosing a row only chooses it; the bar's button acts. Above them,
127
+ * `top`: the CPF and the wallet block (FUT-3685), which are not part of the
128
+ * group (R1).
117
129
  *
118
130
  * One radio `name` across both groups keeps them one choice for the keyboard;
119
131
  * each heading names its own `role="group"`, and what opens inside a row sits
@@ -122,13 +134,15 @@ function Group({ id, heading, children }: { id: string; heading: string; childre
122
134
  export function ChoiceList({
123
135
  door,
124
136
  locked,
137
+ top,
125
138
  ...rest
126
139
  }: Omit<RowProps, "name"> & {
127
140
  door: readonly CheckoutDoorOption[];
128
141
  /** A charge or a send is under way: the list is inert. */
129
142
  locked: boolean;
143
+ /** The CPF and the wallets, above the rows (FUT-3685) — inert with them. */
144
+ top?: ReactNode;
130
145
  }): JSX.Element {
131
- const { Text } = useCheckoutComponents();
132
146
  const base = useId();
133
147
  const props: RowProps = { ...rest, name: `${base}-choice` };
134
148
  const { copy, state } = props;
@@ -138,11 +152,15 @@ export function ChoiceList({
138
152
  data-locked={locked ? "true" : "false"}
139
153
  aria-disabled={locked || undefined}
140
154
  inert={locked || undefined}
155
+ aria-labelledby={`${base}-title`}
141
156
  sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}
142
157
  >
143
- <Text variant="body" size="md" weight="bold" as="p" style={{ margin: 0 }}>
158
+ {/* The list's name, for a screen reader: the step's own heading is what a
159
+ sighted buyer reads (the host's "Pagamento" card, T1). */}
160
+ <Box component="p" id={`${base}-title`} sx={VISUALLY_HIDDEN}>
144
161
  {copy.title}
145
- </Text>
162
+ </Box>
163
+ {top}
146
164
  {/* Each heading only over a group that has rows (a door-only store shows the door one alone). */}
147
165
  {state.rows.length > 0 ? (
148
166
  <Group id={`${base}-online`} heading={copy.onlineHeading}>
@@ -37,14 +37,13 @@ export interface OnlineCapability {
37
37
  /** The methods the chain declares, or `null` while unknown (fails OPEN). */
38
38
  offered: PaymentMethod[] | null;
39
39
  cardUnavailable: boolean;
40
- /** Google Pay is declared AND this browser's `isReadyToPay` said yes. */
41
- googlePayReady: boolean;
42
40
  }
43
41
 
44
42
  /**
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).
43
+ * The "PAGAR AGORA, PELO APP" rows, in the prototype's order: Pix, then the
44
+ * card — or the single hand-off row for a store that takes the method on its
45
+ * own page (N18). The wallets are not rows: they are buttons in the block
46
+ * above the list (FUT-3685, R1), and pressing one never changes the choice.
48
47
  */
49
48
  export function onlineRows(cap: OnlineCapability): OnlineRow[] {
50
49
  if (cap.handOff) return [{ method: "HAND_OFF", disabled: false }];
@@ -52,7 +51,6 @@ export function onlineRows(cap: OnlineCapability): OnlineRow[] {
52
51
  const rows: OnlineRow[] = [];
53
52
  if (offers("PIX")) rows.push({ method: "PIX", disabled: false });
54
53
  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
54
  return rows;
57
55
  }
58
56
 
@@ -0,0 +1,98 @@
1
+ import { Box } from "@mui/material";
2
+ import { useEffect, useRef, type JSX } from "react";
3
+
4
+ import { formatCpf, validateCpf } from "../../../card";
5
+ import { fieldSatisfied } from "../buyer-fields";
6
+ import { useCheckoutCopy } from "../copy-context";
7
+ import { useCheckoutLayout } from "../payment-step-layout";
8
+ import type { BuyerInfo, CheckoutCustomerField } from "../types";
9
+ import { useCheckoutComponents } from "../ui";
10
+
11
+ import { PLACEHOLDERS, complaintOf, useOnComplete } from "./buyer-inline";
12
+ import { usePaymentListMode } from "./context";
13
+ import type { PaymentListCopy } from "./types";
14
+
15
+ /** The prototype's 280px CPF field on a computer (L7), on the theme's spacing scale. */
16
+ const FIELD_WIDE_MAX = 35;
17
+
18
+ /**
19
+ * The CPF at the TOP of Pagamento, above the wallets (FUT-3685, C3, C4, E1,
20
+ * L7): "CPF", the provider's reason under it ("o PagBank pede para pagar pelo
21
+ * app"), the field — masked, checked by its digits — and "Salvar meus dados
22
+ * para a próxima compra". Asked once, for every online way to pay the list
23
+ * offers (R2); full width on a phone, ~280px on a computer.
24
+ *
25
+ * When the CPF is complete, `onComplete` fires once — the list moves the focus
26
+ * to "Número do cartão" when a card typed now is chosen.
27
+ */
28
+ export function CpfTop({ field, buyer, onBuyerChange, copy, provider, onComplete, focusOnMount = false, onKeepSaved }: {
29
+ field: CheckoutCustomerField;
30
+ buyer: BuyerInfo;
31
+ onBuyerChange: (buyer: BuyerInfo) => void;
32
+ copy: PaymentListCopy["buyer"];
33
+ provider: string | null;
34
+ onComplete?: () => void;
35
+ /** Opened by "Alterar" on the payer line: the field takes the focus as it appears. */
36
+ focusOnMount?: boolean;
37
+ /** Opened over a CPF on file: "Manter o CPF salvo" closes it and keeps that CPF. */
38
+ onKeepSaved?: () => void;
39
+ }): JSX.Element {
40
+ const { Button, Checkbox, Input, Text } = useCheckoutComponents();
41
+ const consent = usePaymentListMode()?.consent;
42
+ const fieldCopy = useCheckoutCopy().card.fields;
43
+ const wide = useCheckoutLayout()?.wide ?? false;
44
+ // The CPF the store already holds, when the host passed it, shown masked and editable.
45
+ const value = formatCpf(buyer.taxId ?? "");
46
+ useOnComplete(value.trim() !== "" && fieldSatisfied(field, value), onComplete);
47
+ const scope = useRef<HTMLDivElement | null>(null);
48
+ useEffect(() => {
49
+ if (focusOnMount) scope.current?.querySelector<HTMLInputElement>("input")?.focus();
50
+ }, [focusOnMount]);
51
+ const complaint = complaintOf(field, value, copy, (typed) => validateCpf(typed, fieldCopy));
52
+ return (
53
+ <Box ref={scope} data-testid="list-cpf" sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
54
+ {/* A native <label> around the words and the field names the input with both. */}
55
+ <Box component="label" sx={{ display: "flex", flexDirection: "column", gap: 0.75 }}>
56
+ <Box component="span" sx={{ display: "flex", flexDirection: "column" }}>
57
+ <Text variant="body" size="sm" as="span">
58
+ {copy.cpf}
59
+ </Text>
60
+ <Text variant="body" size="sm" color="secondary" as="span">
61
+ {copy.cpfHint(provider)}
62
+ </Text>
63
+ </Box>
64
+ <Box sx={{ width: "100%", maxWidth: (t) => (wide ? t.spacing(FIELD_WIDE_MAX) : "none") }}>
65
+ <Input
66
+ type="text"
67
+ inputMode="numeric"
68
+ variant="outlined"
69
+ size="md"
70
+ fullWidth
71
+ required={field.required}
72
+ placeholder={PLACEHOLDERS.taxId}
73
+ value={value}
74
+ error={Boolean(complaint)}
75
+ helperText={complaint}
76
+ onChange={(event) => onBuyerChange({ ...buyer, taxId: formatCpf(event.target.value) })}
77
+ data-testid="list-buyer-taxId"
78
+ />
79
+ </Box>
80
+ </Box>
81
+ {onKeepSaved && copy.keepSavedCpf ? (
82
+ <Box sx={{ alignSelf: "flex-start", ml: -1 }}>
83
+ <Button variant="text" color="primary" size="sm" onClick={onKeepSaved} dataTestId="list-cpf-keep">
84
+ {copy.keepSavedCpf}
85
+ </Button>
86
+ </Box>
87
+ ) : null}
88
+ {consent ? (
89
+ <Checkbox
90
+ label={consent.label}
91
+ checked={consent.checked}
92
+ onChange={(_event, checked) => consent.onChange(checked)}
93
+ data-testid="list-save-profile"
94
+ />
95
+ ) : null}
96
+ </Box>
97
+ );
98
+ }
@@ -12,6 +12,12 @@ export const EN_US_PAYMENT_LIST_COPY: PaymentListCopy = {
12
12
  hintNew: "Enter your card details",
13
13
  },
14
14
  googlePay: { label: "Google Pay", hint: "With the card saved in your Google account" },
15
+ wallets: {
16
+ heading: "Quick payment",
17
+ googlePay: "Pay with Google Pay",
18
+ applePay: "Pay with Apple Pay",
19
+ or: "or choose how to pay",
20
+ },
15
21
  handOff: {
16
22
  label: "Pix or card",
17
23
  hint: (provider) =>
@@ -25,6 +31,8 @@ export const EN_US_PAYMENT_LIST_COPY: PaymentListCopy = {
25
31
  name: "Name",
26
32
  email: "Email",
27
33
  why: (provider) => (provider ? `(${provider} asks for it to pay in the app)` : "(needed to pay in the app)"),
34
+ cpfHint: (provider) => (provider ? `${provider} asks for it to pay in the app` : "needed to pay in the app"),
35
+ keepSavedCpf: "Keep the saved CPF",
28
36
  cpfIncomplete: "A CPF has 11 digits.",
29
37
  phoneIncomplete: "The mobile number needs the area code and 9 digits.",
30
38
  },