@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
|
@@ -20,8 +20,8 @@ export const CHARGE_WINDOW_MS = 5_000;
|
|
|
20
20
|
*/
|
|
21
21
|
export type ListChargePhase = "idle" | "counting" | "sending" | "waiting";
|
|
22
22
|
|
|
23
|
-
/** What a charge runs against: the card the buyer chose, or
|
|
24
|
-
export type ListChargeKind = "CARD" | "GOOGLE_PAY";
|
|
23
|
+
/** What a charge runs against: the card the buyer chose, or a wallet's sheet. */
|
|
24
|
+
export type ListChargeKind = "CARD" | "GOOGLE_PAY" | "APPLE_PAY";
|
|
25
25
|
|
|
26
26
|
/** The controller's slice this needs. */
|
|
27
27
|
interface RaisePorts {
|
|
@@ -41,7 +41,7 @@ export interface ListCharge {
|
|
|
41
41
|
kind: ListChargeKind | null;
|
|
42
42
|
/** The amount named when the button was pressed — the bar shows THIS, never a later one. */
|
|
43
43
|
pressedTotal: string | null;
|
|
44
|
-
/** Start a card charge (with its 5-second bar) or a
|
|
44
|
+
/** Start a card charge (with its 5-second bar) or a wallet one (straight away: its sheet is the confirmation). Only from idle. */
|
|
45
45
|
start: (kind: ListChargeKind, total: string) => void;
|
|
46
46
|
/** Cancelar, during the bar: nothing has been sent, and nothing will be. */
|
|
47
47
|
cancel: () => void;
|
|
@@ -131,11 +131,12 @@ export function useListCharge(ports: RaisePorts): ListCharge {
|
|
|
131
131
|
setPressedTotal(null);
|
|
132
132
|
}, []);
|
|
133
133
|
|
|
134
|
-
const send = useCallback(() => {
|
|
134
|
+
const send = useCallback((wallet: boolean) => {
|
|
135
135
|
timer.current = null;
|
|
136
136
|
// Checked again at the last moment: a hold raised between two renders
|
|
137
|
-
// must not let the bar's own timer charge past it.
|
|
138
|
-
|
|
137
|
+
// must not let the bar's own timer charge past it. A wallet's press is not
|
|
138
|
+
// the bar's: its sheet is already open, and the block held it itself.
|
|
139
|
+
if (!wallet && !allowed.current) return cancel();
|
|
139
140
|
setPhase("sending");
|
|
140
141
|
const { order, cartTotal, dueCents } = latest.current;
|
|
141
142
|
if (reusable(order, cartTotal, dueCents) && order) {
|
|
@@ -152,12 +153,13 @@ export function useListCharge(ports: RaisePorts): ListCharge {
|
|
|
152
153
|
const start = useCallback(
|
|
153
154
|
(next: ListChargeKind, total: string) => {
|
|
154
155
|
if (phaseRef.current !== "idle") return;
|
|
155
|
-
|
|
156
|
+
const wallet = next !== "CARD";
|
|
157
|
+
phaseRef.current = wallet ? "sending" : "counting";
|
|
156
158
|
setKind(next);
|
|
157
159
|
setPressedTotal(total);
|
|
158
|
-
if (
|
|
160
|
+
if (wallet) return send(true);
|
|
159
161
|
setPhase("counting");
|
|
160
|
-
timer.current = setTimeout(send, CHARGE_WINDOW_MS);
|
|
162
|
+
timer.current = setTimeout(() => send(false), CHARGE_WINDOW_MS);
|
|
161
163
|
},
|
|
162
164
|
[send],
|
|
163
165
|
);
|
|
@@ -3,10 +3,12 @@ import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type
|
|
|
3
3
|
import { NEW_CARD, type CardDetails, type CardFieldErrors, type SavedCard } from "../../../card";
|
|
4
4
|
import { useSavedCardList } from "../card-sources";
|
|
5
5
|
import { useCheckoutCopy } from "../copy-context";
|
|
6
|
-
import type { BuyerInfo, CheckoutCustomerField, CheckoutProviderConfig } from "../types";
|
|
6
|
+
import type { BuyerInfo, CheckoutCustomerField, CheckoutProviderConfig, PaymentMethod } from "../types";
|
|
7
7
|
|
|
8
8
|
import { askedFields, cardComplete, lastUsedCard, onlineRows, preselection, raisedMethod, type OnlineCapability, type OnlineRow } from "./choice";
|
|
9
|
+
import { isTaxId, topCpfField } from "./top-cpf";
|
|
9
10
|
import type { CheckoutListChoice } from "./types";
|
|
11
|
+
import type { ListWallet } from "./use-capability";
|
|
10
12
|
|
|
11
13
|
const EMPTY_CARD: CardDetails = { number: "", holder: "", expiry: "", cvv: "" };
|
|
12
14
|
|
|
@@ -31,8 +33,15 @@ export interface ListState {
|
|
|
31
33
|
choice: CheckoutListChoice | null;
|
|
32
34
|
choose: (next: CheckoutListChoice) => void;
|
|
33
35
|
card: ListCard;
|
|
34
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* The buyer's own details the chosen online option asks for INSIDE its row
|
|
38
|
+
* (M11) — the hand-off provider's mobile (K4). Never the CPF: that is `top`.
|
|
39
|
+
*/
|
|
35
40
|
asked: CheckoutCustomerField[];
|
|
41
|
+
/** The CPF asked at the top of Pagamento, or `null` (FUT-3685, E1/E2). */
|
|
42
|
+
top: CheckoutCustomerField | null;
|
|
43
|
+
/** Every online method the list offers, wallets included — what the top CPF is decided over (R2). */
|
|
44
|
+
methods: PaymentMethod[];
|
|
36
45
|
}
|
|
37
46
|
|
|
38
47
|
/**
|
|
@@ -100,6 +109,10 @@ export function useListState(input: {
|
|
|
100
109
|
/** The buyer's last online method, from the host (FUT-3611). */
|
|
101
110
|
lastMethod?: "PIX" | "CARD" | null;
|
|
102
111
|
onChoiceChange?: (choice: CheckoutListChoice | null) => void;
|
|
112
|
+
/** The wallets the block above the list draws — each raises a CARD order. */
|
|
113
|
+
wallets?: readonly ListWallet[];
|
|
114
|
+
/** "Alterar" on the payer line revealed the CPF at the top (E2 → E1). */
|
|
115
|
+
revealed?: boolean;
|
|
103
116
|
}): ListState {
|
|
104
117
|
const { capability } = input;
|
|
105
118
|
const rows = useMemo(() => onlineRows(capability), [capability]);
|
|
@@ -147,9 +160,29 @@ export function useListState(input: {
|
|
|
147
160
|
method: raisedMethod(choice.method, capability.offered),
|
|
148
161
|
arrivedWith: input.arrivedWith,
|
|
149
162
|
taxIdOnFile: input.taxIdOnFile,
|
|
150
|
-
})
|
|
163
|
+
}).filter((field) => !isTaxId(field))
|
|
151
164
|
: [],
|
|
152
165
|
[choice, input.config, capability.offered, input.arrivedWith, input.taxIdOnFile],
|
|
153
166
|
);
|
|
154
|
-
|
|
167
|
+
const methods = useMemo(() => offeredOnline(rows, capability.offered, input.wallets ?? []), [rows, capability.offered, input.wallets]);
|
|
168
|
+
const top = useMemo(
|
|
169
|
+
() =>
|
|
170
|
+
topCpfField({
|
|
171
|
+
chain: input.config?.chain,
|
|
172
|
+
methods,
|
|
173
|
+
arrivedWith: input.arrivedWith,
|
|
174
|
+
taxIdOnFile: input.taxIdOnFile,
|
|
175
|
+
revealed: input.revealed ?? false,
|
|
176
|
+
}),
|
|
177
|
+
[input.config, methods, input.arrivedWith, input.taxIdOnFile, input.revealed],
|
|
178
|
+
);
|
|
179
|
+
return { rows, choice, choose, card, asked, top, methods };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The chain methods the list's rows and wallets would raise, each once. */
|
|
183
|
+
function offeredOnline(rows: readonly OnlineRow[], offered: PaymentMethod[] | null, wallets: readonly ListWallet[]): PaymentMethod[] {
|
|
184
|
+
const methods = new Set<PaymentMethod>();
|
|
185
|
+
for (const row of rows) if (!row.disabled) methods.add(raisedMethod(row.method, offered));
|
|
186
|
+
if (wallets.length > 0) methods.add("CARD");
|
|
187
|
+
return [...methods];
|
|
155
188
|
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { Box, Divider } from "@mui/material";
|
|
2
|
+
import { useTheme, type Theme } from "@mui/material/styles";
|
|
3
|
+
import { useEffect, useId, useRef, type JSX } from "react";
|
|
4
|
+
|
|
5
|
+
import { ChargeFailure } from "../card-view";
|
|
6
|
+
import { useCheckoutCopy } from "../copy-context";
|
|
7
|
+
import { UNRESOLVED_CODE } from "../failure-codes";
|
|
8
|
+
import type { GooglePaymentsClient } from "../google-pay-button";
|
|
9
|
+
import { useCheckoutComponents } from "../ui";
|
|
10
|
+
|
|
11
|
+
import type { ListChargeFailure } from "./charge-runners";
|
|
12
|
+
import { GroupHeading } from "./option-row";
|
|
13
|
+
import type { PaymentListCopy } from "./types";
|
|
14
|
+
import type { ListCapability, ListWallet } from "./use-capability";
|
|
15
|
+
|
|
16
|
+
/** The prototype's 44px wallet button (L3), on the theme's spacing scale. */
|
|
17
|
+
const WALLET_HEIGHT = 5.5;
|
|
18
|
+
|
|
19
|
+
/** Why the buttons are held, as each button carries it: disabled, and described by the reason. */
|
|
20
|
+
interface Held {
|
|
21
|
+
/** The reason's element id, or `null` when nothing holds them. */
|
|
22
|
+
describedBy: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The pill's edge: none on a light page (the black pill is its own edge); a
|
|
27
|
+
* hairline at night, where black on a dark surface has none; and a visible
|
|
28
|
+
* one while held, when the pill is dimmed (FUT-3685 review).
|
|
29
|
+
*/
|
|
30
|
+
const pillEdge = (held: boolean) => (t: Theme): string => {
|
|
31
|
+
if (held) return `1px solid ${t.palette.text.secondary}`;
|
|
32
|
+
return t.palette.mode === "dark" ? `1px solid ${t.palette.divider}` : "1px solid transparent";
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The look every wallet pill shares — the prototype's 44px black pill (L3).
|
|
37
|
+
* The edge is an OUTLINE drawn just inside the pill: it paints over Google's
|
|
38
|
+
* own element and moves nothing, so both pills keep one size and their labels
|
|
39
|
+
* one baseline.
|
|
40
|
+
*/
|
|
41
|
+
const pill = (held: boolean) => ({
|
|
42
|
+
height: (t: Theme) => t.spacing(WALLET_HEIGHT),
|
|
43
|
+
width: "100%",
|
|
44
|
+
border: 0,
|
|
45
|
+
outline: pillEdge(held),
|
|
46
|
+
outlineOffset: "-1px",
|
|
47
|
+
borderRadius: (t: Theme) => t.spacing(WALLET_HEIGHT / 2),
|
|
48
|
+
overflow: "hidden",
|
|
49
|
+
} as const);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Google Pay's own button (FUT-471's brand rule: the buyer presses Google's
|
|
53
|
+
* button, drawn by `createButton`, never a look-alike) — black, "pay", a pill.
|
|
54
|
+
* Its press opens the sheet SYNCHRONOUSLY, inside the tap, through `onPress`.
|
|
55
|
+
* Held, Google's element is marked disabled and described by the reason —
|
|
56
|
+
* it stays in the accessibility tree — and a press does nothing.
|
|
57
|
+
*/
|
|
58
|
+
function GoogleWalletButton({ client, onPress, held }: { client: GooglePaymentsClient; onPress: () => void; held: Held }): JSX.Element {
|
|
59
|
+
const mount = useRef<HTMLDivElement | null>(null);
|
|
60
|
+
const latest = useRef(onPress);
|
|
61
|
+
latest.current = onPress;
|
|
62
|
+
const locale = useCheckoutCopy().screens.wallet.googlePay.buttonLocale;
|
|
63
|
+
const theme = useTheme();
|
|
64
|
+
// Google's own parameter, in CSS pixels: half the button's height is a pill.
|
|
65
|
+
const radius = Number.parseFloat(theme.spacing(WALLET_HEIGHT / 2));
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
const element = mount.current;
|
|
68
|
+
if (!element) return undefined;
|
|
69
|
+
element.replaceChildren(
|
|
70
|
+
client.createButton({
|
|
71
|
+
onClick: () => latest.current(),
|
|
72
|
+
buttonSizeMode: "fill",
|
|
73
|
+
buttonLocale: locale,
|
|
74
|
+
buttonColor: "black",
|
|
75
|
+
buttonType: "pay",
|
|
76
|
+
buttonRadius: radius,
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
return () => element.replaceChildren();
|
|
80
|
+
}, [client, locale, radius]);
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
const button = mount.current?.querySelector("button");
|
|
83
|
+
if (!button) return;
|
|
84
|
+
if (held.describedBy) {
|
|
85
|
+
button.setAttribute("aria-disabled", "true");
|
|
86
|
+
button.setAttribute("aria-describedby", held.describedBy);
|
|
87
|
+
} else {
|
|
88
|
+
button.removeAttribute("aria-disabled");
|
|
89
|
+
button.removeAttribute("aria-describedby");
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
const isHeld = held.describedBy !== null;
|
|
93
|
+
return (
|
|
94
|
+
<Box
|
|
95
|
+
ref={mount}
|
|
96
|
+
data-testid="list-google-pay"
|
|
97
|
+
data-held={isHeld ? "true" : undefined}
|
|
98
|
+
sx={{ ...pill(isHeld), pointerEvents: isHeld ? "none" : undefined, "& > *": { width: "100%", height: "100%" } }}
|
|
99
|
+
/>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Apple's button: the `-apple-pay-button` appearance Safari draws in the
|
|
105
|
+
* buyer's own language (Apple's Human Interface Guidelines), black, "pay".
|
|
106
|
+
* Only a browser that has an `ApplePaySession` ever renders it; one that does
|
|
107
|
+
* not know the appearance (a test browser with a stubbed session) gets the
|
|
108
|
+
* accessible name drawn as text on the same black pill instead.
|
|
109
|
+
*/
|
|
110
|
+
function AppleWalletButton({ label, onPress, held }: { label: string; onPress: () => void; held: Held }): JSX.Element {
|
|
111
|
+
const isHeld = held.describedBy !== null;
|
|
112
|
+
return (
|
|
113
|
+
<Box
|
|
114
|
+
component="button"
|
|
115
|
+
type="button"
|
|
116
|
+
aria-label={label}
|
|
117
|
+
disabled={isHeld}
|
|
118
|
+
aria-describedby={held.describedBy ?? undefined}
|
|
119
|
+
onClick={onPress}
|
|
120
|
+
data-testid="list-apple-pay"
|
|
121
|
+
sx={{
|
|
122
|
+
WebkitAppearance: "-apple-pay-button",
|
|
123
|
+
"-apple-pay-button-type": "pay",
|
|
124
|
+
"-apple-pay-button-style": "black",
|
|
125
|
+
...pill(isHeld),
|
|
126
|
+
cursor: isHeld ? "default" : "pointer",
|
|
127
|
+
bgcolor: "common.black",
|
|
128
|
+
color: "common.white",
|
|
129
|
+
typography: "body1",
|
|
130
|
+
fontWeight: 600,
|
|
131
|
+
"&:focus-visible": { outline: (t) => `${t.spacing(0.25)} solid ${t.palette.primary.main}`, outlineOffset: (t) => t.spacing(0.25) },
|
|
132
|
+
"@supports (-webkit-appearance: -apple-pay-button)": { "& > span": { display: "none" } },
|
|
133
|
+
}}
|
|
134
|
+
>
|
|
135
|
+
<span aria-hidden>{label}</span>
|
|
136
|
+
</Box>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** One wallet's button, or a held pill while the host re-prices it. */
|
|
141
|
+
function WalletButton({ wallet, cap, copy, onGoogle, onApple, held }: {
|
|
142
|
+
wallet: ListWallet;
|
|
143
|
+
cap: ListCapability;
|
|
144
|
+
copy: PaymentListCopy;
|
|
145
|
+
onGoogle: () => void;
|
|
146
|
+
onApple: () => void;
|
|
147
|
+
held: Held;
|
|
148
|
+
}): JSX.Element {
|
|
149
|
+
if (wallet === "GOOGLE_PAY" && cap.googlePay) return <GoogleWalletButton client={cap.googlePay.client} onPress={onGoogle} held={held} />;
|
|
150
|
+
if (wallet === "APPLE_PAY") return <AppleWalletButton label={copy.wallets.applePay} onPress={onApple} held={held} />;
|
|
151
|
+
// Google while the host re-prices: no sheet can open, so no Google button either — a plain held pill.
|
|
152
|
+
return <HeldWalletButton label={copy.wallets.googlePay} held={held} />;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** A held wallet while the host re-prices: the pill, disabled, named, described by the reason. */
|
|
156
|
+
function HeldWalletButton({ label, held }: { label: string; held: Held }): JSX.Element {
|
|
157
|
+
return (
|
|
158
|
+
<Box
|
|
159
|
+
component="button"
|
|
160
|
+
type="button"
|
|
161
|
+
disabled
|
|
162
|
+
aria-describedby={held.describedBy ?? undefined}
|
|
163
|
+
data-testid="list-wallet-held"
|
|
164
|
+
sx={{ ...pill(true), bgcolor: "common.black", color: "common.white", typography: "body1", fontWeight: 600 }}
|
|
165
|
+
>
|
|
166
|
+
{label}
|
|
167
|
+
</Box>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* "PAGAMENTO RÁPIDO" (FUT-3685, C5, C6, E4, E5, L3, L5): the ready wallets'
|
|
173
|
+
* buttons above the list, then "ou escolha como pagar". Nothing at all — no
|
|
174
|
+
* heading, no buttons, no divider — when no wallet is ready (E4); one wallet
|
|
175
|
+
* takes the full width (E5); two sit side by side from the theme's `sm`
|
|
176
|
+
* (L5) and stack below it (L3).
|
|
177
|
+
*
|
|
178
|
+
* The buttons are not part of the list's radio group (R1): pressing one opens
|
|
179
|
+
* its sheet and never changes the chosen row. `held` is why they cannot act
|
|
180
|
+
* yet — the CPF the provider needs (E3), the host re-pricing — said under them.
|
|
181
|
+
*/
|
|
182
|
+
export function WalletBlock({ cap, copy, held, failure, onGoogle, onApple }: {
|
|
183
|
+
cap: ListCapability;
|
|
184
|
+
copy: PaymentListCopy;
|
|
185
|
+
held: string | null;
|
|
186
|
+
/** The last wallet charge's refusal; an UNRESOLVED one removes the buttons (FUT-563). */
|
|
187
|
+
failure: ListChargeFailure | null;
|
|
188
|
+
onGoogle: () => void;
|
|
189
|
+
onApple: () => void;
|
|
190
|
+
}): JSX.Element | null {
|
|
191
|
+
const { Text } = useCheckoutComponents();
|
|
192
|
+
const id = useId();
|
|
193
|
+
const reasonId = `${id}-held`;
|
|
194
|
+
if (cap.wallets.length === 0) return null;
|
|
195
|
+
const two = cap.wallets.length > 1;
|
|
196
|
+
const unresolved = failure?.code === UNRESOLVED_CODE;
|
|
197
|
+
return (
|
|
198
|
+
<Box data-testid="list-wallets" sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
|
199
|
+
<Box role="group" aria-labelledby={id} sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
|
200
|
+
<GroupHeading id={id}>{copy.wallets.heading}</GroupHeading>
|
|
201
|
+
{unresolved ? null : (
|
|
202
|
+
<Box
|
|
203
|
+
data-testid="list-wallet-buttons"
|
|
204
|
+
data-held={held ? "true" : undefined}
|
|
205
|
+
sx={{
|
|
206
|
+
display: "grid",
|
|
207
|
+
gap: 1.25,
|
|
208
|
+
gridTemplateColumns: { xs: "minmax(0, 1fr)", sm: two ? "repeat(2, minmax(0, 1fr))" : "minmax(0, 1fr)" },
|
|
209
|
+
opacity: (t) => (held ? t.palette.action.disabledOpacity : 1),
|
|
210
|
+
}}
|
|
211
|
+
>
|
|
212
|
+
{cap.wallets.map((wallet) => (
|
|
213
|
+
<WalletButton key={wallet} wallet={wallet} cap={cap} copy={copy} onGoogle={onGoogle} onApple={onApple} held={{ describedBy: held ? reasonId : null }} />
|
|
214
|
+
))}
|
|
215
|
+
</Box>
|
|
216
|
+
)}
|
|
217
|
+
{held ? (
|
|
218
|
+
// The Text slot takes no id; the reason's own box carries the one the buttons point at.
|
|
219
|
+
<Box id={reasonId} data-testid="list-wallets-held">
|
|
220
|
+
<Text variant="body" size="sm" color="secondary" as="p" style={{ margin: 0 }}>
|
|
221
|
+
{held}
|
|
222
|
+
</Text>
|
|
223
|
+
</Box>
|
|
224
|
+
) : null}
|
|
225
|
+
{failure ? <ChargeFailure message={failure.message} unresolved={unresolved} /> : null}
|
|
226
|
+
</Box>
|
|
227
|
+
<Divider data-testid="list-wallets-or">
|
|
228
|
+
{/* The body style the hints use: a caption tone fell under 4.5:1 on the surface. */}
|
|
229
|
+
<Text variant="body" size="xs" color="secondary" as="span">
|
|
230
|
+
{copy.wallets.or}
|
|
231
|
+
</Text>
|
|
232
|
+
</Divider>
|
|
233
|
+
</Box>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A wallet button's press (FUT-3685, C5, C6): the wallet's sheet opens HERE,
|
|
3
|
+
* synchronously, inside the buyer's tap — the browser opens Google's sheet and
|
|
4
|
+
* Safari starts an Apple Pay session only from inside one — priced at the
|
|
5
|
+
* host's amount DUE (`totalCents`, K3). The order is raised in parallel by the
|
|
6
|
+
* list's charge, and the runner charges the sheet's key only against an order
|
|
7
|
+
* that asks the provider for the same amount (R4).
|
|
8
|
+
*/
|
|
9
|
+
import { useEffect, type MutableRefObject } from "react";
|
|
10
|
+
|
|
11
|
+
import { startApplePaySession } from "../apple-pay-button";
|
|
12
|
+
import { useCheckoutCopy } from "../copy-context";
|
|
13
|
+
import { paymentDataRequest, sheetDismissed } from "../google-pay-button";
|
|
14
|
+
|
|
15
|
+
import type { ListChargeFailure, WalletSheet } from "./charge-runners";
|
|
16
|
+
import type { ListCapability } from "./use-capability";
|
|
17
|
+
import type { ListCharge } from "./use-list-charge";
|
|
18
|
+
|
|
19
|
+
/** What a press needs from the list face. */
|
|
20
|
+
export interface WalletPressInput {
|
|
21
|
+
cap: ListCapability;
|
|
22
|
+
charge: ListCharge;
|
|
23
|
+
/** The host's amount due, in cents — what the sheet shows. */
|
|
24
|
+
cents: number | undefined;
|
|
25
|
+
/** The amount the bar names for an online payment, frozen at the press. */
|
|
26
|
+
total: string;
|
|
27
|
+
/** Something stops a press: a reason the block shows, or a charge already under way. */
|
|
28
|
+
blocked: boolean;
|
|
29
|
+
/** Before the sheet opens: clear the last refusal, keep the asked details. */
|
|
30
|
+
before: () => void;
|
|
31
|
+
/** A sheet that could not open, in the wallet's own words. */
|
|
32
|
+
onFailed: (failure: ListChargeFailure) => void;
|
|
33
|
+
/**
|
|
34
|
+
* Set synchronously by a press, cleared when the charge is back to idle: a
|
|
35
|
+
* second tap before React re-renders (a double tap) opens no second sheet.
|
|
36
|
+
*/
|
|
37
|
+
pressed: MutableRefObject<boolean>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Open a wallet's sheet, synchronously. A sheet that throws instead — Safari's
|
|
42
|
+
* `InvalidAccessError` for a session it will not begin, a pay.js that throws
|
|
43
|
+
* outright — starts no charge, so nothing would ever clear the press guard:
|
|
44
|
+
* both buttons would be dead (FUT-3685 review). So the guard is released, and
|
|
45
|
+
* the buyer is told the wallet could not start; the next press tries again.
|
|
46
|
+
*/
|
|
47
|
+
function openSheet<T>(input: WalletPressInput, open: () => T, cannotStart: string): T | null {
|
|
48
|
+
try {
|
|
49
|
+
return open();
|
|
50
|
+
} catch {
|
|
51
|
+
input.pressed.current = false;
|
|
52
|
+
input.onFailed({ message: cannotStart, code: null });
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Google Pay's press: `loadPaymentData` inside the tap, then the charge. */
|
|
58
|
+
export function useGooglePayPress(input: WalletPressInput, sheet: MutableRefObject<WalletSheet | null>): () => void {
|
|
59
|
+
const cannotStart = useCheckoutCopy().screens.wallet.googlePay.cannotComplete;
|
|
60
|
+
return () => {
|
|
61
|
+
const google = input.cap.googlePay;
|
|
62
|
+
const cents = input.cents;
|
|
63
|
+
if (!google || cents === undefined || input.blocked || input.pressed.current) return;
|
|
64
|
+
input.pressed.current = true;
|
|
65
|
+
input.before();
|
|
66
|
+
const loading = openSheet(input, () => google.client.loadPaymentData(paymentDataRequest(google.params, { totalCents: cents })), cannotStart);
|
|
67
|
+
if (!loading) return;
|
|
68
|
+
// A sheet that rejects later reaches the runner, which says so and settles the charge.
|
|
69
|
+
const answer = loading.then(
|
|
70
|
+
(data) => data.paymentMethodData.tokenizationData.token,
|
|
71
|
+
(error: unknown) => {
|
|
72
|
+
if (sheetDismissed(error)) return null;
|
|
73
|
+
throw error;
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
sheet.current = { type: "GOOGLE_PAY", answer, cents };
|
|
77
|
+
input.charge.start("GOOGLE_PAY", input.total);
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Apple Pay's press: the session is CREATED and BEGUN synchronously, in the
|
|
83
|
+
* tap — Safari's rule — and the order is raised while the buyer looks at the
|
|
84
|
+
* sheet. A merchant validation that cannot run says why, beside the list.
|
|
85
|
+
*/
|
|
86
|
+
export function useApplePayPress(input: WalletPressInput, sheet: MutableRefObject<WalletSheet | null>): () => void {
|
|
87
|
+
const copy = useCheckoutCopy().screens.wallet.applePay;
|
|
88
|
+
return () => {
|
|
89
|
+
const apple = input.cap.applePay;
|
|
90
|
+
const cents = input.cents;
|
|
91
|
+
if (!apple || cents === undefined || input.blocked || input.pressed.current) return;
|
|
92
|
+
input.pressed.current = true;
|
|
93
|
+
input.before();
|
|
94
|
+
const started = openSheet(
|
|
95
|
+
input,
|
|
96
|
+
() =>
|
|
97
|
+
startApplePaySession({
|
|
98
|
+
Session: apple.Session,
|
|
99
|
+
cents,
|
|
100
|
+
copy,
|
|
101
|
+
validateMerchant: apple.validateMerchant,
|
|
102
|
+
onError: (message) => input.onFailed({ message, code: null }),
|
|
103
|
+
}),
|
|
104
|
+
copy.cannotComplete,
|
|
105
|
+
);
|
|
106
|
+
if (!started) return;
|
|
107
|
+
sheet.current = { type: "APPLE_PAY", answer: started.answer, cents, complete: started.complete };
|
|
108
|
+
input.charge.start("APPLE_PAY", input.total);
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A sheet left open with nobody to charge it: the raise behind it was refused,
|
|
114
|
+
* and the list is back to its rows. Apple's sheet waits for an outcome, so it
|
|
115
|
+
* is told this one failed; nothing is charged.
|
|
116
|
+
*/
|
|
117
|
+
export function useCloseOrphanSheet(phase: ListCharge["phase"], sheet: MutableRefObject<WalletSheet | null>, pressed: MutableRefObject<boolean>): void {
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
if (phase !== "idle") return;
|
|
120
|
+
// Back to idle: a new press may open a sheet again.
|
|
121
|
+
pressed.current = false;
|
|
122
|
+
const open = sheet.current;
|
|
123
|
+
sheet.current = null;
|
|
124
|
+
if (!open || open.claimed) return;
|
|
125
|
+
void open.answer.then((key) => {
|
|
126
|
+
if (key !== null) open.complete?.(false);
|
|
127
|
+
}, () => undefined);
|
|
128
|
+
}, [phase, sheet, pressed]);
|
|
129
|
+
}
|
|
@@ -212,7 +212,7 @@ function AddressRow({
|
|
|
212
212
|
const DIVIDER = <Box sx={{ height: "1px", bgcolor: "divider" }} />;
|
|
213
213
|
|
|
214
214
|
/** The cart's lines, "2× X-Burger … R$ 36,00", when the host passed them. */
|
|
215
|
-
function SummaryLines({ lines }: { lines: CheckoutSummaryLine[] }): JSX.Element {
|
|
215
|
+
export function SummaryLines({ lines }: { lines: CheckoutSummaryLine[] }): JSX.Element {
|
|
216
216
|
const { Text } = useCheckoutComponents();
|
|
217
217
|
const copy = useCheckoutCopy().screens.layout;
|
|
218
218
|
return (
|
|
@@ -232,7 +232,7 @@ function SummaryLines({ lines }: { lines: CheckoutSummaryLine[] }): JSX.Element
|
|
|
232
232
|
}
|
|
233
233
|
|
|
234
234
|
/** "Total" and the amount on one line, the host's money rows under it. */
|
|
235
|
-
function SummaryTotal({ totalLabel, discountLines }: { totalLabel: string; discountLines?: ReactNode }): JSX.Element {
|
|
235
|
+
export function SummaryTotal({ totalLabel, discountLines }: { totalLabel: string; discountLines?: ReactNode }): JSX.Element {
|
|
236
236
|
const { Text } = useCheckoutComponents();
|
|
237
237
|
const copy = useCheckoutCopy().screens.layout;
|
|
238
238
|
return (
|
|
@@ -234,6 +234,12 @@ export interface WalletCopy {
|
|
|
234
234
|
cannotStart: string;
|
|
235
235
|
/** It started and failed. */
|
|
236
236
|
cannotComplete: string;
|
|
237
|
+
/**
|
|
238
|
+
* The sheet was authorized and the payment did not go through — the
|
|
239
|
+
* amount moved, or the charge was refused (FUT-3685). Absent ⇒
|
|
240
|
+
* `cannotComplete`. Both packs carry it.
|
|
241
|
+
*/
|
|
242
|
+
paymentFailed?: string;
|
|
237
243
|
payAction: string;
|
|
238
244
|
};
|
|
239
245
|
googlePay: {
|
|
@@ -124,6 +124,7 @@ export const EN_US_CHECKOUT_SCREENS_COPY: CheckoutScreensCopy = {
|
|
|
124
124
|
orderTotal: 'Order total',
|
|
125
125
|
cannotStart: 'Could not start Apple Pay in this store. Pay by card instead.',
|
|
126
126
|
cannotComplete: 'Could not start Apple Pay. Try again, or pay by card.',
|
|
127
|
+
paymentFailed: 'Could not complete the payment with Apple Pay. Try again, or pay by card.',
|
|
127
128
|
payAction: 'Pay with Apple Pay',
|
|
128
129
|
},
|
|
129
130
|
googlePay: {
|
|
@@ -113,6 +113,8 @@ export const PT_BR_CHECKOUT_SCREENS_COPY: CheckoutScreensCopy = {
|
|
|
113
113
|
cannotStart: 'Não foi possível iniciar o Apple Pay nesta loja. Pague com cartão.',
|
|
114
114
|
cannotComplete:
|
|
115
115
|
'Não foi possível iniciar o Apple Pay. Tente novamente ou pague com cartão.',
|
|
116
|
+
paymentFailed:
|
|
117
|
+
'Não foi possível concluir o pagamento com o Apple Pay. Tente novamente ou pague com cartão.',
|
|
116
118
|
payAction: 'Pagar com Apple Pay',
|
|
117
119
|
},
|
|
118
120
|
googlePay: {
|
|
@@ -122,18 +122,19 @@ export const EN_US_PLATFORM_HOMOLOGACAO_COPY: PlatformHomologacaoCopy = {
|
|
|
122
122
|
reconnect: 'Reconnect test seller',
|
|
123
123
|
connectFailed: 'Could not start the test seller connection.',
|
|
124
124
|
returnedConnected: 'Test seller connected. The callback and the code exchange were recorded in the evidence.',
|
|
125
|
-
returnedFailed: 'The test seller connection did not complete.
|
|
125
|
+
returnedFailed: 'The test seller connection did not complete. Try connecting again.',
|
|
126
126
|
generate: 'Generate evidence file\u00a0(TXT)',
|
|
127
|
-
downloadLatest: 'Download
|
|
127
|
+
downloadLatest: 'Download the latest recorded',
|
|
128
128
|
phases: {
|
|
129
129
|
preparing: 'Consulting the application, renewing the token and minting the public key…',
|
|
130
130
|
encrypting: 'Encrypting the test cards in the browser…',
|
|
131
131
|
running: 'Creating the Pix and card orders in the sandbox — this can take up to a minute…',
|
|
132
132
|
downloading: 'Building the file…',
|
|
133
133
|
},
|
|
134
|
-
summary: ({ executed, failed, pending }) =>
|
|
134
|
+
summary: ({ executed, failed, pending }) =>
|
|
135
|
+
`✓\u00a0${executed}\u00a0executed · ✗\u00a0${failed}\u00a0failed · ⧗\u00a0${pending}\u00a0pending`,
|
|
135
136
|
incomplete:
|
|
136
|
-
'The file lists what is missing under Pendências. Do not submit it as complete. Notifications that arrive later show up when you download
|
|
137
|
+
'The file lists what is missing under Pendências. Do not submit it as complete. Notifications that arrive later show up when you download the latest recorded.',
|
|
137
138
|
generateFailed: (phase) =>
|
|
138
139
|
`${
|
|
139
140
|
{
|
|
@@ -142,6 +143,6 @@ export const EN_US_PLATFORM_HOMOLOGACAO_COPY: PlatformHomologacaoCopy = {
|
|
|
142
143
|
running: 'Creating the orders in the sandbox failed.',
|
|
143
144
|
downloading: 'Building the file failed.',
|
|
144
145
|
}[phase]
|
|
145
|
-
} Try again; "Download
|
|
146
|
+
} Try again; "Download the latest recorded" still returns what was recorded.`,
|
|
146
147
|
},
|
|
147
148
|
};
|
|
@@ -103,9 +103,9 @@ export const PT_BR_PLATFORM_HOMOLOGACAO_COPY: PlatformHomologacaoCopy = {
|
|
|
103
103
|
returnedConnected:
|
|
104
104
|
'Lojista de teste conectado. O callback e a troca do código foram registrados nas evidências.',
|
|
105
105
|
returnedFailed:
|
|
106
|
-
'A conexão do lojista de teste não foi concluída.
|
|
106
|
+
'A conexão do lojista de teste não foi concluída. Tente conectar de novo.',
|
|
107
107
|
generate: 'Gerar arquivo de evidências\u00a0(TXT)',
|
|
108
|
-
downloadLatest: 'Baixar
|
|
108
|
+
downloadLatest: 'Baixar o último registrado',
|
|
109
109
|
phases: {
|
|
110
110
|
preparing: 'Consultando a aplicação, renovando o token e gerando a chave pública…',
|
|
111
111
|
encrypting: 'Criptografando os cartões de teste no navegador…',
|
|
@@ -113,11 +113,12 @@ export const PT_BR_PLATFORM_HOMOLOGACAO_COPY: PlatformHomologacaoCopy = {
|
|
|
113
113
|
downloading: 'Montando o arquivo…',
|
|
114
114
|
},
|
|
115
115
|
summary: ({ executed, failed, pending }) =>
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
116
|
+
// Non-breaking inside each count, so only the separators may wrap.
|
|
117
|
+
`✓\u00a0${executed}\u00a0${executed === 1 ? 'executado' : 'executados'} · ` +
|
|
118
|
+
`✗\u00a0${failed}\u00a0${failed === 1 ? 'falhou' : 'falharam'} · ` +
|
|
119
|
+
`⧗\u00a0${pending}\u00a0${pending === 1 ? 'pendente' : 'pendentes'}`,
|
|
119
120
|
incomplete:
|
|
120
|
-
'O arquivo lista o que falta na seção Pendências. Não o envie como completo. Notificações que chegarem depois aparecem ao baixar
|
|
121
|
+
'O arquivo lista o que falta na seção Pendências. Não o envie como completo. Notificações que chegarem depois aparecem ao baixar o último registrado.',
|
|
121
122
|
generateFailed: (phase) =>
|
|
122
123
|
`${
|
|
123
124
|
{
|
|
@@ -126,6 +127,6 @@ export const PT_BR_PLATFORM_HOMOLOGACAO_COPY: PlatformHomologacaoCopy = {
|
|
|
126
127
|
running: 'Falhou ao criar os pedidos no sandbox.',
|
|
127
128
|
downloading: 'Falhou ao montar o arquivo.',
|
|
128
129
|
}[phase]
|
|
129
|
-
} Tente de novo; "Baixar
|
|
130
|
+
} Tente de novo; "Baixar o último registrado" ainda traz o que já foi registrado.`,
|
|
130
131
|
},
|
|
131
132
|
};
|
|
@@ -68,6 +68,16 @@ function buildRuntime(config: PaymentFlowsConfig): FlowsRuntime {
|
|
|
68
68
|
};
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/** The host's confirmation slots, as `CheckoutFlow` takes them. */
|
|
72
|
+
function confirmationProps(confirmation: PaymentFlowsConfig["confirmation"]) {
|
|
73
|
+
return {
|
|
74
|
+
confirmationExtra: confirmation?.extra,
|
|
75
|
+
confirmationHero: confirmation?.hero,
|
|
76
|
+
confirmationFooter: confirmation?.footer,
|
|
77
|
+
backActionEmphasis: confirmation?.backActionEmphasis,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
71
81
|
/** The host's one-list Pagamento (FUT-3591), when it opted in — a hook, read every render. */
|
|
72
82
|
function useHostPaymentList(runtime: FlowsRuntime) {
|
|
73
83
|
return runtime.config.usePaymentList?.();
|
|
@@ -111,8 +121,7 @@ function buildCheckout(
|
|
|
111
121
|
settlement={settlement ?? hostSettlement}
|
|
112
122
|
providerConfig={config}
|
|
113
123
|
tenantSlug={tenantSlug}
|
|
114
|
-
|
|
115
|
-
confirmationHero={runtime.config.confirmation?.hero}
|
|
124
|
+
{...confirmationProps(runtime.config.confirmation)}
|
|
116
125
|
validateApplePayMerchant={ports.validateApplePayMerchant}
|
|
117
126
|
paymentList={paymentList}
|
|
118
127
|
/>
|