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