@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.
Files changed (35) hide show
  1. package/package.json +2 -2
  2. package/src/components/checkout/apple-pay-button.tsx +84 -36
  3. package/src/components/checkout/checkout-flow.tsx +3 -2
  4. package/src/components/checkout/google-pay-button.tsx +6 -0
  5. package/src/components/checkout/list/buyer-inline.tsx +22 -14
  6. package/src/components/checkout/list/charge-runners.tsx +47 -23
  7. package/src/components/checkout/list/choice-list.tsx +16 -5
  8. package/src/components/checkout/list/choice.ts +4 -6
  9. package/src/components/checkout/list/cpf-top.tsx +98 -0
  10. package/src/components/checkout/list/en-US.ts +8 -0
  11. package/src/components/checkout/list/list-bar.tsx +29 -44
  12. package/src/components/checkout/list/list-face.tsx +110 -104
  13. package/src/components/checkout/list/list-top.tsx +99 -0
  14. package/src/components/checkout/list/online-content.tsx +4 -0
  15. package/src/components/checkout/list/option-row.tsx +5 -2
  16. package/src/components/checkout/list/payer-line.tsx +39 -0
  17. package/src/components/checkout/list/payment-list-step.tsx +50 -5
  18. package/src/components/checkout/list/pt-BR.ts +8 -0
  19. package/src/components/checkout/list/summary-column.tsx +120 -0
  20. package/src/components/checkout/list/top-cpf.ts +94 -0
  21. package/src/components/checkout/list/types.ts +55 -10
  22. package/src/components/checkout/list/use-capability.ts +113 -35
  23. package/src/components/checkout/list/use-list-charge.ts +11 -9
  24. package/src/components/checkout/list/use-list-state.ts +37 -4
  25. package/src/components/checkout/list/wallet-block.tsx +235 -0
  26. package/src/components/checkout/list/wallet-press.ts +129 -0
  27. package/src/components/checkout/payment-phase.tsx +1 -0
  28. package/src/components/checkout/payment-summary.tsx +2 -2
  29. package/src/components/checkout/screens-copy.ts +6 -0
  30. package/src/components/checkout/screens-en-US.ts +1 -0
  31. package/src/components/checkout/screens-pt-BR.ts +2 -0
  32. package/src/components/platform/en-US.ts +6 -5
  33. package/src/components/platform/pt-BR.ts +8 -7
  34. package/src/flows/create-payment-flows.tsx +11 -2
  35. 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 Google Pay's sheet. */
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 Google Pay one (straight away). Only from idle. */
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
- if (!allowed.current) return cancel();
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
- phaseRef.current = next === "GOOGLE_PAY" ? "sending" : "counting";
156
+ const wallet = next !== "CARD";
157
+ phaseRef.current = wallet ? "sending" : "counting";
156
158
  setKind(next);
157
159
  setPressedTotal(total);
158
- if (next === "GOOGLE_PAY") return send();
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
- /** The buyer's own details the chosen online option must ask for (M11). */
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
- return { rows, choice, choose, card, asked };
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
+ }
@@ -101,6 +101,7 @@ export function PaymentPhase({
101
101
  defaultBuyer={props.defaultBuyer}
102
102
  saveBuyerContact={props.saveBuyerContact}
103
103
  consentLabel={props.copy.dados.saveProfile}
104
+ validateApplePayMerchant={validateApplePayMerchant}
104
105
  />
105
106
  );
106
107
  }
@@ -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. The reason was recorded in the evidence file.',
125
+ returnedFailed: 'The test seller connection did not complete. Try connecting again.',
126
126
  generate: 'Generate evidence file\u00a0(TXT)',
127
- downloadLatest: 'Download again (no run)',
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 }) => `✓ ${executed} executed · ✗ ${failed} failed · ⧗ ${pending} 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 again.',
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 again" still returns what was recorded.`,
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. O motivo foi registrado no arquivo de evidências.',
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 de novo (sem executar)',
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
- `✓ ${executed} ${executed === 1 ? 'executado' : 'executados'} · ` +
117
- `✗ ${failed} ${failed === 1 ? 'falhou' : 'falharam'} · ` +
118
- `⧗ ${pending} ${pending === 1 ? 'pendente' : 'pendentes'}`,
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 de novo.',
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 de novo" ainda traz o que já foi registrado.`,
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
- confirmationExtra={runtime.config.confirmation?.extra}
115
- confirmationHero={runtime.config.confirmation?.hero}
124
+ {...confirmationProps(runtime.config.confirmation)}
116
125
  validateApplePayMerchant={ports.validateApplePayMerchant}
117
126
  paymentList={paymentList}
118
127
  />