@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.
- package/package.json +2 -2
- package/src/components/checkout/apple-pay-button.tsx +84 -36
- package/src/components/checkout/checkout-flow.tsx +3 -2
- package/src/components/checkout/google-pay-button.tsx +6 -0
- package/src/components/checkout/list/buyer-inline.tsx +22 -14
- package/src/components/checkout/list/charge-runners.tsx +47 -23
- package/src/components/checkout/list/choice-list.tsx +25 -7
- package/src/components/checkout/list/choice.ts +4 -6
- package/src/components/checkout/list/cpf-top.tsx +98 -0
- package/src/components/checkout/list/en-US.ts +8 -0
- package/src/components/checkout/list/list-bar.tsx +29 -44
- package/src/components/checkout/list/list-face.tsx +110 -104
- package/src/components/checkout/list/list-top.tsx +99 -0
- package/src/components/checkout/list/online-content.tsx +4 -0
- package/src/components/checkout/list/option-row.tsx +55 -32
- package/src/components/checkout/list/payer-line.tsx +39 -0
- package/src/components/checkout/list/payment-list-step.tsx +50 -5
- package/src/components/checkout/list/pt-BR.ts +8 -0
- package/src/components/checkout/list/summary-column.tsx +120 -0
- package/src/components/checkout/list/top-cpf.ts +94 -0
- package/src/components/checkout/list/types.ts +89 -13
- package/src/components/checkout/list/use-capability.ts +113 -35
- package/src/components/checkout/list/use-list-charge.ts +11 -9
- package/src/components/checkout/list/use-list-state.ts +37 -4
- package/src/components/checkout/list/wallet-block.tsx +235 -0
- package/src/components/checkout/list/wallet-press.ts +129 -0
- package/src/components/checkout/payment-phase.tsx +1 -0
- package/src/components/checkout/payment-summary.tsx +2 -2
- package/src/components/checkout/screens-copy.ts +6 -0
- package/src/components/checkout/screens-en-US.ts +1 -0
- package/src/components/checkout/screens-pt-BR.ts +2 -0
- package/src/flows/create-payment-flows.tsx +11 -2
- package/src/flows/types.ts +7 -2
- 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.
|
|
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.
|
|
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,
|
|
93
|
-
function paymentRequest(
|
|
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: (
|
|
102
|
+
total: { label: copy.orderTotal, amount: (cents / 100).toFixed(2) },
|
|
103
103
|
};
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
/**
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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 =
|
|
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 = () =>
|
|
159
|
-
|
|
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
|
|
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
|
|
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
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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]
|
|
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
|
|
126
|
-
* `
|
|
127
|
-
* order only if the order asks the provider for
|
|
128
|
-
* amount due, which a balance the host holds may
|
|
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
|
-
|
|
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
|
|
167
|
+
const refusedWords = useRefusedWords(sheet.type);
|
|
152
168
|
const wallet = useWalletCharge(order, buyer, onResolved, pollIntervalMs, scope);
|
|
153
169
|
useFireOnce(true, () => {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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) !==
|
|
160
|
-
warnMissingAmountDue(order,
|
|
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
|
-
|
|
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(() =>
|
|
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={
|
|
101
|
+
onChoose={choose}
|
|
96
102
|
testId={`list-door-${option.id}`}
|
|
103
|
+
open={option.showContentWhileIdle === true}
|
|
97
104
|
>
|
|
98
|
-
{
|
|
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
|
-
|
|
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
|
-
</
|
|
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,
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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
|
},
|