@12-apps/payments-frontend 3.38.0 → 3.40.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 +1 -1
- package/src/card/types.ts +6 -0
- package/src/components/checkout/card-sources.ts +11 -2
- package/src/components/checkout/list/bank-apps.ts +113 -0
- package/src/components/checkout/list/bank-en-US.ts +26 -0
- package/src/components/checkout/list/bank-logos.tsx +35 -0
- package/src/components/checkout/list/bank-pt-BR.ts +26 -0
- package/src/components/checkout/list/charge-runners.tsx +22 -3
- package/src/components/checkout/list/choice-list.tsx +4 -1
- package/src/components/checkout/list/choice.ts +30 -5
- package/src/components/checkout/list/context.tsx +27 -3
- package/src/components/checkout/list/en-US.ts +2 -0
- package/src/components/checkout/list/list-face.tsx +92 -29
- package/src/components/checkout/list/list-memory.ts +96 -0
- package/src/components/checkout/list/list-pix.tsx +153 -17
- package/src/components/checkout/list/online-content.tsx +11 -4
- package/src/components/checkout/list/option-row.tsx +4 -2
- package/src/components/checkout/list/payment-list-step.tsx +2 -1
- package/src/components/checkout/list/pix-banks.tsx +178 -0
- package/src/components/checkout/list/pt-BR.ts +2 -0
- package/src/components/checkout/list/types.ts +126 -4
- package/src/components/checkout/list/use-capability.ts +15 -10
- package/src/components/checkout/list/use-list-charge.ts +37 -12
- package/src/components/checkout/list/use-list-state.ts +11 -8
- package/src/components/checkout/pix-after-copy.tsx +8 -2
- package/src/components/checkout/pix-parts.tsx +56 -0
- package/src/components/checkout/pix-view.tsx +2 -0
- package/src/components/checkout/transport.ts +3 -1
- package/src/components/checkout/types.ts +9 -9
- package/src/index.ts +1 -1
- package/src/locales.ts +12 -1
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { Box } from "@mui/material";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState, type JSX } from "react";
|
|
3
|
+
|
|
4
|
+
import { Emphasized } from "../pix-parts";
|
|
5
|
+
import { useCheckoutComponents } from "../ui";
|
|
6
|
+
|
|
7
|
+
import { BankLogo } from "./bank-logos";
|
|
8
|
+
import { openBankApp, PIX_BANKS, type PhoneOs } from "./bank-apps";
|
|
9
|
+
import type { CheckoutPixBank, PixBankId } from "./types";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The after-copy face's way to the bank app (FUT-3612), on a phone: with no
|
|
13
|
+
* bank chosen, the six as a 2×3 grid of large buttons under "Abra seu banco e
|
|
14
|
+
* cole em Pix copia e cola"; with one, "Abrindo o **Nubank**…" and "Outro
|
|
15
|
+
* banco" in one row — or, when the app did not open, the sentence that says so
|
|
16
|
+
* with "Tentar de novo".
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type BankStatus = "opening" | "failed";
|
|
20
|
+
|
|
21
|
+
export interface PixBankState {
|
|
22
|
+
/** The bank this face opens, or `null` while the grid is shown. */
|
|
23
|
+
chosen: PixBankId | null;
|
|
24
|
+
status: BankStatus | null;
|
|
25
|
+
/** Open `bank` (a pick from the grid remembers it). */
|
|
26
|
+
open: (bank: PixBankId, remember: boolean) => void;
|
|
27
|
+
/** "Outro banco": the grid again. The stored bank is replaced only by the next pick. */
|
|
28
|
+
showGrid: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The bank face's state. It starts on the remembered bank — unless the press
|
|
33
|
+
* asked for the grid ("Abrir outro banco") — and never opens anything while
|
|
34
|
+
* the code is veiled (FUT-3535: an app opened then would paste the old amount).
|
|
35
|
+
*/
|
|
36
|
+
export function usePixBankState(
|
|
37
|
+
remembered: PixBankId | null,
|
|
38
|
+
onPick: (bank: PixBankId) => void,
|
|
39
|
+
os: PhoneOs | null,
|
|
40
|
+
stale: boolean,
|
|
41
|
+
startOnGrid: boolean,
|
|
42
|
+
): PixBankState {
|
|
43
|
+
const [chosen, setChosen] = useState<PixBankId | null>(startOnGrid ? null : remembered);
|
|
44
|
+
const [status, setStatus] = useState<BankStatus | null>(null);
|
|
45
|
+
const live = useRef(true);
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
// Set in the body too: a remount (StrictMode's, or a parent's) runs the
|
|
48
|
+
// cleanup first, and a flag left false would swallow every "não abriu".
|
|
49
|
+
live.current = true;
|
|
50
|
+
return () => {
|
|
51
|
+
live.current = false;
|
|
52
|
+
};
|
|
53
|
+
}, []);
|
|
54
|
+
const open = useCallback(
|
|
55
|
+
(next: PixBankId, remember: boolean) => {
|
|
56
|
+
if (stale || !os) return;
|
|
57
|
+
setChosen(next);
|
|
58
|
+
setStatus("opening");
|
|
59
|
+
if (remember) onPick(next);
|
|
60
|
+
void openBankApp(next, os).then((opened) => {
|
|
61
|
+
if (live.current && !opened) setStatus("failed");
|
|
62
|
+
});
|
|
63
|
+
},
|
|
64
|
+
[os, onPick, stale],
|
|
65
|
+
);
|
|
66
|
+
const showGrid = useCallback(() => {
|
|
67
|
+
setChosen(null);
|
|
68
|
+
setStatus(null);
|
|
69
|
+
}, []);
|
|
70
|
+
return { chosen, status, open, showGrid };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function OtherLink({ label, onClick, testId }: { label: string; onClick: () => void; testId: string }): JSX.Element {
|
|
74
|
+
const { Button } = useCheckoutComponents();
|
|
75
|
+
return (
|
|
76
|
+
<Box sx={{ ml: "auto", flex: "none", "& button": { textDecoration: "underline", minHeight: (t) => t.spacing(5.5), px: 0.5 } }}>
|
|
77
|
+
<Button variant="text" color="primary" size="sm" onClick={onClick} dataTestId={testId}>
|
|
78
|
+
{label}
|
|
79
|
+
</Button>
|
|
80
|
+
</Box>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** "Abrindo o **Nubank**…" · "Outro banco" — or "Não conseguimos abrir…" · "Tentar de novo" · "Outro banco". */
|
|
85
|
+
function Opening({ bank, state, chosen }: { bank: CheckoutPixBank; state: PixBankState; chosen: PixBankId }): JSX.Element {
|
|
86
|
+
const { Button, Text } = useCheckoutComponents();
|
|
87
|
+
const name = bank.copy.names[chosen];
|
|
88
|
+
const failed = state.status === "failed";
|
|
89
|
+
return (
|
|
90
|
+
<Box data-testid="pix-bank-opening" data-status={state.status ?? "opening"} sx={{ display: "flex", alignItems: "center", flexWrap: "wrap", gap: 1 }}>
|
|
91
|
+
<Box component="span" role={failed ? "alert" : "status"} sx={{ minWidth: 0 }}>
|
|
92
|
+
<Text variant="body" size="sm" as="span">
|
|
93
|
+
{failed ? bank.copy.notOpened(name) : <Emphasized text={bank.copy.opening(name)} />}
|
|
94
|
+
</Text>
|
|
95
|
+
</Box>
|
|
96
|
+
{failed ? (
|
|
97
|
+
<Box sx={{ "& button": { minHeight: (t) => t.spacing(5.5) } }}>
|
|
98
|
+
<Button variant="outline" color="primary" size="sm" onClick={() => state.open(chosen, false)} dataTestId="pix-bank-retry">
|
|
99
|
+
{bank.copy.retry}
|
|
100
|
+
</Button>
|
|
101
|
+
</Box>
|
|
102
|
+
) : null}
|
|
103
|
+
<OtherLink label={bank.copy.other} onClick={state.showGrid} testId="pix-bank-other" />
|
|
104
|
+
</Box>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** One of the six: the bank's logo at 32px and its name, ≈56px tall. */
|
|
109
|
+
/** The grid's own width, never the window's: under it, a cell is too narrow for logo and name side by side. */
|
|
110
|
+
const NARROW_GRID = "@container pixbanks (max-width: 289.95px)";
|
|
111
|
+
|
|
112
|
+
function BankButton({ bank, id, onPick, disabled }: { bank: CheckoutPixBank; id: PixBankId; onPick: () => void; disabled: boolean }): JSX.Element {
|
|
113
|
+
const { Button } = useCheckoutComponents();
|
|
114
|
+
const name = bank.copy.names[id];
|
|
115
|
+
const label = bank.copy.open(name);
|
|
116
|
+
const lead = label.slice(0, label.lastIndexOf(name.name));
|
|
117
|
+
return (
|
|
118
|
+
<Box
|
|
119
|
+
sx={{
|
|
120
|
+
minWidth: 0,
|
|
121
|
+
"& button": {
|
|
122
|
+
width: "100%",
|
|
123
|
+
minHeight: (t) => t.spacing(7),
|
|
124
|
+
borderRadius: 1.5,
|
|
125
|
+
justifyContent: "flex-start",
|
|
126
|
+
textAlign: "left",
|
|
127
|
+
gap: 1.25,
|
|
128
|
+
px: 1.5,
|
|
129
|
+
color: "text.primary",
|
|
130
|
+
borderColor: "divider",
|
|
131
|
+
// A 320px phone gives the grid ~254px (each half ~123px): logo
|
|
132
|
+
// beside "Santander" no longer fits, so the logo goes above the name.
|
|
133
|
+
[NARROW_GRID]: {
|
|
134
|
+
flexDirection: "column",
|
|
135
|
+
justifyContent: "center",
|
|
136
|
+
textAlign: "center",
|
|
137
|
+
gap: 0.5,
|
|
138
|
+
px: 1,
|
|
139
|
+
py: 1,
|
|
140
|
+
"& .MuiButton-startIcon, & .MuiButton-start": { m: 0 },
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
"& button, & button *": { fontWeight: 600 },
|
|
144
|
+
}}
|
|
145
|
+
>
|
|
146
|
+
<Button variant="outline" color="neutral" size="md" fullWidth disabled={disabled} icon={<BankLogo bank={id} />} onClick={onPick} dataTestId={`pix-bank-${id}`}>
|
|
147
|
+
{/* The accessible name is "Abrir Nubank"; the eye reads the logo and "Nubank". */}
|
|
148
|
+
<Box component="span" sx={{ position: "absolute", width: "1px", height: "1px", overflow: "hidden", clip: "rect(0 0 0 0)", whiteSpace: "nowrap" }}>
|
|
149
|
+
{lead}
|
|
150
|
+
</Box>
|
|
151
|
+
{name.name}
|
|
152
|
+
</Button>
|
|
153
|
+
</Box>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function Grid({ bank, state, stale }: { bank: CheckoutPixBank; state: PixBankState; stale: boolean }): JSX.Element {
|
|
158
|
+
const { Text } = useCheckoutComponents();
|
|
159
|
+
return (
|
|
160
|
+
<Box data-testid="pix-bank-grid" sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
|
161
|
+
<Text variant="body" size="md" weight="semibold" as="p" style={{ margin: 0 }}>
|
|
162
|
+
{bank.copy.listTitle}
|
|
163
|
+
</Text>
|
|
164
|
+
<Box sx={{ containerType: "inline-size", containerName: "pixbanks" }}>
|
|
165
|
+
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: 1 }}>
|
|
166
|
+
{PIX_BANKS.map((id) => (
|
|
167
|
+
<BankButton key={id} bank={bank} id={id} disabled={stale} onPick={() => state.open(id, true)} />
|
|
168
|
+
))}
|
|
169
|
+
</Box>
|
|
170
|
+
</Box>
|
|
171
|
+
</Box>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The grid, or the bank being opened. */
|
|
176
|
+
export function PixBanks({ bank, state, stale }: { bank: CheckoutPixBank; state: PixBankState; stale: boolean }): JSX.Element {
|
|
177
|
+
return state.chosen ? <Opening bank={bank} state={state} chosen={state.chosen} /> : <Grid bank={bank} state={state} stale={stale} />;
|
|
178
|
+
}
|
|
@@ -51,6 +51,7 @@ export const PT_BR_PAYMENT_LIST_COPY: PaymentListCopy = {
|
|
|
51
51
|
details: "Preencha seus dados para pagar pelo app.",
|
|
52
52
|
chooseCard: "Escolha o cartão.",
|
|
53
53
|
cardDetails: "Preencha os dados do cartão.",
|
|
54
|
+
repricing: "Atualizando o valor…",
|
|
54
55
|
},
|
|
55
56
|
actions: {
|
|
56
57
|
choose: "Escolha como pagar",
|
|
@@ -62,6 +63,7 @@ export const PT_BR_PAYMENT_LIST_COPY: PaymentListCopy = {
|
|
|
62
63
|
payer: (name) => `Pagando como ${name} · CPF ✓`,
|
|
63
64
|
payerChange: "Alterar",
|
|
64
65
|
charging: (total, card) => `Pagando ${total} no ${card ?? "cartão informado"}…`,
|
|
66
|
+
chargingCovered: "Confirmando o pedido, pago com o seu saldo…",
|
|
65
67
|
cancel: "Cancelar",
|
|
66
68
|
pixFace: {
|
|
67
69
|
title: (total) => `Pague ${total} com Pix`,
|
|
@@ -102,12 +102,126 @@ export interface CheckoutPaymentList {
|
|
|
102
102
|
/** Told whenever the chosen option changes, including to nothing. */
|
|
103
103
|
onChoiceChange?: (choice: CheckoutListChoice | null) => void;
|
|
104
104
|
/**
|
|
105
|
-
* The amount
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
105
|
+
* The amount an ONLINE payment will ask the provider for, in integer cents —
|
|
106
|
+
* the cart's total, or what is left of it after a balance the host holds
|
|
107
|
+
* (store credit). It is what Google Pay's sheet is priced with, since that
|
|
108
|
+
* sheet must open INSIDE the tap, before any order exists to read a price
|
|
109
|
+
* from. Never given, or zero (a balance covers everything) ⇒ no Google Pay
|
|
110
|
+
* row, and a Google Pay choice is dropped. Once given, a moment without it
|
|
111
|
+
* (the host re-pricing) keeps a chosen Google Pay with its button held —
|
|
112
|
+
* plainly, with `copy.reasons.repricing` — until it returns. The key is
|
|
113
|
+
* refused if the order raised behind the sheet asks the provider for another
|
|
114
|
+
* amount (`CheckoutOrder.amountDueCents`, else its total) — so a host that
|
|
115
|
+
* takes a balance off the charge MUST answer `amountDueCents`.
|
|
109
116
|
*/
|
|
110
117
|
totalCents?: number;
|
|
118
|
+
/**
|
|
119
|
+
* The same amount, pre-formatted by the host ("R$ 22,00"), when it is not
|
|
120
|
+
* the cart's total — a balance the host holds pays part of it. The card's
|
|
121
|
+
* button, the 5-second bar and Google Pay's press name THIS while an online
|
|
122
|
+
* option is chosen; a door option and an empty choice keep the cart's total,
|
|
123
|
+
* since no balance pays at the door. Absent ⇒ the cart's total. It is also
|
|
124
|
+
* the amount the Pix face asks for.
|
|
125
|
+
*
|
|
126
|
+
* **A balance that covers everything** (`totalCents: 0`): the card option
|
|
127
|
+
* asks for no card, the bar says `copy.chargingCovered`, and the order the
|
|
128
|
+
* button raises must come back SETTLED by the host — paid from the balance,
|
|
129
|
+
* with no provider call — so the list goes to its paid screen. An order that
|
|
130
|
+
* comes back `AWAITING_PAYMENT` with `amountDueCents: 0` is never charged:
|
|
131
|
+
* the list returns to its rows.
|
|
132
|
+
*/
|
|
133
|
+
amountDueLabel?: string;
|
|
134
|
+
/**
|
|
135
|
+
* The buyer's last ONLINE way to pay, as the host remembers it (FUT-3611) —
|
|
136
|
+
* chosen for them before they touch anything, when the store offers it: Pix
|
|
137
|
+
* as Pix; a card as the saved card used last. Absent or `null` ⇒ the saved
|
|
138
|
+
* card rule alone. A door tender is never chosen for the buyer.
|
|
139
|
+
*/
|
|
140
|
+
lastMethod?: "PIX" | "CARD" | null;
|
|
141
|
+
/**
|
|
142
|
+
* "Pedir em 1 toque" opened this checkout (FUT-3611): the tap on the
|
|
143
|
+
* cardápio was the buyer's consent, so the card's 5-second bar starts by
|
|
144
|
+
* itself once the saved card is chosen and nothing holds the button — ONCE
|
|
145
|
+
* per step, and only within {@link ARMED_WINDOW_MS} of the step opening: a
|
|
146
|
+
* checkout that lands held (a field to fill) never charges by itself minutes
|
|
147
|
+
* later. Without a saved card it does nothing.
|
|
148
|
+
*/
|
|
149
|
+
armedCharge?: boolean;
|
|
150
|
+
/**
|
|
151
|
+
* The saved card the host's consent named (the 1-tap offer's). Given, the
|
|
152
|
+
* armed charge fires only on THAT card; a different selection never charges
|
|
153
|
+
* by itself.
|
|
154
|
+
*/
|
|
155
|
+
armedCardId?: string;
|
|
156
|
+
/**
|
|
157
|
+
* Host content under the 5-second bar's Cancelar while it counts — the
|
|
158
|
+
* storefront's "Cancelar e desativar a compra em 1 toque". It is handed the
|
|
159
|
+
* bar's own cancel, so a link there stops the charge before doing its own.
|
|
160
|
+
*/
|
|
161
|
+
chargeFooter?: (cancel: () => void) => ReactNode;
|
|
162
|
+
/**
|
|
163
|
+
* Pix straight to the buyer's bank app (FUT-3612) — on a phone only: the
|
|
164
|
+
* button generates AND copies the code, a short message says so through
|
|
165
|
+
* `notify`, and the after-copy face offers the six banks (or opens the one
|
|
166
|
+
* remembered). Absent ⇒ today's Pix: "Gerar Pix", then a tap to copy.
|
|
167
|
+
*/
|
|
168
|
+
pixBank?: CheckoutPixBank;
|
|
169
|
+
/** Says a short sentence in the host's own toast — "Código Pix copiado". */
|
|
170
|
+
notify?: (message: string) => void;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The six banks the Pix face offers to open, in the prototype's order. */
|
|
174
|
+
export type PixBankId = "nubank" | "itau" | "bradesco" | "bb" | "caixa" | "santander";
|
|
175
|
+
|
|
176
|
+
/** `CheckoutPaymentList.pixBank`. */
|
|
177
|
+
export interface CheckoutPixBank {
|
|
178
|
+
/** The bank the buyer picked last time, as the host stored it — or `null`. */
|
|
179
|
+
remembered: PixBankId | null;
|
|
180
|
+
/** A bank was picked: the host remembers it for next time (a new pick replaces it). */
|
|
181
|
+
onPick: (bank: PixBankId) => void;
|
|
182
|
+
/** The words; ship with the list's pack (`PIX_BANK_COPY`). */
|
|
183
|
+
copy: PixBankCopy;
|
|
184
|
+
/**
|
|
185
|
+
* Whether this device can open a bank app — a phone. Absent ⇒ read from the
|
|
186
|
+
* browser (Android or iOS). A computer keeps today's face and gets only the
|
|
187
|
+
* generate-and-copy.
|
|
188
|
+
*/
|
|
189
|
+
phone?: boolean;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** A bank's name and the article pt-BR puts before it ("o Nubank", "a Caixa"). */
|
|
193
|
+
export interface PixBankName {
|
|
194
|
+
name: string;
|
|
195
|
+
/** "Abrir o Nubank" / "Abrir a Caixa" — the article with its trailing space, or "" for none. */
|
|
196
|
+
the: string;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export interface PixBankCopy {
|
|
200
|
+
names: Record<PixBankId, PixBankName>;
|
|
201
|
+
/** The bar's button with no bank remembered — "Gerar e copiar o Pix". */
|
|
202
|
+
generateAndCopy: string;
|
|
203
|
+
/** With one remembered — "Gerar, copiar e abrir o Nubank". */
|
|
204
|
+
generateCopyAndOpen: (bank: PixBankName) => string;
|
|
205
|
+
/** The before-copy face's button with one remembered — "Copiar e abrir o Nubank". */
|
|
206
|
+
copyAndOpen: (bank: PixBankName) => string;
|
|
207
|
+
/** The link under the button — "Abrir outro banco". */
|
|
208
|
+
openOther: string;
|
|
209
|
+
/** Over the grid — "Abra seu banco e cole em Pix copia e cola". */
|
|
210
|
+
listTitle: string;
|
|
211
|
+
/** A bank button's accessible name — "Abrir Nubank". */
|
|
212
|
+
open: (bank: PixBankName) => string;
|
|
213
|
+
/** "Abrindo o **Nubank**…" — `**` marks the bold span. */
|
|
214
|
+
opening: (bank: PixBankName) => string;
|
|
215
|
+
/** "Outro banco". */
|
|
216
|
+
other: string;
|
|
217
|
+
/** "Não conseguimos abrir o Nubank. O app está instalado neste celular?" */
|
|
218
|
+
notOpened: (bank: PixBankName) => string;
|
|
219
|
+
/** "Tentar de novo". */
|
|
220
|
+
retry: string;
|
|
221
|
+
/** The toasts. */
|
|
222
|
+
copied: string;
|
|
223
|
+
copiedPick: string;
|
|
224
|
+
copiedOpening: (bank: PixBankName) => string;
|
|
111
225
|
}
|
|
112
226
|
|
|
113
227
|
/** The list's words — a pack per locale ships at `@12-apps/payments-frontend/locales`. */
|
|
@@ -155,6 +269,8 @@ export interface PaymentListCopy {
|
|
|
155
269
|
details: string;
|
|
156
270
|
chooseCard: string;
|
|
157
271
|
cardDetails: string;
|
|
272
|
+
/** Google Pay is chosen while the host re-prices (`totalCents` briefly absent). Absent ⇒ no sentence. */
|
|
273
|
+
repricing?: string;
|
|
158
274
|
};
|
|
159
275
|
/** The button's verb, beside the total. */
|
|
160
276
|
actions: {
|
|
@@ -169,6 +285,12 @@ export interface PaymentListCopy {
|
|
|
169
285
|
payerChange: string;
|
|
170
286
|
/** The 5-second bar: "Pagando R$ 27,00 no Visa final 4242…"; `card` is `null` for a card typed now. */
|
|
171
287
|
charging: (total: string, card: string | null) => string;
|
|
288
|
+
/**
|
|
289
|
+
* The 5-second bar when the host's balance pays the WHOLE amount
|
|
290
|
+
* (`totalCents` 0), so no card is charged and none is named — "Confirmando
|
|
291
|
+
* o pedido, pago com o seu saldo…". Absent ⇒ `charging` as usual.
|
|
292
|
+
*/
|
|
293
|
+
chargingCovered?: string;
|
|
172
294
|
cancel: string;
|
|
173
295
|
pixFace: {
|
|
174
296
|
/** "Pague R$ 22,00 com Pix". */
|
|
@@ -21,24 +21,29 @@ export interface ListCapability extends OnlineCapability {
|
|
|
21
21
|
* always had (FUT-471), so the list never offers a wallet that cannot work.
|
|
22
22
|
*
|
|
23
23
|
* The sheet must open inside the tap, before any order exists, so it is priced
|
|
24
|
-
* with the host's `totalCents
|
|
24
|
+
* with the host's `totalCents` — the amount DUE. A host that never priced it,
|
|
25
|
+
* or prices it at zero (a balance it holds covers everything), offers no row.
|
|
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.
|
|
25
29
|
*
|
|
26
30
|
* Apple Pay is not a row here: its sheet must open synchronously inside the
|
|
27
31
|
* tap, before any order could be raised for it. The tabs layout keeps it.
|
|
28
32
|
*/
|
|
29
33
|
export function useListCapability(
|
|
30
34
|
config: CheckoutProviderConfig | null,
|
|
31
|
-
/** The host's price for the sheet — no price, no Google Pay row. */
|
|
35
|
+
/** The host's price for the sheet — no price (or zero), no Google Pay row. */
|
|
32
36
|
totalCents: number | undefined,
|
|
33
37
|
): ListCapability {
|
|
34
38
|
const declared = googlePayConfig(config) !== null;
|
|
35
39
|
// `null` tells the client hook not to load pay.js at all.
|
|
36
40
|
const client = useGooglePayClient(declared ? undefined : null, "TEST");
|
|
37
|
-
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (totalCents
|
|
41
|
+
const priced = totalCents !== undefined && totalCents > 0;
|
|
42
|
+
// Priced once, and not since withdrawn by an explicit zero.
|
|
43
|
+
const [wasPriced, setWasPriced] = useState(false);
|
|
44
|
+
if (priced && !wasPriced) setWasPriced(true);
|
|
45
|
+
if (totalCents === 0 && wasPriced) setWasPriced(false);
|
|
46
|
+
const repricing = totalCents === undefined && wasPriced;
|
|
42
47
|
return useMemo(() => {
|
|
43
48
|
const params = googlePayConfig(config);
|
|
44
49
|
const usable = params && client ? { client, params } : null;
|
|
@@ -46,8 +51,8 @@ export function useListCapability(
|
|
|
46
51
|
handOff: methodChosenAtProvider(config?.chain?.[0]?.checkoutScreen, config),
|
|
47
52
|
offered: offeredMethods(config),
|
|
48
53
|
cardUnavailable: !cardPathAvailable(config),
|
|
49
|
-
googlePayReady: usable !== null && (
|
|
50
|
-
googlePay:
|
|
54
|
+
googlePayReady: usable !== null && (priced || repricing),
|
|
55
|
+
googlePay: priced ? usable : null,
|
|
51
56
|
};
|
|
52
|
-
}, [config, client,
|
|
57
|
+
}, [config, client, priced, repricing]);
|
|
53
58
|
}
|
|
@@ -32,6 +32,8 @@ interface RaisePorts {
|
|
|
32
32
|
startPayment: (method: PaymentMethod) => Promise<void>;
|
|
33
33
|
/** The amount the buyer is shown now (the cart's, or the balance's). */
|
|
34
34
|
cartTotal: string;
|
|
35
|
+
/** What an online payment asks the provider for now, when the host prices it (`paymentList.totalCents`). */
|
|
36
|
+
dueCents?: number;
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
export interface ListCharge {
|
|
@@ -57,9 +59,23 @@ export interface ListCharge {
|
|
|
57
59
|
chargeable: CheckoutOrder | null;
|
|
58
60
|
}
|
|
59
61
|
|
|
60
|
-
/**
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
/**
|
|
63
|
+
* A held CARD order still awaiting payment AND priced as the buyer now sees —
|
|
64
|
+
* reusable for the charge. A balance ticked or unticked moves what the charge
|
|
65
|
+
* asks for and not the order's total, so the amount due is compared too.
|
|
66
|
+
*/
|
|
67
|
+
function reusable(order: CheckoutOrder | null, cartTotal: string, dueCents: number | undefined): boolean {
|
|
68
|
+
if (order?.method !== "CARD" || order.status !== "AWAITING_PAYMENT" || order.totalLabel !== cartTotal) return false;
|
|
69
|
+
return dueCents === undefined || (order.amountDueCents ?? order.totalCents) === dueCents;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* An awaiting CARD order that asks the provider for nothing: a balance the host
|
|
74
|
+
* holds covers it, and the host should have settled it (see `amountDueLabel`).
|
|
75
|
+
* It is never charged.
|
|
76
|
+
*/
|
|
77
|
+
function owesNothing(order: CheckoutOrder | null): boolean {
|
|
78
|
+
return order?.method === "CARD" && order.status === "AWAITING_PAYMENT" && order.amountDueCents === 0;
|
|
63
79
|
}
|
|
64
80
|
|
|
65
81
|
/** The order `send` meant: the one it reused, or the CARD order raised to replace a stale one. */
|
|
@@ -67,11 +83,24 @@ function chargeTarget(
|
|
|
67
83
|
order: CheckoutOrder | null,
|
|
68
84
|
target: { reuse: CheckoutOrder } | { replace: CheckoutOrder | null } | null,
|
|
69
85
|
): CheckoutOrder | null {
|
|
70
|
-
if (!order || !target || order.method !== "CARD" || order.status !== "AWAITING_PAYMENT") return null;
|
|
86
|
+
if (!order || !target || order.method !== "CARD" || order.status !== "AWAITING_PAYMENT" || owesNothing(order)) return null;
|
|
71
87
|
if ("reuse" in target) return target.reuse === order ? order : null;
|
|
72
88
|
return order !== target.replace ? order : null;
|
|
73
89
|
}
|
|
74
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Back to the list when the press left nothing to charge: a raise that failed
|
|
93
|
+
* (the step's error panel says why), or a held order that owes the provider
|
|
94
|
+
* nothing — never charged, rather than waited on forever.
|
|
95
|
+
*/
|
|
96
|
+
function useSettleWhenNothingToCharge(phase: ListChargePhase, kind: ListChargeKind | null, ports: RaisePorts, settle: () => void): void {
|
|
97
|
+
const { order, creating, createError } = ports;
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (phase !== "sending" || creating) return;
|
|
100
|
+
if ((!order && createError) || (kind === "CARD" && owesNothing(order))) settle();
|
|
101
|
+
}, [phase, kind, creating, order, createError, settle]);
|
|
102
|
+
}
|
|
103
|
+
|
|
75
104
|
/**
|
|
76
105
|
* The bar's charge, after the button (FUT-3592): count down for a card, then
|
|
77
106
|
* raise the order — or reuse the CARD order already held, after a refusal the
|
|
@@ -108,8 +137,8 @@ export function useListCharge(ports: RaisePorts): ListCharge {
|
|
|
108
137
|
// must not let the bar's own timer charge past it.
|
|
109
138
|
if (!allowed.current) return cancel();
|
|
110
139
|
setPhase("sending");
|
|
111
|
-
const { order, cartTotal } = latest.current;
|
|
112
|
-
if (reusable(order, cartTotal) && order) {
|
|
140
|
+
const { order, cartTotal, dueCents } = latest.current;
|
|
141
|
+
if (reusable(order, cartTotal, dueCents) && order) {
|
|
113
142
|
target.current = { reuse: order };
|
|
114
143
|
return;
|
|
115
144
|
}
|
|
@@ -148,12 +177,8 @@ export function useListCharge(ports: RaisePorts): ListCharge {
|
|
|
148
177
|
if (timer.current) clearTimeout(timer.current);
|
|
149
178
|
}, []);
|
|
150
179
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const { order, creating, createError } = ports;
|
|
154
|
-
useEffect(() => {
|
|
155
|
-
if (phase === "sending" && !creating && !order && createError) settle();
|
|
156
|
-
}, [phase, creating, order, createError, settle]);
|
|
180
|
+
const { order, creating } = ports;
|
|
181
|
+
useSettleWhenNothingToCharge(phase, kind, ports, settle);
|
|
157
182
|
|
|
158
183
|
const sending = phase === "sending" || phase === "waiting";
|
|
159
184
|
const chargeable = sending && !creating ? chargeTarget(order, target.current) : null;
|
|
@@ -5,7 +5,7 @@ import { useSavedCardList } from "../card-sources";
|
|
|
5
5
|
import { useCheckoutCopy } from "../copy-context";
|
|
6
6
|
import type { BuyerInfo, CheckoutCustomerField, CheckoutProviderConfig } from "../types";
|
|
7
7
|
|
|
8
|
-
import { askedFields, cardComplete, onlineRows, preselection, raisedMethod, type OnlineCapability, type OnlineRow } from "./choice";
|
|
8
|
+
import { askedFields, cardComplete, lastUsedCard, onlineRows, preselection, raisedMethod, type OnlineCapability, type OnlineRow } from "./choice";
|
|
9
9
|
import type { CheckoutListChoice } from "./types";
|
|
10
10
|
|
|
11
11
|
const EMPTY_CARD: CardDetails = { number: "", holder: "", expiry: "", cvv: "" };
|
|
@@ -59,6 +59,7 @@ function usePreselect(
|
|
|
59
59
|
rows: readonly OnlineRow[],
|
|
60
60
|
saved: readonly SavedCard[],
|
|
61
61
|
fresh: boolean,
|
|
62
|
+
lastMethod: "PIX" | "CARD" | null,
|
|
62
63
|
apply: (choice: CheckoutListChoice, selection: string | null) => void,
|
|
63
64
|
touched: { current: boolean },
|
|
64
65
|
): void {
|
|
@@ -66,18 +67,18 @@ function usePreselect(
|
|
|
66
67
|
useEffect(() => {
|
|
67
68
|
if (done.current || touched.current || !loaded) return;
|
|
68
69
|
done.current = true;
|
|
69
|
-
const pick = preselection(rows, saved, fresh);
|
|
70
|
+
const pick = preselection(rows, saved, fresh, lastMethod);
|
|
70
71
|
if (pick) apply(pick.choice, pick.selection);
|
|
71
|
-
}, [loaded, rows, saved, fresh, apply, touched]);
|
|
72
|
+
}, [loaded, rows, saved, fresh, lastMethod, apply, touched]);
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
/**
|
|
75
|
-
* The card chosen again: the head of the saved list —
|
|
76
|
-
* a refused card (FUT-1145), no saved card at all
|
|
77
|
-
* failed), or the fields when nothing is saved.
|
|
76
|
+
* The card chosen again: the one used last (else the head of the saved list) —
|
|
77
|
+
* or, for a buyer back from a refused card (FUT-1145), no saved card at all
|
|
78
|
+
* (that one may be the card that failed), or the fields when nothing is saved.
|
|
78
79
|
*/
|
|
79
80
|
function reopenedSelection(cards: readonly SavedCard[], fresh: boolean): string | null {
|
|
80
|
-
const head = cards
|
|
81
|
+
const head = lastUsedCard(cards);
|
|
81
82
|
if (!head) return NEW_CARD;
|
|
82
83
|
return fresh ? null : head.id;
|
|
83
84
|
}
|
|
@@ -96,6 +97,8 @@ export function useListState(input: {
|
|
|
96
97
|
fresh: boolean;
|
|
97
98
|
arrivedWith: BuyerInfo | undefined;
|
|
98
99
|
taxIdOnFile: boolean;
|
|
100
|
+
/** The buyer's last online method, from the host (FUT-3611). */
|
|
101
|
+
lastMethod?: "PIX" | "CARD" | null;
|
|
99
102
|
onChoiceChange?: (choice: CheckoutListChoice | null) => void;
|
|
100
103
|
}): ListState {
|
|
101
104
|
const { capability } = input;
|
|
@@ -112,7 +115,7 @@ export function useListState(input: {
|
|
|
112
115
|
},
|
|
113
116
|
[setSelection],
|
|
114
117
|
);
|
|
115
|
-
usePreselect(loaded, rows, cards, input.fresh, apply, touched);
|
|
118
|
+
usePreselect(loaded, rows, cards, input.fresh, input.lastMethod ?? null, apply, touched);
|
|
116
119
|
const choose = useCallback(
|
|
117
120
|
(next: CheckoutListChoice) => {
|
|
118
121
|
touched.current = true;
|
|
@@ -123,6 +123,12 @@ interface PixAfterCopyProps {
|
|
|
123
123
|
onPreferCard?: () => void;
|
|
124
124
|
/** The poll's line ("Verificando…", or the stalled panel that replaces it). */
|
|
125
125
|
footer: ReactNode;
|
|
126
|
+
/**
|
|
127
|
+
* The way to the bank app, on a phone (FUT-3612): drawn where the heading
|
|
128
|
+
* was — the toast already said the code was copied and the bar says what it
|
|
129
|
+
* waits for — and, while it is the six banks, in place of the checklist.
|
|
130
|
+
*/
|
|
131
|
+
banks?: { node: ReactNode; replacesChecklist: boolean };
|
|
126
132
|
}
|
|
127
133
|
|
|
128
134
|
/** The narrow layout's two secondaries side by side; wide, the card link joins the row. */
|
|
@@ -164,8 +170,8 @@ export function PixAfterCopy(props: PixAfterCopyProps): JSX.Element {
|
|
|
164
170
|
return (
|
|
165
171
|
<Box data-testid="pix-after-copy" sx={{ display: "flex", flexWrap: "wrap", gap: 4, alignItems: "flex-start" }}>
|
|
166
172
|
<Box sx={{ flex: "999 1 420px", minWidth: 0, display: "flex", flexDirection: "column", gap: 2.5 }}>
|
|
167
|
-
<Heading words={words} totalLabel={totalLabel} />
|
|
168
|
-
<Checklist words={words} deadline={deadline} />
|
|
173
|
+
{props.banks ? props.banks.node : <Heading words={words} totalLabel={totalLabel} />}
|
|
174
|
+
{props.banks?.replacesChecklist ? null : <Checklist words={words} deadline={deadline} />}
|
|
169
175
|
<Actions {...props} />
|
|
170
176
|
{footer}
|
|
171
177
|
{onPreferCard ? (
|
|
@@ -82,6 +82,62 @@ export function useClipboardCopy(text: string): { copy: () => Promise<boolean>;
|
|
|
82
82
|
return { copy, justCopied };
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/** A copy opened inside a tap whose text arrives later (FUT-3612) — see {@link deferredCopy}. */
|
|
86
|
+
export interface DeferredCopy {
|
|
87
|
+
/** The text has arrived: write it. */
|
|
88
|
+
resolve: (text: string) => void;
|
|
89
|
+
/** There will be no text (the raise was refused): give the clipboard up. */
|
|
90
|
+
fail: () => void;
|
|
91
|
+
/** `true` once the clipboard holds the text; `false` when it refused or there was none. */
|
|
92
|
+
done: Promise<boolean>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Open the clipboard NOW, inside the buyer's tap, for a text the server has
|
|
97
|
+
* not answered yet. Safari lets a page write to the clipboard only inside the
|
|
98
|
+
* gesture, so the write is started here with a promise of the text
|
|
99
|
+
* (`ClipboardItem`); where that is missing or refused, the plain write is
|
|
100
|
+
* tried once the text arrives — Chrome takes it from a focused page.
|
|
101
|
+
*/
|
|
102
|
+
export function deferredCopy(): DeferredCopy {
|
|
103
|
+
let give: (text: string | null) => void = () => undefined;
|
|
104
|
+
const text = new Promise<string | null>((resolve) => {
|
|
105
|
+
give = resolve;
|
|
106
|
+
});
|
|
107
|
+
const plain = async (): Promise<boolean> => {
|
|
108
|
+
const value = await text;
|
|
109
|
+
if (value === null || !navigator.clipboard) return false;
|
|
110
|
+
try {
|
|
111
|
+
await navigator.clipboard.writeText(value);
|
|
112
|
+
return true;
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
const Item = typeof window === "undefined" ? undefined : (window as { ClipboardItem?: typeof ClipboardItem }).ClipboardItem;
|
|
118
|
+
let done: Promise<boolean> = Promise.resolve(false);
|
|
119
|
+
let opened = false;
|
|
120
|
+
if (Item && navigator.clipboard && typeof navigator.clipboard.write === "function") {
|
|
121
|
+
const blob = text.then((value) => {
|
|
122
|
+
if (value === null) throw new Error("no text");
|
|
123
|
+
return new Blob([value], { type: "text/plain" });
|
|
124
|
+
});
|
|
125
|
+
// An unanswered blob must not surface as an unhandled rejection.
|
|
126
|
+
blob.catch(() => undefined);
|
|
127
|
+
try {
|
|
128
|
+
done = navigator.clipboard.write([new Item({ "text/plain": blob })]).then(
|
|
129
|
+
() => true,
|
|
130
|
+
async () => (await text) !== null && plain(),
|
|
131
|
+
);
|
|
132
|
+
opened = true;
|
|
133
|
+
} catch {
|
|
134
|
+
// A browser whose ClipboardItem takes no promise throws here, in the tap.
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (!opened) done = plain();
|
|
138
|
+
return { resolve: (value) => give(value), fail: () => give(null), done };
|
|
139
|
+
}
|
|
140
|
+
|
|
85
141
|
/**
|
|
86
142
|
* A PIX's QR or code, veiled while the cart has changed under it (FUT-3535):
|
|
87
143
|
* blurred past reading, out of reach of a pointer, a selection and a screen
|
|
@@ -287,6 +287,8 @@ function PixBody({ order, pix, poll }: { order: CheckoutOrder; pix: PixCharge; p
|
|
|
287
287
|
onShowQr={() => setAfterCopy(false)}
|
|
288
288
|
onPreferCard={stage.preferCard}
|
|
289
289
|
footer={poll.error || poll.timedOut ? footerLine(copy.verifying) : null}
|
|
290
|
+
writeClipboard={writeClipboard}
|
|
291
|
+
setAfterCopy={setAfterCopy}
|
|
290
292
|
/>
|
|
291
293
|
);
|
|
292
294
|
}
|
|
@@ -308,7 +308,9 @@ export function createCheckoutClient(transport: CheckoutTransport): CheckoutClie
|
|
|
308
308
|
// offered for a Stone charge it would fail (or misroute).
|
|
309
309
|
const query = tenantSlug ? `?tenantSlug=${encodeURIComponent(tenantSlug)}` : "";
|
|
310
310
|
const result = await call<SavedCard[]>(`/cards${query}`, { method: "GET" });
|
|
311
|
-
|
|
311
|
+
// A route that answers `null` (or no list at all) has no saved card —
|
|
312
|
+
// every screen that reads this maps over it.
|
|
313
|
+
return result.ok && Array.isArray(result.data) ? result.data : [];
|
|
312
314
|
},
|
|
313
315
|
|
|
314
316
|
beginVault: () => call<BuyerVaultSession>("/cards/begin", { method: "POST" }),
|