@12-apps/payments-frontend 3.43.1 → 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/flows/create-payment-flows.tsx +11 -2
- package/src/flows/types.ts +7 -2
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useState, type JSX } from "react";
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState, type JSX, type ReactNode } from "react";
|
|
2
2
|
|
|
3
3
|
import type { CheckoutBasketIdentity } from "../basket";
|
|
4
4
|
import { CartChangedBoundary } from "../cart-changed";
|
|
5
5
|
import { displayTotals } from "../checkout-totals";
|
|
6
|
+
import type { CheckoutSummaryLine } from "../payment-summary";
|
|
6
7
|
import { usePaymentPixStage } from "../payment-step-layout";
|
|
7
8
|
import { PixStageProvider } from "../pix-stage";
|
|
8
9
|
import { PixView } from "../pix-view";
|
|
@@ -13,6 +14,7 @@ import type { useCheckoutController } from "../use-checkout-controller";
|
|
|
13
14
|
import { PaymentListProvider } from "./context";
|
|
14
15
|
import { ListBar, WaitingLine } from "./list-bar";
|
|
15
16
|
import { ListFace } from "./list-face";
|
|
17
|
+
import { ListLayout } from "./summary-column";
|
|
16
18
|
import type { ListCapability } from "./use-capability";
|
|
17
19
|
import type { ListCharge } from "./use-list-charge";
|
|
18
20
|
import type { ListState } from "./use-list-state";
|
|
@@ -26,7 +28,15 @@ type Controller = ReturnType<typeof useCheckoutController>;
|
|
|
26
28
|
interface PaymentListStepProps {
|
|
27
29
|
c: Controller;
|
|
28
30
|
list: CheckoutPaymentList;
|
|
29
|
-
cart: {
|
|
31
|
+
cart: {
|
|
32
|
+
totalLabel: string;
|
|
33
|
+
totalItems: number;
|
|
34
|
+
identity?: CheckoutBasketIdentity;
|
|
35
|
+
/** The cart's lines, for the "Resumo do pedido" column on a computer (L4). */
|
|
36
|
+
lines?: CheckoutSummaryLine[];
|
|
37
|
+
/** The host's fee and discount rows, for the same column. */
|
|
38
|
+
discountLines?: ReactNode;
|
|
39
|
+
};
|
|
30
40
|
/** Settling a balance: its totals win, and its basket is no signal. */
|
|
31
41
|
totalOverride?: { label: string; items: number };
|
|
32
42
|
providerConfig: CheckoutProviderConfig | null | undefined;
|
|
@@ -38,6 +48,11 @@ interface PaymentListStepProps {
|
|
|
38
48
|
/** The Dados step's consent sentence — the list asks it beside the details it asks. */
|
|
39
49
|
consentLabel: string;
|
|
40
50
|
pollIntervalMs?: number;
|
|
51
|
+
/**
|
|
52
|
+
* The host's Apple Pay merchant-validation port (FUT-472), server-side.
|
|
53
|
+
* Without it the list offers no Apple Pay: its session could only abort.
|
|
54
|
+
*/
|
|
55
|
+
validateApplePayMerchant?: (validationURL: string) => Promise<unknown>;
|
|
41
56
|
}
|
|
42
57
|
|
|
43
58
|
/** The store's provider name, as its chain head published it — `null` when it did not. */
|
|
@@ -80,13 +95,29 @@ function usePreferMethod(c: Controller, choose: ListState["choose"]): (method: P
|
|
|
80
95
|
);
|
|
81
96
|
}
|
|
82
97
|
|
|
98
|
+
/**
|
|
99
|
+
* "Alterar" on the payer line opens the CPF at the top (E2 → E1); "Manter o
|
|
100
|
+
* CPF salvo" closes it, putting back the CPF the buyer arrived with — a CPF
|
|
101
|
+
* typed in between is dropped, never sent.
|
|
102
|
+
*/
|
|
103
|
+
function useReveal(c: Controller, arrivedWith: BuyerInfo | undefined) {
|
|
104
|
+
const [revealed, setRevealed] = useState(false);
|
|
105
|
+
const { buyer, setBuyer } = c;
|
|
106
|
+
const onKeepSavedCpf = useCallback(() => {
|
|
107
|
+
setBuyer({ ...buyer, taxId: arrivedWith?.taxId });
|
|
108
|
+
setRevealed(false);
|
|
109
|
+
}, [buyer, setBuyer, arrivedWith]);
|
|
110
|
+
return useMemo(() => ({ revealed, onRevealCpf: () => setRevealed(true), onKeepSavedCpf }), [revealed, onKeepSavedCpf]);
|
|
111
|
+
}
|
|
112
|
+
|
|
83
113
|
/** The list face, with what the step gathered for it. */
|
|
84
|
-
function ListFaceFor({ props, state, charge, cap, onHandOff }: {
|
|
114
|
+
function ListFaceFor({ props, state, charge, cap, onHandOff, reveal }: {
|
|
85
115
|
props: PaymentListStepProps;
|
|
86
116
|
state: ListState;
|
|
87
117
|
charge: ListCharge;
|
|
88
118
|
cap: ListCapability;
|
|
89
119
|
onHandOff: () => void;
|
|
120
|
+
reveal: { revealed: boolean; onRevealCpf: () => void; onKeepSavedCpf: () => void };
|
|
90
121
|
}): JSX.Element {
|
|
91
122
|
const { c, cart, totalOverride } = props;
|
|
92
123
|
const config = props.providerConfig ?? null;
|
|
@@ -106,6 +137,9 @@ function ListFaceFor({ props, state, charge, cap, onHandOff }: {
|
|
|
106
137
|
pollIntervalMs={props.pollIntervalMs ?? 2500}
|
|
107
138
|
saveBuyerContact={props.saveBuyerContact}
|
|
108
139
|
onHandOff={onHandOff}
|
|
140
|
+
revealed={reveal.revealed}
|
|
141
|
+
onRevealCpf={reveal.onRevealCpf}
|
|
142
|
+
onKeepSavedCpf={reveal.onKeepSavedCpf}
|
|
109
143
|
/>
|
|
110
144
|
);
|
|
111
145
|
}
|
|
@@ -122,7 +156,9 @@ function ListFaceFor({ props, state, charge, cap, onHandOff }: {
|
|
|
122
156
|
export function PaymentListStep(props: PaymentListStepProps): JSX.Element {
|
|
123
157
|
const { c, list } = props;
|
|
124
158
|
const config = props.providerConfig ?? null;
|
|
125
|
-
const cap = useListCapability(config, list.totalCents);
|
|
159
|
+
const cap = useListCapability(config, list.totalCents, props.validateApplePayMerchant);
|
|
160
|
+
const reveal = useReveal(c, props.defaultBuyer);
|
|
161
|
+
const { revealed } = reveal;
|
|
126
162
|
const state = useListState({
|
|
127
163
|
config,
|
|
128
164
|
capability: cap,
|
|
@@ -132,6 +168,8 @@ export function PaymentListStep(props: PaymentListStepProps): JSX.Element {
|
|
|
132
168
|
taxIdOnFile: props.taxIdOnFile,
|
|
133
169
|
lastMethod: list.lastMethod,
|
|
134
170
|
onChoiceChange: list.onChoiceChange,
|
|
171
|
+
wallets: cap.wallets,
|
|
172
|
+
revealed,
|
|
135
173
|
});
|
|
136
174
|
const charge = useListCharge({ ...c, cartTotal: displayTotals(props.totalOverride, props.cart).label, dueCents: list.totalCents });
|
|
137
175
|
const [handingOff, startHandOff] = useHandingOff(c.createError);
|
|
@@ -146,7 +184,14 @@ export function PaymentListStep(props: PaymentListStepProps): JSX.Element {
|
|
|
146
184
|
) : handingOff ? (
|
|
147
185
|
<HandOffFace props={props} method={c.method ?? "PIX"} />
|
|
148
186
|
) : (
|
|
149
|
-
<
|
|
187
|
+
<ListLayout
|
|
188
|
+
hostTarget={list.summaryTarget}
|
|
189
|
+
lines={props.totalOverride ? undefined : props.cart.lines}
|
|
190
|
+
moneyLines={props.cart.discountLines}
|
|
191
|
+
totalLabel={displayTotals(props.totalOverride, props.cart).label}
|
|
192
|
+
>
|
|
193
|
+
<ListFaceFor props={props} state={state} charge={charge} cap={cap} onHandOff={startHandOff} reveal={reveal} />
|
|
194
|
+
</ListLayout>
|
|
150
195
|
);
|
|
151
196
|
return (
|
|
152
197
|
<PaymentListProvider list={list} onLeavePix={c.dropOrder} consent={consent}>
|
|
@@ -28,6 +28,12 @@ export const PT_BR_PAYMENT_LIST_COPY: PaymentListCopy = {
|
|
|
28
28
|
hintNew: "Digite os dados do cartão",
|
|
29
29
|
},
|
|
30
30
|
googlePay: { label: "Google Pay", hint: "Com o cartão salvo no seu Google" },
|
|
31
|
+
wallets: {
|
|
32
|
+
heading: "Pagamento rápido",
|
|
33
|
+
googlePay: "Pagar com Google Pay",
|
|
34
|
+
applePay: "Pagar com Apple Pay",
|
|
35
|
+
or: "ou escolha como pagar",
|
|
36
|
+
},
|
|
31
37
|
handOff: {
|
|
32
38
|
label: "Pix ou cartão",
|
|
33
39
|
hint: (provider) =>
|
|
@@ -41,6 +47,8 @@ export const PT_BR_PAYMENT_LIST_COPY: PaymentListCopy = {
|
|
|
41
47
|
name: "Nome",
|
|
42
48
|
email: "E-mail",
|
|
43
49
|
why: (provider) => (provider ? `(${the(provider)} pede para pagar pelo app)` : "(pedido para pagar pelo app)"),
|
|
50
|
+
cpfHint: (provider) => (provider ? `${the(provider)} pede para pagar pelo app` : "pedido para pagar pelo app"),
|
|
51
|
+
keepSavedCpf: "Manter o CPF salvo",
|
|
44
52
|
cpfIncomplete: "O CPF tem 11 dígitos.",
|
|
45
53
|
phoneIncomplete: "O celular precisa de DDD e 9 dígitos.",
|
|
46
54
|
},
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Box } from "@mui/material";
|
|
2
|
+
import { createContext, useContext, useMemo, useState, type JSX, type ReactNode } from "react";
|
|
3
|
+
import { createPortal } from "react-dom";
|
|
4
|
+
|
|
5
|
+
import { useCheckoutCopy } from "../copy-context";
|
|
6
|
+
import { SummaryLines, SummaryTotal, type CheckoutSummaryLine } from "../payment-summary";
|
|
7
|
+
import { useCheckoutLayout } from "../payment-step-layout";
|
|
8
|
+
import { useCheckoutComponents } from "../ui";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Where the bar's content goes on a computer (FUT-3685, L4): into the
|
|
12
|
+
* "Resumo do pedido" column — an element the column draws, which the bar's
|
|
13
|
+
* content is portalled into — or `null` on a phone, where the bar stays the
|
|
14
|
+
* host's sticky `ActionBar` (L2).
|
|
15
|
+
*/
|
|
16
|
+
const ColumnTarget = createContext<{ element: HTMLElement | null } | null>(null);
|
|
17
|
+
|
|
18
|
+
const SummaryColumnTarget = ColumnTarget.Provider;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `null` for the bar (a phone); otherwise the column's element to portal the
|
|
22
|
+
* bar's content into — itself `null` for the one render before the column
|
|
23
|
+
* mounts, when nothing is drawn rather than a bar that would flash.
|
|
24
|
+
*/
|
|
25
|
+
export function useSummaryColumnTarget(): { element: HTMLElement | null } | null {
|
|
26
|
+
return useContext(ColumnTarget);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The prototype's 680px steps column and 360px summary column, on the theme's spacing scale. */
|
|
30
|
+
const STEPS_MAX = 85;
|
|
31
|
+
const COLUMN_WIDTH = 45;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The computer's two columns (L4): the steps on the left, at most ~680px, and
|
|
35
|
+
* the "Resumo do pedido" column on the right, ~360px, holding the lines, the
|
|
36
|
+
* fees, "Total", the host's context line and the pay button — so no bar sits
|
|
37
|
+
* at the bottom of the screen.
|
|
38
|
+
*/
|
|
39
|
+
function TwoColumns({ steps, column }: { steps: ReactNode; column: ReactNode }): JSX.Element {
|
|
40
|
+
return (
|
|
41
|
+
<Box data-testid="list-columns" sx={{ display: "flex", gap: 4, alignItems: "flex-start", justifyContent: "center", width: "100%" }}>
|
|
42
|
+
<Box sx={{ flex: 1, minWidth: 0, maxWidth: (t) => t.spacing(STEPS_MAX) }}>{steps}</Box>
|
|
43
|
+
<Box sx={{ flex: "none", width: (t) => t.spacing(COLUMN_WIDTH), position: "sticky", top: (t) => t.spacing(3) }}>{column}</Box>
|
|
44
|
+
</Box>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* "Resumo do pedido" (T20): the cart's lines and the host's money rows (fees,
|
|
50
|
+
* discounts) as the host words them, a hairline, "Total" and the amount — and
|
|
51
|
+
* under them `slot`, the element the bar's content is portalled into.
|
|
52
|
+
*/
|
|
53
|
+
function SummaryColumn({ lines, moneyLines, totalLabel, slot }: {
|
|
54
|
+
lines?: CheckoutSummaryLine[];
|
|
55
|
+
/** The host's fee and discount rows (`CheckoutCartView.discountLines`). */
|
|
56
|
+
moneyLines?: ReactNode;
|
|
57
|
+
totalLabel: string;
|
|
58
|
+
slot: (element: HTMLElement | null) => void;
|
|
59
|
+
}): JSX.Element {
|
|
60
|
+
const { Text } = useCheckoutComponents();
|
|
61
|
+
const copy = useCheckoutCopy().screens.layout;
|
|
62
|
+
const shown = lines ?? [];
|
|
63
|
+
return (
|
|
64
|
+
<Box
|
|
65
|
+
component="aside"
|
|
66
|
+
aria-label={copy.summaryTitle}
|
|
67
|
+
data-testid="list-summary-column"
|
|
68
|
+
sx={{ display: "flex", flexDirection: "column", gap: 1.75, p: 2.75, bgcolor: "background.paper", border: "1px solid", borderColor: "divider", borderRadius: 3.5 }}
|
|
69
|
+
>
|
|
70
|
+
<Text variant="heading" size="md" weight="bold" as="h2" style={{ margin: 0 }}>
|
|
71
|
+
{copy.summaryTitle}
|
|
72
|
+
</Text>
|
|
73
|
+
{shown.length > 0 ? <SummaryLines lines={shown} /> : null}
|
|
74
|
+
{moneyLines ? <Box data-testid="list-summary-money">{moneyLines}</Box> : null}
|
|
75
|
+
<Box sx={{ height: "1px", bgcolor: "divider" }} />
|
|
76
|
+
<SummaryTotal totalLabel={totalLabel} />
|
|
77
|
+
<Box ref={slot} data-testid="list-summary-action" sx={{ display: "flex", flexDirection: "column" }} />
|
|
78
|
+
</Box>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The list face's layout (FUT-3685, L4), in one of three shapes:
|
|
84
|
+
*
|
|
85
|
+
* - the HOST lays out the page (`hostTarget` given — `paymentList.summaryTarget`):
|
|
86
|
+
* the "Resumo do pedido" column is portalled into the host's element, beside
|
|
87
|
+
* the host's own step cards, and the bar's action lands in it. While the
|
|
88
|
+
* element is `null` (not mounted yet) the bar draws nothing rather than flash;
|
|
89
|
+
* - a computer, the host silent: the step and the column side by side, here;
|
|
90
|
+
* - a phone: the step alone, over the host's bar (L2).
|
|
91
|
+
*
|
|
92
|
+
* Only the list face is laid out this way: the Pix screen and the hand-off keep
|
|
93
|
+
* their bar (K9).
|
|
94
|
+
*/
|
|
95
|
+
export function ListLayout({ hostTarget, lines, moneyLines, totalLabel, children }: {
|
|
96
|
+
hostTarget: HTMLElement | null | undefined;
|
|
97
|
+
lines?: CheckoutSummaryLine[];
|
|
98
|
+
moneyLines?: ReactNode;
|
|
99
|
+
totalLabel: string;
|
|
100
|
+
children: ReactNode;
|
|
101
|
+
}): JSX.Element {
|
|
102
|
+
const wide = useCheckoutLayout()?.wide ?? false;
|
|
103
|
+
const [slot, setSlot] = useState<HTMLElement | null>(null);
|
|
104
|
+
const target = useMemo(() => ({ element: slot }), [slot]);
|
|
105
|
+
const column = <SummaryColumn lines={lines} moneyLines={moneyLines} totalLabel={totalLabel} slot={setSlot} />;
|
|
106
|
+
if (hostTarget !== undefined) {
|
|
107
|
+
return (
|
|
108
|
+
<SummaryColumnTarget value={target}>
|
|
109
|
+
{children}
|
|
110
|
+
{hostTarget ? createPortal(column, hostTarget) : null}
|
|
111
|
+
</SummaryColumnTarget>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
if (!wide) return <>{children}</>;
|
|
115
|
+
return (
|
|
116
|
+
<SummaryColumnTarget value={target}>
|
|
117
|
+
<TwoColumns steps={children} column={column} />
|
|
118
|
+
</SummaryColumnTarget>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CPF asked ONCE, at the top of the Pagamento step (FUT-3685, contract E1,
|
|
3
|
+
* E2, E3, R2, R3).
|
|
4
|
+
*
|
|
5
|
+
* PagBank charges a wallet as a card and requires the buyer's tax id on every
|
|
6
|
+
* order, and neither wallet's sheet can supply one. So the CPF cannot wait
|
|
7
|
+
* inside the chosen row any more: a wallet button is pressed with no row
|
|
8
|
+
* chosen at all. It is asked above the wallets, when ANY way to pay the list
|
|
9
|
+
* offers online needs it under the chain rule (FUT-526) — the union over the
|
|
10
|
+
* offered methods, never the chosen row (R2) — and it gates the online ways to
|
|
11
|
+
* pay only: a door row never needs it (R3).
|
|
12
|
+
*
|
|
13
|
+
* Every other field a method asks for (the hand-off provider's mobile, K4)
|
|
14
|
+
* stays inside its own row.
|
|
15
|
+
*/
|
|
16
|
+
import { buyerFieldsFor, fieldSatisfied } from "../buyer-fields";
|
|
17
|
+
import type { BuyerInfo, CheckoutChainLink, CheckoutCustomerField, PaymentMethod } from "../types";
|
|
18
|
+
|
|
19
|
+
/** Whether a field asks for the buyer's CPF. */
|
|
20
|
+
export function isTaxId(field: Pick<CheckoutCustomerField, "key">): boolean {
|
|
21
|
+
return field.key === "taxId";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The required CPF the chain asks for under any of `methods`, or `null` when none does. */
|
|
25
|
+
function requiredTaxId(
|
|
26
|
+
chain: readonly CheckoutChainLink[] | undefined,
|
|
27
|
+
methods: readonly PaymentMethod[],
|
|
28
|
+
): CheckoutCustomerField | null {
|
|
29
|
+
for (const method of methods) {
|
|
30
|
+
const field = buyerFieldsFor(chain, method).find((candidate) => isTaxId(candidate) && candidate.required);
|
|
31
|
+
if (field) return field;
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The CPF field the top of Pagamento shows, or `null` for none (E1, E2).
|
|
38
|
+
*
|
|
39
|
+
* - Decided against what the buyer ARRIVED with, never what they are typing,
|
|
40
|
+
* so the field does not vanish under their thumb as it completes.
|
|
41
|
+
* - A buyer with a CPF on file sees no field — until they press "Alterar" on
|
|
42
|
+
* the payer line (`revealed`, E2 → E1). That field opens holding the CPF
|
|
43
|
+
* the host passed (masked, editable) and is OPTIONAL: empty means "keep the
|
|
44
|
+
* CPF the store holds" — a host that does not pass it (the field opens
|
|
45
|
+
* empty) must not strand the buyer — and only a non-empty CPF that is not
|
|
46
|
+
* valid holds the online ways to pay, with the reason (E3, K6). "Manter o
|
|
47
|
+
* CPF salvo" closes it again.
|
|
48
|
+
*/
|
|
49
|
+
export function topCpfField(input: {
|
|
50
|
+
chain: readonly CheckoutChainLink[] | undefined;
|
|
51
|
+
/** Every online method the list offers, wallets included (they raise a CARD order). */
|
|
52
|
+
methods: readonly PaymentMethod[];
|
|
53
|
+
arrivedWith: BuyerInfo | undefined;
|
|
54
|
+
taxIdOnFile: boolean;
|
|
55
|
+
revealed: boolean;
|
|
56
|
+
}): CheckoutCustomerField | null {
|
|
57
|
+
const field = requiredTaxId(input.chain, input.methods);
|
|
58
|
+
if (!field) return null;
|
|
59
|
+
if (input.taxIdOnFile) return input.revealed ? { ...field, required: false } : null;
|
|
60
|
+
if (fieldSatisfied(field, input.arrivedWith?.taxId)) return null;
|
|
61
|
+
return field;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Whether the top CPF holds a payment raised with `method`: the method's own
|
|
66
|
+
* chain needs it and it is not satisfied — or the buyer typed one that is not
|
|
67
|
+
* valid, which would go out with any online payment.
|
|
68
|
+
*/
|
|
69
|
+
export function cpfHolds(
|
|
70
|
+
top: CheckoutCustomerField | null,
|
|
71
|
+
buyer: BuyerInfo,
|
|
72
|
+
chain: readonly CheckoutChainLink[] | undefined,
|
|
73
|
+
method: PaymentMethod,
|
|
74
|
+
): boolean {
|
|
75
|
+
if (!top || fieldSatisfied(top, buyer.taxId)) return false;
|
|
76
|
+
const typed = (buyer.taxId ?? "").trim() !== "";
|
|
77
|
+
return typed || requiredTaxId(chain, [method]) !== null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The provider the hint names — "o PagBank pede para pagar pelo app" (T3): the
|
|
82
|
+
* first chain member that asks for the CPF under one of `methods` (or declares
|
|
83
|
+
* nothing, which reads as asking for it), else the chain head. `null` when the
|
|
84
|
+
* member published no name.
|
|
85
|
+
*/
|
|
86
|
+
export function cpfProvider(
|
|
87
|
+
chain: readonly CheckoutChainLink[] | undefined,
|
|
88
|
+
methods: readonly PaymentMethod[],
|
|
89
|
+
): string | null {
|
|
90
|
+
const asks = (link: CheckoutChainLink): boolean =>
|
|
91
|
+
link.customerSchema === undefined || requiredTaxId([link], methods) !== null;
|
|
92
|
+
const link = chain?.find(asks) ?? chain?.[0];
|
|
93
|
+
return link?.displayName?.trim() || null;
|
|
94
|
+
}
|
|
@@ -62,7 +62,12 @@ export interface CheckoutDoorOption {
|
|
|
62
62
|
onSend: () => void;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
/**
|
|
65
|
+
/**
|
|
66
|
+
* The online ways to pay the list may offer, as the host is told about them.
|
|
67
|
+
* `"GOOGLE_PAY"` is never reported any more: the wallets are buttons above the
|
|
68
|
+
* list, not rows (FUT-3685). It stays in the union so a host's exhaustive
|
|
69
|
+
* switch still compiles.
|
|
70
|
+
*/
|
|
66
71
|
export type CheckoutOnlineChoice = "PIX" | "CARD" | "GOOGLE_PAY" | "HAND_OFF";
|
|
67
72
|
|
|
68
73
|
/** What the buyer has chosen in the list. */
|
|
@@ -104,15 +109,14 @@ export interface CheckoutPaymentList {
|
|
|
104
109
|
/**
|
|
105
110
|
* The amount an ONLINE payment will ask the provider for, in integer cents —
|
|
106
111
|
* the cart's total, or what is left of it after a balance the host holds
|
|
107
|
-
* (store credit). It is what
|
|
108
|
-
* sheet must open INSIDE the tap, before any order exists to read a
|
|
109
|
-
* from. Never given, or zero (a balance covers everything) ⇒ no
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* takes a balance off the charge MUST answer `amountDueCents`.
|
|
112
|
+
* (store credit). It is what the wallets' sheets are priced with (FUT-3685),
|
|
113
|
+
* since a sheet must open INSIDE the tap, before any order exists to read a
|
|
114
|
+
* price from. Never given, or zero (a balance covers everything) ⇒ no wallet
|
|
115
|
+
* buttons. Once given, a moment without it (the host re-pricing) keeps their
|
|
116
|
+
* place, held — plainly, with `copy.reasons.repricing` — until it returns. A
|
|
117
|
+
* key is refused if the order raised behind the sheet asks the provider for
|
|
118
|
+
* another amount (`CheckoutOrder.amountDueCents`, else its total) — so a host
|
|
119
|
+
* that takes a balance off the charge MUST answer `amountDueCents`.
|
|
116
120
|
*/
|
|
117
121
|
totalCents?: number;
|
|
118
122
|
/**
|
|
@@ -168,6 +172,17 @@ export interface CheckoutPaymentList {
|
|
|
168
172
|
pixBank?: CheckoutPixBank;
|
|
169
173
|
/** Says a short sentence in the host's own toast — "Código Pix copiado". */
|
|
170
174
|
notify?: (message: string) => void;
|
|
175
|
+
/**
|
|
176
|
+
* The host lays out the computer's two columns itself (FUT-3685, L4): its
|
|
177
|
+
* step cards on the left, and this element on the right — the list portals
|
|
178
|
+
* the "Resumo do pedido" column into it (the lines, the money rows, "Total",
|
|
179
|
+
* `summary`, the extras and the pay button), so no bar is drawn at the
|
|
180
|
+
* bottom. `null` while the element is not mounted yet: the bar waits rather
|
|
181
|
+
* than flash. Leave it OUT (undefined) on a phone, or to keep the list's own
|
|
182
|
+
* layout — the step and the column side by side once the flow's frame is
|
|
183
|
+
* wide, the bar otherwise. The Pix screen and the hand-off keep their bar.
|
|
184
|
+
*/
|
|
185
|
+
summaryTarget?: HTMLElement | null;
|
|
171
186
|
}
|
|
172
187
|
|
|
173
188
|
/** The six banks the Pix face offers to open, in the prototype's order. */
|
|
@@ -240,7 +255,26 @@ export interface PaymentListCopy {
|
|
|
240
255
|
/** "Digite os dados do cartão". */
|
|
241
256
|
hintNew: string;
|
|
242
257
|
};
|
|
258
|
+
/**
|
|
259
|
+
* @deprecated Google Pay is no longer a row (FUT-3685): it is a button in
|
|
260
|
+
* the "PAGAMENTO RÁPIDO" block — see {@link PaymentListCopy.wallets}. Kept so
|
|
261
|
+
* a host's own pack still type-checks; nothing draws it.
|
|
262
|
+
*/
|
|
243
263
|
googlePay: { label: string; hint: string };
|
|
264
|
+
/**
|
|
265
|
+
* The "PAGAMENTO RÁPIDO" block above the list (FUT-3685, T5–T8): the wallet
|
|
266
|
+
* buttons' accessible names and the words around them.
|
|
267
|
+
*/
|
|
268
|
+
wallets: {
|
|
269
|
+
/** "Pagamento rápido" — drawn in capitals by CSS. */
|
|
270
|
+
heading: string;
|
|
271
|
+
/** "Pagar com Google Pay". */
|
|
272
|
+
googlePay: string;
|
|
273
|
+
/** "Pagar com Apple Pay". */
|
|
274
|
+
applePay: string;
|
|
275
|
+
/** "ou escolha como pagar" — the divider between the wallets and the rows. */
|
|
276
|
+
or: string;
|
|
277
|
+
};
|
|
244
278
|
handOff: {
|
|
245
279
|
label: string;
|
|
246
280
|
/** "Você paga na página da InfinitePay e volta" — `null` when the store published no name. */
|
|
@@ -258,6 +292,17 @@ export interface PaymentListCopy {
|
|
|
258
292
|
email: string;
|
|
259
293
|
/** "(o PagBank pede para pagar pelo app)" — `null` when no name was published. */
|
|
260
294
|
why: (provider: string | null) => string;
|
|
295
|
+
/**
|
|
296
|
+
* The line under "CPF" at the top of Pagamento (FUT-3685, T3) — "o PagBank
|
|
297
|
+
* pede para pagar pelo app"; `null` when no name was published.
|
|
298
|
+
*/
|
|
299
|
+
cpfHint: (provider: string | null) => string;
|
|
300
|
+
/**
|
|
301
|
+
* The link under the CPF opened by "Alterar" over a CPF on file, closing it
|
|
302
|
+
* again — "Manter o CPF salvo" (FUT-3685). Both packs carry it; absent ⇒ no
|
|
303
|
+
* link (an empty field keeps the saved CPF either way).
|
|
304
|
+
*/
|
|
305
|
+
keepSavedCpf?: string;
|
|
261
306
|
cpfIncomplete: string;
|
|
262
307
|
phoneIncomplete: string;
|
|
263
308
|
};
|
|
@@ -1,58 +1,136 @@
|
|
|
1
|
-
import { useMemo, useState } from "react";
|
|
1
|
+
import { useCallback, useMemo, useRef, useState } from "react";
|
|
2
2
|
|
|
3
|
+
import { applePaySessionClass, applePaySupported, type ApplePaySessionClass } from "../apple-pay-button";
|
|
3
4
|
import { useGooglePayClient, type GooglePayGatewayParams, type GooglePaymentsClient } from "../google-pay-button";
|
|
4
|
-
import { cardPathAvailable, googlePayConfig, offeredMethods } from "../method-capability";
|
|
5
|
+
import { applePayDeclared, cardPathAvailable, googlePayConfig, offeredMethods } from "../method-capability";
|
|
5
6
|
import { methodChosenAtProvider } from "../providers/registry";
|
|
6
|
-
import type { CheckoutProviderConfig } from "../types";
|
|
7
|
+
import type { CheckoutProviderConfig, PaymentMethod } from "../types";
|
|
7
8
|
|
|
8
9
|
import type { OnlineCapability } from "./choice";
|
|
9
10
|
|
|
10
|
-
/**
|
|
11
|
+
/** Apple Pay, ready to start a session inside the tap. */
|
|
12
|
+
export interface ListApplePay {
|
|
13
|
+
Session: ApplePaySessionClass;
|
|
14
|
+
/** The host's merchant-validation port (server-side). */
|
|
15
|
+
validateMerchant: (validationURL: string) => Promise<unknown>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** What the store's chain lets THIS browser offer in the list, the wallets included. */
|
|
11
19
|
export interface ListCapability extends OnlineCapability {
|
|
12
|
-
/** Google Pay, ready to open its sheet — `null` when
|
|
20
|
+
/** Google Pay, ready to open its sheet — `null` when it is not offered, or while the host re-prices. */
|
|
13
21
|
googlePay: { client: GooglePaymentsClient; params: GooglePayGatewayParams } | null;
|
|
22
|
+
/** Apple Pay, ready to start its session — `null` when it is not offered, or while the host re-prices. */
|
|
23
|
+
applePay: ListApplePay | null;
|
|
24
|
+
/**
|
|
25
|
+
* The wallets the "PAGAMENTO RÁPIDO" block draws, in the prototype's order.
|
|
26
|
+
* A wallet drawn while the host re-prices has its button held
|
|
27
|
+
* (`googlePay`/`applePay` is `null` for that moment).
|
|
28
|
+
*/
|
|
29
|
+
wallets: ListWallet[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type ListWallet = "GOOGLE_PAY" | "APPLE_PAY";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The host priced the sheet once and, for a moment, has no price (re-pricing:
|
|
36
|
+
* the credit was toggled) — a wallet keeps its place, held, until the price
|
|
37
|
+
* returns. An explicit zero (a balance covers everything) withdraws it.
|
|
38
|
+
*/
|
|
39
|
+
function useRepricing(totalCents: number | undefined): { priced: boolean; repricing: boolean } {
|
|
40
|
+
const priced = totalCents !== undefined && totalCents > 0;
|
|
41
|
+
const [wasPriced, setWasPriced] = useState(false);
|
|
42
|
+
if (priced && !wasPriced) setWasPriced(true);
|
|
43
|
+
if (totalCents === 0 && wasPriced) setWasPriced(false);
|
|
44
|
+
return { priced, repricing: totalCents === undefined && wasPriced };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Apple Pay's session class, when the chain head declares the wallet and this device can pay (R5). */
|
|
48
|
+
function appleSession(config: CheckoutProviderConfig | null, port: ListApplePay["validateMerchant"] | undefined): ListApplePay | null {
|
|
49
|
+
if (!port || !applePayDeclared(config) || !applePaySupported()) return null;
|
|
50
|
+
const Session = applePaySessionClass();
|
|
51
|
+
return Session ? { Session, validateMerchant: port } : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Whether the card is offered and available on THIS page — what a wallet is another way of producing. */
|
|
55
|
+
function cardHere(handOff: boolean, offered: PaymentMethod[] | null, cardUnavailable: boolean): boolean {
|
|
56
|
+
return !handOff && !cardUnavailable && (offered === null || offered.includes("CARD"));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The wallets the block draws: each ready one, while the card is here and the sheet is (or was) priced. */
|
|
60
|
+
function walletsShown(shown: boolean, google: unknown, apple: unknown): ListWallet[] {
|
|
61
|
+
if (!shown) return [];
|
|
62
|
+
const wallets: ListWallet[] = [];
|
|
63
|
+
if (google) wallets.push("GOOGLE_PAY");
|
|
64
|
+
if (apple) wallets.push("APPLE_PAY");
|
|
65
|
+
return wallets;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Google Pay's client and the head's gateway parameters, when both are there. */
|
|
69
|
+
function googleSheet(config: CheckoutProviderConfig | null, client: GooglePaymentsClient | null): ListCapability["googlePay"] {
|
|
70
|
+
const params = googlePayConfig(config);
|
|
71
|
+
return params && client ? { client, params } : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** {@link useListCapability}'s answer, from what it read. */
|
|
75
|
+
function capabilityOf(
|
|
76
|
+
config: CheckoutProviderConfig | null,
|
|
77
|
+
client: GooglePaymentsClient | null,
|
|
78
|
+
price: { priced: boolean; repricing: boolean },
|
|
79
|
+
port: ListApplePay["validateMerchant"] | undefined,
|
|
80
|
+
): ListCapability {
|
|
81
|
+
const offered = offeredMethods(config);
|
|
82
|
+
const cardUnavailable = !cardPathAvailable(config);
|
|
83
|
+
const handOff = methodChosenAtProvider(config?.chain?.[0]?.checkoutScreen, config);
|
|
84
|
+
const card = cardHere(handOff, offered, cardUnavailable);
|
|
85
|
+
const google = googleSheet(config, client);
|
|
86
|
+
const apple = appleSession(config, port);
|
|
87
|
+
const live = card && price.priced;
|
|
88
|
+
return {
|
|
89
|
+
handOff,
|
|
90
|
+
offered,
|
|
91
|
+
cardUnavailable,
|
|
92
|
+
wallets: walletsShown(card && (price.priced || price.repricing), google, apple),
|
|
93
|
+
googlePay: live ? google : null,
|
|
94
|
+
applePay: live ? apple : null,
|
|
95
|
+
};
|
|
14
96
|
}
|
|
15
97
|
|
|
16
98
|
/**
|
|
17
99
|
* The capability reads the tabs layout already makes (`method-capability.ts`,
|
|
18
|
-
* `providers/registry.ts`), plus the
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
100
|
+
* `providers/registry.ts`), plus the two wallets of the "PAGAMENTO RÁPIDO"
|
|
101
|
+
* block (FUT-3685, R5). A wallet is ready when the chain head declares it AND
|
|
102
|
+
* this device can pay with it — Google's `isReadyToPay`, Apple's
|
|
103
|
+
* `canMakePayments` — AND the card is offered and available here, since a
|
|
104
|
+
* wallet is another way of producing the card (so a Pix-only store never shows
|
|
105
|
+
* the block). Apple also needs the host's merchant-validation port: without it
|
|
106
|
+
* the session could only abort.
|
|
22
107
|
*
|
|
23
|
-
*
|
|
108
|
+
* Both sheets open inside the tap, before any order exists, so they are priced
|
|
24
109
|
* with the host's `totalCents` — the amount DUE. A host that never priced it,
|
|
25
|
-
* or prices it at zero (a balance
|
|
26
|
-
* Once priced, a moment without a price (the host re-pricing — the credit was
|
|
27
|
-
* toggled) keeps the row, and the list holds a chosen Google Pay's button,
|
|
28
|
-
* plainly, until the price returns.
|
|
29
|
-
*
|
|
30
|
-
* Apple Pay is not a row here: its sheet must open synchronously inside the
|
|
31
|
-
* tap, before any order could be raised for it. The tabs layout keeps it.
|
|
110
|
+
* or prices it at zero (a balance covers everything), offers no wallet.
|
|
32
111
|
*/
|
|
33
112
|
export function useListCapability(
|
|
34
113
|
config: CheckoutProviderConfig | null,
|
|
35
|
-
/** The host's price for the
|
|
114
|
+
/** The host's price for the sheets — no price (or zero), no wallet. */
|
|
36
115
|
totalCents: number | undefined,
|
|
116
|
+
validateApplePayMerchant?: ListApplePay["validateMerchant"],
|
|
37
117
|
): ListCapability {
|
|
38
118
|
const declared = googlePayConfig(config) !== null;
|
|
39
119
|
// `null` tells the client hook not to load pay.js at all.
|
|
40
120
|
const client = useGooglePayClient(declared ? undefined : null, "TEST");
|
|
41
|
-
const priced
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
return
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
};
|
|
57
|
-
}, [config, client, priced, repricing]);
|
|
121
|
+
const { priced, repricing } = useRepricing(totalCents);
|
|
122
|
+
// The port is read at the press, through a ref: a host passing a new
|
|
123
|
+
// function each render must not rebuild the capability (and the wallets'
|
|
124
|
+
// buttons) every time. Only whether there IS one changes what is offered.
|
|
125
|
+
const port = useRef(validateApplePayMerchant);
|
|
126
|
+
port.current = validateApplePayMerchant;
|
|
127
|
+
const hasPort = validateApplePayMerchant !== undefined;
|
|
128
|
+
const stablePort = useCallback((validationURL: string): Promise<unknown> => {
|
|
129
|
+
const current = port.current;
|
|
130
|
+
return current ? current(validationURL) : Promise.reject(new Error("no merchant validation port"));
|
|
131
|
+
}, []);
|
|
132
|
+
return useMemo(
|
|
133
|
+
() => capabilityOf(config, client, { priced, repricing }, hasPort ? stablePort : undefined),
|
|
134
|
+
[config, client, priced, repricing, hasPort, stablePort],
|
|
135
|
+
);
|
|
58
136
|
}
|