@12-apps/payments-frontend 3.39.0 → 3.41.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/activation/use-redirect-activation.ts +22 -1
- package/src/components/PaymentProviderSettings.tsx +13 -0
- 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/choice.ts +8 -3
- package/src/components/checkout/list/context.tsx +27 -3
- package/src/components/checkout/list/list-face.tsx +55 -15
- package/src/components/checkout/list/list-memory.ts +96 -0
- package/src/components/checkout/list/list-pix.tsx +151 -15
- package/src/components/checkout/list/payment-list-step.tsx +1 -0
- package/src/components/checkout/list/pix-banks.tsx +178 -0
- package/src/components/checkout/list/types.ts +91 -0
- package/src/components/checkout/list/use-list-state.ts +6 -3
- 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/settings-live.ts +66 -0
- package/src/flows/create-payments-settings.tsx +6 -2
- package/src/index.ts +2 -2
- package/src/locales.ts +12 -1
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
import { NEW_CARD } from "../../../card";
|
|
4
|
+
import { deferredCopy } from "../pix-parts";
|
|
5
|
+
|
|
6
|
+
import { bankFlowOs } from "./bank-apps";
|
|
7
|
+
import { usePaymentListMode, type PaymentListMode } from "./context";
|
|
8
|
+
import type { ListState } from "./use-list-state";
|
|
9
|
+
import type { CheckoutPaymentList, PixBankId } from "./types";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What the list does with the host's memory of the buyer (FUT-3611, FUT-3612):
|
|
13
|
+
* the bank a Pix opens in, and the charge "Pedir em 1 toque" armed.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** How long after the step opens an armed charge may still start by itself. */
|
|
17
|
+
export const ARMED_WINDOW_MS = 15_000;
|
|
18
|
+
|
|
19
|
+
/** The bank flow's remembered bank, on a phone — what the Pix button opens. */
|
|
20
|
+
function rememberedBank(list: CheckoutPaymentList): PixBankId | null {
|
|
21
|
+
const bank = list.pixBank;
|
|
22
|
+
if (!bank?.remembered || !bankFlowOs(bank.phone)) return null;
|
|
23
|
+
return bank.remembered;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Pix with the host's bank flow (FUT-3612): the clipboard is opened HERE,
|
|
28
|
+
* inside the tap, for a code the server has not answered yet — the Pix face
|
|
29
|
+
* writes it the moment it arrives, then opens the remembered bank (or, for
|
|
30
|
+
* "Abrir outro banco", shows the six).
|
|
31
|
+
*/
|
|
32
|
+
export function armPixCopy(list: CheckoutPaymentList, mode: PaymentListMode, pick: boolean): void {
|
|
33
|
+
if (!list.pixBank) return;
|
|
34
|
+
mode.pixIntent.current?.copy.fail();
|
|
35
|
+
const os = bankFlowOs(list.pixBank.phone);
|
|
36
|
+
mode.pixIntent.current = { copy: deferredCopy(), bank: os && !pick ? list.pixBank.remembered : null, pick };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function pixChosen(state: ListState): boolean {
|
|
40
|
+
return state.choice?.kind === "online" && state.choice.method === "PIX";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Pix with the bank flow: "Gerar e copiar o Pix", or "Gerar, copiar e abrir o Nubank". */
|
|
44
|
+
export function pixBankLabel(list: CheckoutPaymentList, state: ListState): string | null {
|
|
45
|
+
const bank = list.pixBank;
|
|
46
|
+
if (!bank || !pixChosen(state)) return null;
|
|
47
|
+
const remembered = rememberedBank(list);
|
|
48
|
+
return remembered ? bank.copy.generateCopyAndOpen(bank.copy.names[remembered]) : bank.copy.generateAndCopy;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** "Abrir outro banco" under the button: Pix chosen, a bank remembered, a phone, and the button free to act. */
|
|
52
|
+
export function otherBankShown(list: CheckoutPaymentList, state: ListState, free: boolean): boolean {
|
|
53
|
+
return free && pixChosen(state) && rememberedBank(list) !== null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A saved card — not the fields — is what the card choice will charge. */
|
|
57
|
+
function savedCardChosen(state: ListState): boolean {
|
|
58
|
+
const { choice, card } = state;
|
|
59
|
+
if (choice?.kind !== "online" || choice.method !== "CARD") return false;
|
|
60
|
+
return card.selection !== null && card.selection !== NEW_CARD;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The armed charge may start now: the host armed it, the saved card the
|
|
65
|
+
* consent named is the one chosen, and nothing (`free`) holds the button.
|
|
66
|
+
*/
|
|
67
|
+
export function armedReady(list: CheckoutPaymentList, state: ListState, free: boolean): boolean {
|
|
68
|
+
if (!list.armedCharge || !free || !savedCardChosen(state)) return false;
|
|
69
|
+
return list.armedCardId === undefined || state.card.selection === list.armedCardId;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* "Pedir em 1 toque" opened this checkout (FUT-3611): start the card's bar by
|
|
74
|
+
* itself, ONCE per step — as soon as {@link armedReady} holds, within
|
|
75
|
+
* {@link ARMED_WINDOW_MS}. Anything after Cancelar never charges by itself.
|
|
76
|
+
*/
|
|
77
|
+
export function useArmedCharge(ready: boolean, act: () => void): void {
|
|
78
|
+
const mode = usePaymentListMode();
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
const armed = mode?.armed.current;
|
|
81
|
+
if (!ready || !armed || armed.spent) return;
|
|
82
|
+
// Spent either way: fired now, or too late to be the tap's own charge.
|
|
83
|
+
armed.spent = true;
|
|
84
|
+
if (Date.now() - armed.since <= ARMED_WINDOW_MS) act();
|
|
85
|
+
}, [ready, act, mode]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A Pix press whose code never came (the raise was refused) gives the clipboard up. */
|
|
89
|
+
export function useDropRefusedCopy(createError: unknown): void {
|
|
90
|
+
const mode = usePaymentListMode();
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (!createError || !mode?.pixIntent.current) return;
|
|
93
|
+
mode.pixIntent.current.copy.fail();
|
|
94
|
+
mode.pixIntent.current = null;
|
|
95
|
+
}, [createError, mode]);
|
|
96
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Box, useTheme } from "@mui/material";
|
|
2
|
-
import type
|
|
2
|
+
import { useEffect, useRef, useState, type JSX, type KeyboardEvent, type MouseEvent, type ReactNode } from "react";
|
|
3
3
|
|
|
4
4
|
import { usePaymentRefresh, usePaymentStale } from "../cart-changed";
|
|
5
5
|
import { useCheckoutCopy } from "../copy-context";
|
|
@@ -9,8 +9,11 @@ import type { PixAfterCopyCopy } from "../screens-copy";
|
|
|
9
9
|
import type { CheckoutOrder, PixCharge } from "../types";
|
|
10
10
|
import { useCheckoutComponents } from "../ui";
|
|
11
11
|
|
|
12
|
+
import { bankFlowOs, type PhoneOs } from "./bank-apps";
|
|
12
13
|
import type { PaymentListMode } from "./context";
|
|
13
14
|
import { ListBar, ActionButton, WaitingLine } from "./list-bar";
|
|
15
|
+
import { PixBanks, usePixBankState, type PixBankState } from "./pix-banks";
|
|
16
|
+
import type { CheckoutPixBank, PixBankId } from "./types";
|
|
14
17
|
|
|
15
18
|
/**
|
|
16
19
|
* The Pix, in the one list (FUT-3592 #3): "Gerar Pix" already created the
|
|
@@ -146,6 +149,42 @@ interface ListPixFaceProps {
|
|
|
146
149
|
onPreferCard?: () => void;
|
|
147
150
|
/** The poll's stalled panel, when it has one. */
|
|
148
151
|
footer: ReactNode;
|
|
152
|
+
/** The clipboard alone, saying whether it took the code — the bank flow's copy. */
|
|
153
|
+
writeClipboard: () => Promise<boolean>;
|
|
154
|
+
setAfterCopy: (afterCopy: boolean) => void;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** A phone with a bank remembered: the copy opens it too, and keeps the way to another (FUT-3612). */
|
|
158
|
+
function rememberedOnPhone(flow: BankFlow | null): PixBankId | null {
|
|
159
|
+
return flow?.os ? flow.state.chosen : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** "Copiar e abrir no Nubank" with a remembered bank on a phone, else the plain copy. */
|
|
163
|
+
function copyLabel(plain: string, flow: BankFlow | null): string {
|
|
164
|
+
const remembered = rememberedOnPhone(flow);
|
|
165
|
+
return remembered && flow ? flow.bank.copy.copyAndOpen(flow.bank.copy.names[remembered]) : plain;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** "Abrir outro banco", when a remembered bank on a phone is what the copy opens. */
|
|
169
|
+
function otherBankLabel(flow: BankFlow | null): string | null {
|
|
170
|
+
return flow && rememberedOnPhone(flow) ? flow.bank.copy.openOther : null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The bar's one control: held, "Atualizar pagamento", the wait after the copy, or the copy. */
|
|
174
|
+
function PixBarAction({ mode, afterCopy, onCopy, copyLabel }: {
|
|
175
|
+
mode: PaymentListMode;
|
|
176
|
+
afterCopy: boolean;
|
|
177
|
+
onCopy: () => void;
|
|
178
|
+
copyLabel: string;
|
|
179
|
+
}): JSX.Element {
|
|
180
|
+
const stale = usePaymentStale();
|
|
181
|
+
const refresh = usePaymentRefresh();
|
|
182
|
+
const refreshLabel = useCheckoutCopy().screens.cartChanged.refreshAction;
|
|
183
|
+
const { hold } = mode.list;
|
|
184
|
+
if (hold) return <ActionButton label={hold.action} total={null} disabled onClick={() => undefined} />;
|
|
185
|
+
if (stale && refresh) return <ActionButton label={refreshLabel} total={null} disabled={false} onClick={refresh} />;
|
|
186
|
+
if (afterCopy) return <WaitingLine text={mode.list.copy.pixFace.waiting} />;
|
|
187
|
+
return <ActionButton label={copyLabel} total={null} disabled={stale} onClick={onCopy} />;
|
|
149
188
|
}
|
|
150
189
|
|
|
151
190
|
/**
|
|
@@ -153,30 +192,126 @@ interface ListPixFaceProps {
|
|
|
153
192
|
* code the cart has outgrown — "Atualizar pagamento" (FUT-3535, E20), then
|
|
154
193
|
* the copy before it and the wait after it.
|
|
155
194
|
*/
|
|
156
|
-
function ListPixBar({ mode, afterCopy, onCopy
|
|
195
|
+
function ListPixBar({ mode, afterCopy, onCopy, bankFlow, onOtherBank }: {
|
|
196
|
+
mode: PaymentListMode;
|
|
197
|
+
afterCopy: boolean;
|
|
198
|
+
onCopy: () => void;
|
|
199
|
+
bankFlow: BankFlow | null;
|
|
200
|
+
onOtherBank: () => void;
|
|
201
|
+
}): JSX.Element {
|
|
157
202
|
const copy = mode.list.copy.pixFace;
|
|
158
203
|
const stale = usePaymentStale();
|
|
159
|
-
const refresh = usePaymentRefresh();
|
|
160
|
-
const refreshLabel = useCheckoutCopy().screens.cartChanged.refreshAction;
|
|
161
204
|
const { hold } = mode.list;
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
if (stale && refresh) return <ActionButton label={refreshLabel} total={null} disabled={false} onClick={refresh} />;
|
|
165
|
-
if (afterCopy) return <WaitingLine text={copy.waiting} />;
|
|
166
|
-
return <ActionButton label={copy.copyCode} total={null} disabled={stale} onClick={onCopy} />;
|
|
167
|
-
};
|
|
205
|
+
const settled = !hold && !stale;
|
|
206
|
+
const other = settled && !afterCopy ? otherBankLabel(bankFlow) : null;
|
|
168
207
|
return (
|
|
169
|
-
<ListBar summary={mode.list.summary} reason={hold?.reason ?? null} live={afterCopy &&
|
|
170
|
-
{
|
|
208
|
+
<ListBar summary={mode.list.summary} reason={hold?.reason ?? null} live={afterCopy && settled ? copy.waiting : null}>
|
|
209
|
+
<PixBarAction mode={mode} afterCopy={afterCopy} onCopy={onCopy} copyLabel={copyLabel(copy.copyCode, bankFlow)} />
|
|
210
|
+
{other ? <OtherBankLink label={other} onClick={onOtherBank} /> : null}
|
|
171
211
|
</ListBar>
|
|
172
212
|
);
|
|
173
213
|
}
|
|
174
214
|
|
|
215
|
+
/** "Abrir outro banco" — a centred text link under the bar's button (FUT-3612). */
|
|
216
|
+
export function OtherBankLink({ label, onClick }: { label: string; onClick: () => void }): JSX.Element {
|
|
217
|
+
const { Button } = useCheckoutComponents();
|
|
218
|
+
return (
|
|
219
|
+
<Box sx={{ display: "flex", justifyContent: "center", "& button": { textDecoration: "underline", minHeight: (t) => t.spacing(5.5) } }}>
|
|
220
|
+
<Button variant="text" color="primary" size="sm" onClick={onClick} dataTestId="pix-open-other-bank">
|
|
221
|
+
{label}
|
|
222
|
+
</Button>
|
|
223
|
+
</Box>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The Pix-to-bank flow, when the host offers it (FUT-3612). */
|
|
228
|
+
interface BankFlow {
|
|
229
|
+
bank: CheckoutPixBank;
|
|
230
|
+
/** `null` on a computer: copy-and-say, no banks. */
|
|
231
|
+
os: PhoneOs | null;
|
|
232
|
+
state: PixBankState;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The toast after a copy: which bank opens, or that one is to be chosen. */
|
|
236
|
+
function copiedSentence(flow: BankFlow, opening: PixBankId | null): string {
|
|
237
|
+
const { copy } = flow.bank;
|
|
238
|
+
if (!flow.os) return copy.copied;
|
|
239
|
+
return opening ? copy.copiedOpening(copy.names[opening]) : copy.copiedPick;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** A copy the clipboard took: the after-copy face, the grid when asked for, the toast, and the bank opened. */
|
|
243
|
+
function landCopy(at: { mode: PaymentListMode; flow: BankFlow; ports: CopyPorts; stale: boolean }, opening: PixBankId | null, grid: boolean): void {
|
|
244
|
+
const { mode, flow, ports } = at;
|
|
245
|
+
ports.setAfterCopy(true);
|
|
246
|
+
if (grid) flow.state.showGrid();
|
|
247
|
+
const target = flow.os ? opening : null;
|
|
248
|
+
mode.list.notify?.(copiedSentence(flow, target));
|
|
249
|
+
if (target && !at.stale) flow.state.open(target, false);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Every copy on the bank flow ends the same way: the after-copy face, the
|
|
254
|
+
* toast, and the bank opened — the one chosen, or none when the grid is asked
|
|
255
|
+
* for. A copy the clipboard refused does none of it (the code stays to copy by
|
|
256
|
+
* hand).
|
|
257
|
+
*/
|
|
258
|
+
function useBankCopies(mode: PaymentListMode, flow: BankFlow | null, pix: PixCharge, ports: CopyPorts) {
|
|
259
|
+
const stale = usePaymentStale();
|
|
260
|
+
const live = useRef(true);
|
|
261
|
+
useEffect(() => {
|
|
262
|
+
live.current = true;
|
|
263
|
+
return () => {
|
|
264
|
+
live.current = false;
|
|
265
|
+
};
|
|
266
|
+
}, []);
|
|
267
|
+
const land = (ok: boolean, grid: boolean, target: PixBankId | null): void => {
|
|
268
|
+
if (ok && live.current && flow) landCopy({ mode, flow, ports, stale }, grid ? null : target, grid);
|
|
269
|
+
};
|
|
270
|
+
// The bar's press already opened the clipboard, waiting for this code.
|
|
271
|
+
useEffect(() => {
|
|
272
|
+
const intent = mode.pixIntent.current;
|
|
273
|
+
if (!intent) return;
|
|
274
|
+
mode.pixIntent.current = null;
|
|
275
|
+
intent.copy.resolve(pix.copyPaste);
|
|
276
|
+
void intent.copy.done.then((ok) => land(ok, intent.pick, intent.pick ? null : intent.bank));
|
|
277
|
+
// Once, as the code arrives.
|
|
278
|
+
}, []);
|
|
279
|
+
const copyThen = (grid: boolean) => () =>
|
|
280
|
+
void ports.writeClipboard().then((ok) => land(ok, grid, flow?.state.chosen ?? null));
|
|
281
|
+
return { copy: copyThen(false), other: copyThen(true) };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
interface CopyPorts {
|
|
285
|
+
writeClipboard: () => Promise<boolean>;
|
|
286
|
+
setAfterCopy: (afterCopy: boolean) => void;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** The bank flow's state, or `null` when the host does not offer it. */
|
|
290
|
+
function useBankFlow(mode: PaymentListMode): BankFlow | null {
|
|
291
|
+
const bank = mode.list.pixBank;
|
|
292
|
+
const [os] = useState(() => bankFlowOs(bank?.phone));
|
|
293
|
+
const stale = usePaymentStale();
|
|
294
|
+
const startOnGrid = mode.pixIntent.current?.pick ?? false;
|
|
295
|
+
const state = usePixBankState(bank?.remembered ?? null, bank?.onPick ?? noPick, os, stale, startOnGrid);
|
|
296
|
+
return bank ? { bank, os, state } : null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function noPick(): void {
|
|
300
|
+
// No bank flow: nothing to remember.
|
|
301
|
+
}
|
|
302
|
+
|
|
175
303
|
/** Both faces of the list's Pix, and the bar under them. */
|
|
176
304
|
export function ListPixFace(props: ListPixFaceProps & { mode: PaymentListMode }): JSX.Element {
|
|
177
305
|
const { Text } = useCheckoutComponents();
|
|
178
306
|
const { mode, order, pix, afterCopy } = props;
|
|
179
307
|
const copy = mode.list.copy.pixFace;
|
|
308
|
+
const flow = useBankFlow(mode);
|
|
309
|
+
const copies = useBankCopies(mode, flow, pix, props);
|
|
310
|
+
const stale = usePaymentStale();
|
|
311
|
+
// On a phone the bank flow takes the copy: "Copiar e abrir o Nubank", the
|
|
312
|
+
// toast, the app. Without it — or on a computer — the copy is today's.
|
|
313
|
+
const onCopy = flow ? copies.copy : props.onCopy;
|
|
314
|
+
const banks = flow?.os ? { node: <PixBanks bank={flow.bank} state={flow.state} stale={stale} />, replacesChecklist: flow.state.chosen === null } : undefined;
|
|
180
315
|
return (
|
|
181
316
|
<Box data-testid="list-pix" sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
|
182
317
|
<Text variant="body" size="md" weight="bold" as="p" style={{ margin: 0 }} data-testid="list-pix-title">
|
|
@@ -189,18 +324,19 @@ export function ListPixFace(props: ListPixFaceProps & { mode: PaymentListMode })
|
|
|
189
324
|
totalLabel={mode.list.amountDueLabel ?? order.totalLabel}
|
|
190
325
|
deadline={props.time}
|
|
191
326
|
justCopied={props.justCopied}
|
|
192
|
-
onCopyAgain={props.onCopyAgain}
|
|
327
|
+
onCopyAgain={flow ? copies.copy : props.onCopyAgain}
|
|
193
328
|
onShowQr={() => {
|
|
194
329
|
mode.setQrOpen(true);
|
|
195
330
|
props.onShowQr();
|
|
196
331
|
}}
|
|
197
332
|
onPreferCard={props.onPreferCard}
|
|
198
333
|
footer={props.footer}
|
|
334
|
+
banks={banks}
|
|
199
335
|
/>
|
|
200
336
|
) : (
|
|
201
|
-
<BeforeCopy mode={mode} pix={pix} time={props.time} onCopy={
|
|
337
|
+
<BeforeCopy mode={mode} pix={pix} time={props.time} onCopy={onCopy} onChangeMethod={mode.onLeavePix} footer={props.footer} />
|
|
202
338
|
)}
|
|
203
|
-
<ListPixBar mode={mode} afterCopy={afterCopy} onCopy={
|
|
339
|
+
<ListPixBar mode={mode} afterCopy={afterCopy} onCopy={onCopy} bankFlow={flow} onOtherBank={copies.other} />
|
|
204
340
|
</Box>
|
|
205
341
|
);
|
|
206
342
|
}
|
|
@@ -130,6 +130,7 @@ export function PaymentListStep(props: PaymentListStepProps): JSX.Element {
|
|
|
130
130
|
fresh: c.freshInstrument,
|
|
131
131
|
arrivedWith: props.defaultBuyer,
|
|
132
132
|
taxIdOnFile: props.taxIdOnFile,
|
|
133
|
+
lastMethod: list.lastMethod,
|
|
133
134
|
onChoiceChange: list.onChoiceChange,
|
|
134
135
|
});
|
|
135
136
|
const charge = useListCharge({ ...c, cartTotal: displayTotals(props.totalOverride, props.cart).label, dueCents: list.totalCents });
|
|
@@ -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
|
+
}
|
|
@@ -131,6 +131,97 @@ export interface CheckoutPaymentList {
|
|
|
131
131
|
* the list returns to its rows.
|
|
132
132
|
*/
|
|
133
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;
|
|
134
225
|
}
|
|
135
226
|
|
|
136
227
|
/** The list's words — a pack per locale ships at `@12-apps/payments-frontend/locales`. */
|
|
@@ -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,9 +67,9 @@ 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
|
/**
|
|
@@ -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 ? (
|