@12-apps/payments-frontend 3.32.0 → 3.34.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/components/checkout/checkout-flow.tsx +3 -1
- package/src/components/checkout/checkout-steps.tsx +48 -59
- package/src/components/checkout/icons.tsx +6 -0
- package/src/components/checkout/method-picker.tsx +51 -89
- package/src/components/checkout/payment-step-layout.tsx +65 -0
- package/src/components/checkout/pix-after-copy.tsx +182 -0
- package/src/components/checkout/pix-pane.tsx +225 -0
- package/src/components/checkout/pix-parts.tsx +79 -0
- package/src/components/checkout/pix-stage.tsx +34 -0
- package/src/components/checkout/pix-view.tsx +133 -124
- package/src/components/checkout/screens-copy.ts +77 -11
- package/src/components/checkout/screens-en-US.ts +39 -9
- package/src/components/checkout/screens-pt-BR.ts +38 -8
- package/src/index.ts +1 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { Box } from "@mui/material";
|
|
2
|
+
import { useId, type JSX, type KeyboardEvent, type ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import { useCheckoutCopy } from "./copy-context";
|
|
5
|
+
import { ContentCopyIcon } from "./icons";
|
|
6
|
+
import { Emphasized, PIX_WIDE, PixQr } from "./pix-parts";
|
|
7
|
+
import type { PixCharge } from "./types";
|
|
8
|
+
import { useCheckoutComponents } from "./ui";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The PIX pane before a copy (the 2026-10-06 Pix redesign).
|
|
12
|
+
*
|
|
13
|
+
* Narrow — a phone — it is two tabs, "Copia e cola" first: the buyer pays on
|
|
14
|
+
* the phone they are holding and cannot scan a QR drawn on that same screen,
|
|
15
|
+
* so the code and its full-width button come before anything else. Wide — a
|
|
16
|
+
* desktop — the same two panels stand side by side with "ou" between them:
|
|
17
|
+
* the QR, for the phone in the buyer's hand, and the code, for their internet
|
|
18
|
+
* banking.
|
|
19
|
+
*
|
|
20
|
+
* Both panels are always mounted and the switch is CSS (`PIX_WIDE`), so the
|
|
21
|
+
* layout needs no measurement: the first paint is already right, and the QR is
|
|
22
|
+
* in the document whichever tab is showing.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export type PixTab = "copy" | "qr";
|
|
26
|
+
|
|
27
|
+
/** A narrow-only element: shown under the container query's threshold. */
|
|
28
|
+
const NARROW_ONLY = { [PIX_WIDE]: { display: "none" } } as const;
|
|
29
|
+
|
|
30
|
+
/** A wide-only element. */
|
|
31
|
+
function wideOnly(display: string) {
|
|
32
|
+
return { display: "none", [PIX_WIDE]: { display } } as const;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A tab panel: shown when its tab is picked, and always in the wide layout. */
|
|
36
|
+
function panelSx(shown: boolean) {
|
|
37
|
+
return {
|
|
38
|
+
display: shown ? "flex" : "none",
|
|
39
|
+
flexDirection: "column",
|
|
40
|
+
gap: 1.75,
|
|
41
|
+
minWidth: 0,
|
|
42
|
+
[PIX_WIDE]: { display: "flex" },
|
|
43
|
+
} as const;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The small capitals over each wide column. Uppercase by CSS only. */
|
|
47
|
+
function ColumnHeading({ children, center }: { children: ReactNode; center?: boolean }): JSX.Element {
|
|
48
|
+
const { Text } = useCheckoutComponents();
|
|
49
|
+
return (
|
|
50
|
+
<Box sx={{ ...wideOnly("block"), textAlign: center ? "center" : "left" }}>
|
|
51
|
+
<Text variant="body" size="xs" weight="bold" color="secondary" as="p" style={{ textTransform: "uppercase", letterSpacing: "0.04em", margin: 0 }}>
|
|
52
|
+
{children}
|
|
53
|
+
</Text>
|
|
54
|
+
</Box>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const TAB_ORDER: readonly PixTab[] = ["copy", "qr"];
|
|
59
|
+
|
|
60
|
+
/** The ARIA tabs pattern's keys: arrows move and select, Home and End jump. */
|
|
61
|
+
function nextTab(key: string, tab: PixTab): PixTab | null {
|
|
62
|
+
const at = TAB_ORDER.indexOf(tab);
|
|
63
|
+
if (key === "ArrowRight") return TAB_ORDER[(at + 1) % TAB_ORDER.length] ?? null;
|
|
64
|
+
if (key === "ArrowLeft") return TAB_ORDER[(at + TAB_ORDER.length - 1) % TAB_ORDER.length] ?? null;
|
|
65
|
+
if (key === "Home") return TAB_ORDER[0] ?? null;
|
|
66
|
+
if (key === "End") return TAB_ORDER[TAB_ORDER.length - 1] ?? null;
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** DOM ids for the tabs and panels, unique per pane (two panes on one page must not collide). */
|
|
71
|
+
function paneIds(base: string, id: PixTab): { tab: string; panel: string } {
|
|
72
|
+
return { tab: `${base}-tab-${id}`, panel: `${base}-panel-${id}` };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function PixTabs({ tab, onTab, idBase }: { tab: PixTab; onTab: (tab: PixTab) => void; idBase: string }): JSX.Element {
|
|
76
|
+
const copy = useCheckoutCopy().screens.pix;
|
|
77
|
+
const tabs: { id: PixTab; label: string }[] = [
|
|
78
|
+
{ id: "copy", label: copy.copyPasteTab },
|
|
79
|
+
{ id: "qr", label: copy.qrTab },
|
|
80
|
+
];
|
|
81
|
+
const onKeyDown = (event: KeyboardEvent<HTMLElement>): void => {
|
|
82
|
+
const next = nextTab(event.key, tab);
|
|
83
|
+
if (!next) return;
|
|
84
|
+
event.preventDefault();
|
|
85
|
+
onTab(next);
|
|
86
|
+
document.getElementById(paneIds(idBase, next).tab)?.focus();
|
|
87
|
+
};
|
|
88
|
+
return (
|
|
89
|
+
<Box
|
|
90
|
+
role="tablist"
|
|
91
|
+
aria-label={copy.tabsLabel}
|
|
92
|
+
onKeyDown={onKeyDown}
|
|
93
|
+
sx={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", borderBottom: "1px solid", borderColor: "divider", ...NARROW_ONLY }}
|
|
94
|
+
>
|
|
95
|
+
{tabs.map((item) => {
|
|
96
|
+
const selected = item.id === tab;
|
|
97
|
+
return (
|
|
98
|
+
<Box
|
|
99
|
+
key={item.id}
|
|
100
|
+
component="button"
|
|
101
|
+
type="button"
|
|
102
|
+
role="tab"
|
|
103
|
+
id={paneIds(idBase, item.id).tab}
|
|
104
|
+
aria-selected={selected}
|
|
105
|
+
aria-controls={paneIds(idBase, item.id).panel}
|
|
106
|
+
tabIndex={selected ? 0 : -1}
|
|
107
|
+
data-testid={`pix-tab-${item.id}`}
|
|
108
|
+
onClick={() => onTab(item.id)}
|
|
109
|
+
sx={{
|
|
110
|
+
minHeight: 44,
|
|
111
|
+
border: 0,
|
|
112
|
+
borderBottom: "3px solid",
|
|
113
|
+
borderColor: selected ? "primary.main" : "transparent",
|
|
114
|
+
bgcolor: "transparent",
|
|
115
|
+
font: "inherit",
|
|
116
|
+
fontSize: "0.875rem",
|
|
117
|
+
fontWeight: selected ? 700 : 600,
|
|
118
|
+
color: selected ? "text.primary" : "text.secondary",
|
|
119
|
+
cursor: "pointer",
|
|
120
|
+
"&:focus-visible": { outline: "2px solid", outlineColor: "primary.main", outlineOffset: -2 },
|
|
121
|
+
}}
|
|
122
|
+
>
|
|
123
|
+
{item.label}
|
|
124
|
+
</Box>
|
|
125
|
+
);
|
|
126
|
+
})}
|
|
127
|
+
</Box>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function QrPanel({ pix, shown, idBase }: { pix: PixCharge; shown: boolean; idBase: string }): JSX.Element {
|
|
132
|
+
const { Text } = useCheckoutComponents();
|
|
133
|
+
const copy = useCheckoutCopy().screens.pix;
|
|
134
|
+
return (
|
|
135
|
+
<Box
|
|
136
|
+
id={paneIds(idBase, "qr").panel}
|
|
137
|
+
role="tabpanel"
|
|
138
|
+
aria-labelledby={paneIds(idBase, "qr").tab}
|
|
139
|
+
sx={{
|
|
140
|
+
...panelSx(shown),
|
|
141
|
+
alignItems: "center",
|
|
142
|
+
textAlign: "center",
|
|
143
|
+
[PIX_WIDE]: { display: "flex", p: 3, borderRadius: 1.75, border: "1px solid", borderColor: "divider", bgcolor: "action.hover" },
|
|
144
|
+
}}
|
|
145
|
+
>
|
|
146
|
+
<ColumnHeading center>{copy.qrHeading}</ColumnHeading>
|
|
147
|
+
<Box sx={{ "& svg": { width: 200, height: 200 }, [PIX_WIDE]: { "& svg": { width: 232, height: 232 } } }}>
|
|
148
|
+
<PixQr payload={pix.copyPaste} size={232} alt={copy.qrAlt} testId="pix-qr" />
|
|
149
|
+
</Box>
|
|
150
|
+
<Text variant="body" size="sm" color="secondary" as="p" style={{ margin: 0, maxWidth: 280 }}>
|
|
151
|
+
<Box component="span" sx={NARROW_ONLY}>{copy.qrTabCaption}</Box>
|
|
152
|
+
<Box component="span" sx={wideOnly("inline")}>
|
|
153
|
+
<Emphasized text={copy.qrInstructions} />
|
|
154
|
+
</Box>
|
|
155
|
+
</Text>
|
|
156
|
+
</Box>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function CopyPanel({ pix, shown, onCopy, idBase }: { pix: PixCharge; shown: boolean; onCopy: () => void; idBase: string }): JSX.Element {
|
|
161
|
+
const { Button, Text } = useCheckoutComponents();
|
|
162
|
+
const copy = useCheckoutCopy().screens.pix;
|
|
163
|
+
return (
|
|
164
|
+
<Box id={paneIds(idBase, "copy").panel} role="tabpanel" aria-labelledby={paneIds(idBase, "copy").tab} sx={{ ...panelSx(shown), [PIX_WIDE]: { display: "flex", justifyContent: "center" } }}>
|
|
165
|
+
<ColumnHeading>{copy.copyPasteHeading}</ColumnHeading>
|
|
166
|
+
<Box
|
|
167
|
+
sx={{ px: 2, py: 1.5, border: "1px solid", borderColor: "divider", borderRadius: 1.5, bgcolor: "action.hover" }}
|
|
168
|
+
>
|
|
169
|
+
<Text variant="code" size="xs" as="p" data-testid="pix-code" style={{ margin: 0, wordBreak: "break-all", lineHeight: 1.55 }}>
|
|
170
|
+
{pix.copyPaste}
|
|
171
|
+
</Text>
|
|
172
|
+
</Box>
|
|
173
|
+
{/* The one action on the pane: the prototype's 56px bar (52 wide), whatever the host's lg is. */}
|
|
174
|
+
<Box sx={{ "& > *": { minHeight: 56 }, [PIX_WIDE]: { "& > *": { minHeight: 52 } } }}>
|
|
175
|
+
<Button variant="solid" color="primary" size="lg" fullWidth icon={<ContentCopyIcon fontSize="small" />} onClick={onCopy} dataTestId="pix-copy">
|
|
176
|
+
{copy.copyAction}
|
|
177
|
+
</Button>
|
|
178
|
+
</Box>
|
|
179
|
+
<Text variant="body" size="sm" color="secondary" as="p" style={{ margin: 0 }}>
|
|
180
|
+
<Box component="span" sx={{ display: "block", textAlign: "center", ...NARROW_ONLY }}>
|
|
181
|
+
<Emphasized text={copy.copyPasteHint} />
|
|
182
|
+
</Box>
|
|
183
|
+
<Box component="span" sx={wideOnly("block")}>
|
|
184
|
+
<Emphasized text={copy.internetBankingHint} />
|
|
185
|
+
</Box>
|
|
186
|
+
</Text>
|
|
187
|
+
</Box>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The word between the wide layout's two columns, on a hairline. */
|
|
192
|
+
function OrDivider(): JSX.Element {
|
|
193
|
+
const { Text } = useCheckoutComponents();
|
|
194
|
+
const copy = useCheckoutCopy().screens.pix;
|
|
195
|
+
const line = { width: "1px", height: 56, bgcolor: "divider" };
|
|
196
|
+
return (
|
|
197
|
+
<Box aria-hidden sx={{ ...wideOnly("flex"), flexDirection: "column", alignItems: "center", alignSelf: "center", gap: 1 }}>
|
|
198
|
+
<Box sx={line} />
|
|
199
|
+
<Text variant="body" size="xs" weight="semibold" color="secondary" as="span">
|
|
200
|
+
{copy.or}
|
|
201
|
+
</Text>
|
|
202
|
+
<Box sx={line} />
|
|
203
|
+
</Box>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function PixPane({ pix, tab, onTab, onCopy }: { pix: PixCharge; tab: PixTab; onTab: (tab: PixTab) => void; onCopy: () => void }): JSX.Element {
|
|
208
|
+
const idBase = useId();
|
|
209
|
+
return (
|
|
210
|
+
<>
|
|
211
|
+
<PixTabs tab={tab} onTab={onTab} idBase={idBase} />
|
|
212
|
+
<Box
|
|
213
|
+
sx={{
|
|
214
|
+
display: "grid",
|
|
215
|
+
gap: 2,
|
|
216
|
+
[PIX_WIDE]: { gridTemplateColumns: "minmax(0, 1fr) auto minmax(0, 1fr)", gap: 4, alignItems: "stretch" },
|
|
217
|
+
}}
|
|
218
|
+
>
|
|
219
|
+
<QrPanel pix={pix} shown={tab === "qr"} idBase={idBase} />
|
|
220
|
+
<OrDivider />
|
|
221
|
+
<CopyPanel pix={pix} shown={tab === "copy"} onCopy={onCopy} idBase={idBase} />
|
|
222
|
+
</Box>
|
|
223
|
+
</>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Box } from "@mui/material";
|
|
2
|
+
import { Fragment, useCallback, useEffect, useRef, useState, type JSX } from "react";
|
|
3
|
+
import QRCode from "react-qr-code";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The pieces the PIX pane and its after-copy face share (the 2026-10-06 Pix
|
|
7
|
+
* redesign): the container-query switch, the QR, bold spans in a sentence, and
|
|
8
|
+
* the clipboard.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The pane switches from tabs to two columns by its OWN width, never the
|
|
13
|
+
* window's: the same checkout is mounted in a 720px column, a 1040px one and a
|
|
14
|
+
* phone, and only the room it was given says whether two columns fit.
|
|
15
|
+
*/
|
|
16
|
+
export const PIX_WIDE = "@container pixpane (min-width: 760px)";
|
|
17
|
+
|
|
18
|
+
/** The container the {@link PIX_WIDE} query reads. */
|
|
19
|
+
export const PIX_CONTAINER = { containerType: "inline-size", containerName: "pixpane" } as const;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A sentence with `**…**` spans drawn bold. The copy packs mark the words the
|
|
23
|
+
* buyer looks for in their bank app ("Pix Copia e Cola"), so the emphasis is
|
|
24
|
+
* chosen with the words, by whoever writes them.
|
|
25
|
+
*/
|
|
26
|
+
export function Emphasized({ text }: { text: string }): JSX.Element {
|
|
27
|
+
const parts = text.split("**");
|
|
28
|
+
return (
|
|
29
|
+
<>
|
|
30
|
+
{parts.map((part, index) =>
|
|
31
|
+
index % 2 === 1 ? <b key={index}>{part}</b> : <Fragment key={index}>{part}</Fragment>,
|
|
32
|
+
)}
|
|
33
|
+
</>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The QR, in its bordered box, at `size` px. */
|
|
38
|
+
export function PixQr({ payload, size, alt, testId }: { payload: string; size: number; alt: string; testId?: string }): JSX.Element {
|
|
39
|
+
return (
|
|
40
|
+
<Box
|
|
41
|
+
data-testid={testId}
|
|
42
|
+
role="img"
|
|
43
|
+
aria-label={alt}
|
|
44
|
+
sx={{ bgcolor: "background.paper", p: 1.75, borderRadius: 1.5, border: "1px solid", borderColor: "divider", lineHeight: 0 }}
|
|
45
|
+
>
|
|
46
|
+
<QRCode value={payload} size={size} />
|
|
47
|
+
</Box>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Copy the payload, and say so for two seconds. `copy` resolves `true` only
|
|
53
|
+
* when the clipboard took it: a failed write must not move the buyer on to a
|
|
54
|
+
* screen that says "Código Pix copiado" (the code stays visible to select by
|
|
55
|
+
* hand instead).
|
|
56
|
+
*/
|
|
57
|
+
export function useClipboardCopy(text: string): { copy: () => Promise<boolean>; justCopied: boolean } {
|
|
58
|
+
const [justCopied, setJustCopied] = useState(false);
|
|
59
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
60
|
+
useEffect(
|
|
61
|
+
() => () => {
|
|
62
|
+
if (timer.current) clearTimeout(timer.current);
|
|
63
|
+
},
|
|
64
|
+
[],
|
|
65
|
+
);
|
|
66
|
+
const copy = useCallback(async (): Promise<boolean> => {
|
|
67
|
+
try {
|
|
68
|
+
if (!navigator.clipboard) return false;
|
|
69
|
+
await navigator.clipboard.writeText(text);
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
setJustCopied(true);
|
|
74
|
+
if (timer.current) clearTimeout(timer.current);
|
|
75
|
+
timer.current = setTimeout(() => setJustCopied(false), 2000);
|
|
76
|
+
return true;
|
|
77
|
+
}, [text]);
|
|
78
|
+
return { copy, justCopied };
|
|
79
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createContext, useContext, useState, type JSX, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which face the PIX pane is showing: the code to pay, or the wait that
|
|
5
|
+
* follows a copy (the 2026-10-06 Pix redesign).
|
|
6
|
+
*
|
|
7
|
+
* The flag lives ABOVE the pane, in the payment step, because the after-copy
|
|
8
|
+
* face hides the method picker — and the picker is the step's, not the
|
|
9
|
+
* pane's. `preferCard` is the step's way to the card, offered only when the
|
|
10
|
+
* store takes one: with the picker hidden it is the buyer's only route there,
|
|
11
|
+
* so it must never point at a method the store cannot charge.
|
|
12
|
+
*/
|
|
13
|
+
export interface PixStage {
|
|
14
|
+
afterCopy: boolean;
|
|
15
|
+
setAfterCopy: (afterCopy: boolean) => void;
|
|
16
|
+
preferCard?: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const PixStageContext = createContext<PixStage | null>(null);
|
|
20
|
+
|
|
21
|
+
export function PixStageProvider({ stage, children }: { stage: PixStage; children: ReactNode }): JSX.Element {
|
|
22
|
+
return <PixStageContext.Provider value={stage}>{children}</PixStageContext.Provider>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The stage the pane renders against. A pane mounted outside a payment step
|
|
27
|
+
* (a host composing `PixView` on its own, a unit test) keeps its own flag and
|
|
28
|
+
* offers no way to the card.
|
|
29
|
+
*/
|
|
30
|
+
export function usePixStage(): PixStage {
|
|
31
|
+
const shared = useContext(PixStageContext);
|
|
32
|
+
const [afterCopy, setAfterCopy] = useState(false);
|
|
33
|
+
return shared ?? { afterCopy, setAfterCopy };
|
|
34
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { Box } from "@mui/material";
|
|
2
|
-
import { useEffect, useMemo, useState, type JSX } from "react";
|
|
3
|
-
import QRCode from "react-qr-code";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState, type JSX } from "react";
|
|
4
3
|
|
|
5
4
|
import { useCheckoutCopy } from "./copy-context";
|
|
6
|
-
import {
|
|
5
|
+
import { PixAfterCopy } from "./pix-after-copy";
|
|
6
|
+
import { PixPane, type PixTab } from "./pix-pane";
|
|
7
|
+
import { PIX_CONTAINER, PIX_WIDE, useClipboardCopy } from "./pix-parts";
|
|
8
|
+
import { usePixStage } from "./pix-stage";
|
|
7
9
|
import type { PixPaneCopy, SettlingCopy } from "./screens-copy";
|
|
8
10
|
import { StalledWait } from "./stalled-wait";
|
|
9
11
|
import type { CheckoutOrder, OrderStatus, PixCharge } from "./types";
|
|
@@ -11,10 +13,11 @@ import { useCheckoutComponents } from "./ui";
|
|
|
11
13
|
import { usePaymentPolling } from "./use-payment-polling";
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
|
-
* PIX payment view:
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* the payment-status
|
|
16
|
+
* PIX payment view: the pane to pay from (tabs on a phone, QR and code side by
|
|
17
|
+
* side on a desktop — `./pix-pane`), and after a copy the wait that follows
|
|
18
|
+
* (`./pix-after-copy`). Polls the order in the background throughout and hands
|
|
19
|
+
* a terminal status up to the parent, which then shows the payment-status
|
|
20
|
+
* screen. The 2026-10-06 redesign moved the layout out; the wait stays here.
|
|
18
21
|
*/
|
|
19
22
|
|
|
20
23
|
/**
|
|
@@ -65,62 +68,6 @@ function pixWaitMs(expiresAt: string | undefined): number {
|
|
|
65
68
|
return Math.max(0, deadline - Date.now()) + PIX_EXPIRY_GRACE_MS;
|
|
66
69
|
}
|
|
67
70
|
|
|
68
|
-
/** The copyable "copia e cola" strip with its copy button. */
|
|
69
|
-
function PixCodeBox({ pix }: { pix: PixCharge }): JSX.Element {
|
|
70
|
-
const { Button, Text } = useCheckoutComponents();
|
|
71
|
-
const copy = useCheckoutCopy().screens.pix;
|
|
72
|
-
const [copied, setCopied] = useState(false);
|
|
73
|
-
|
|
74
|
-
const copyCode = async (): Promise<void> => {
|
|
75
|
-
try {
|
|
76
|
-
await navigator.clipboard?.writeText(pix.copyPaste);
|
|
77
|
-
setCopied(true);
|
|
78
|
-
setTimeout(() => setCopied(false), 2000);
|
|
79
|
-
} catch {
|
|
80
|
-
/* clipboard unavailable — the code is still visible to copy manually */
|
|
81
|
-
}
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
return (
|
|
85
|
-
<Box
|
|
86
|
-
sx={{
|
|
87
|
-
display: "flex",
|
|
88
|
-
alignItems: "center",
|
|
89
|
-
gap: 1,
|
|
90
|
-
width: "100%",
|
|
91
|
-
maxWidth: 420,
|
|
92
|
-
p: 1,
|
|
93
|
-
borderRadius: 1,
|
|
94
|
-
border: "1px solid",
|
|
95
|
-
borderColor: "divider",
|
|
96
|
-
bgcolor: "background.default",
|
|
97
|
-
}}
|
|
98
|
-
>
|
|
99
|
-
<Text
|
|
100
|
-
variant="code"
|
|
101
|
-
size="xs"
|
|
102
|
-
as="span"
|
|
103
|
-
data-testid="pix-code"
|
|
104
|
-
style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
|
105
|
-
>
|
|
106
|
-
{pix.copyPaste}
|
|
107
|
-
</Text>
|
|
108
|
-
<Button
|
|
109
|
-
variant="outline"
|
|
110
|
-
color="neutral"
|
|
111
|
-
size="sm"
|
|
112
|
-
icon={<ContentCopyIcon fontSize="small" />}
|
|
113
|
-
onClick={() => {
|
|
114
|
-
void copyCode();
|
|
115
|
-
}}
|
|
116
|
-
dataTestId="pix-copy"
|
|
117
|
-
>
|
|
118
|
-
{copied ? copy.copiedAction : copy.copyAction}
|
|
119
|
-
</Button>
|
|
120
|
-
</Box>
|
|
121
|
-
);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
71
|
/**
|
|
125
72
|
* Which of the wait's three faces the footer is showing — STOPPED, then STILL
|
|
126
73
|
* TRYING, then working.
|
|
@@ -163,18 +110,17 @@ function PixPollFooter({
|
|
|
163
110
|
error,
|
|
164
111
|
timedOut,
|
|
165
112
|
onCheckAgain,
|
|
166
|
-
|
|
113
|
+
awaiting,
|
|
167
114
|
}: {
|
|
168
115
|
error: string | null;
|
|
169
116
|
timedOut: boolean;
|
|
170
117
|
onCheckAgain: () => void;
|
|
171
|
-
/**
|
|
172
|
-
|
|
118
|
+
/** What the healthy wait says: whose confirmation it is waiting for, or "Verificando…" after a copy. */
|
|
119
|
+
awaiting: string;
|
|
173
120
|
}): JSX.Element {
|
|
174
121
|
const { Text } = useCheckoutComponents();
|
|
175
|
-
const {
|
|
122
|
+
const { settling } = useCheckoutCopy().screens;
|
|
176
123
|
const panel = pixWaitPanel(settling, error, timedOut);
|
|
177
|
-
const awaiting = (manual ? pix.manual?.awaiting : undefined) ?? pix.awaiting;
|
|
178
124
|
if (panel) {
|
|
179
125
|
// The same panel the card and wallet panes show, held to the width of the
|
|
180
126
|
// copy-and-paste strip above it so the centred PIX column stays a column.
|
|
@@ -193,7 +139,7 @@ function PixPollFooter({
|
|
|
193
139
|
return (
|
|
194
140
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1, color: "text.secondary" }}>
|
|
195
141
|
<LoadingDot />
|
|
196
|
-
<Text variant="
|
|
142
|
+
<Text variant="body" size="xs" color="secondary" as="span" data-testid="pix-awaiting">
|
|
197
143
|
{awaiting}
|
|
198
144
|
</Text>
|
|
199
145
|
</Box>
|
|
@@ -201,91 +147,154 @@ function PixPollFooter({
|
|
|
201
147
|
}
|
|
202
148
|
|
|
203
149
|
/**
|
|
204
|
-
* The
|
|
205
|
-
*
|
|
150
|
+
* The words that depend on who confirms. A store-confirmed Pix must not say
|
|
151
|
+
* "a confirmação é automática": nothing confirms it but the store.
|
|
206
152
|
*/
|
|
207
|
-
function pixSentences(
|
|
208
|
-
copy: PixPaneCopy,
|
|
209
|
-
manual: boolean,
|
|
210
|
-
): Pick<PixPaneCopy, "instructions" | "validUntil"> {
|
|
153
|
+
function pixSentences(copy: PixPaneCopy, manual: boolean): Pick<PixPaneCopy, "validUntil" | "awaiting" | "afterCopy"> {
|
|
211
154
|
return (manual ? copy.manual : undefined) ?? copy;
|
|
212
155
|
}
|
|
213
156
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
onResolved: (status: OrderStatus) => void;
|
|
221
|
-
pollIntervalMs?: number;
|
|
222
|
-
}): JSX.Element {
|
|
157
|
+
/**
|
|
158
|
+
* The deadline and the wait, rendered once for both layouts: narrow, two
|
|
159
|
+
* centred lines under the pane; wide, one footer row under a hairline with the
|
|
160
|
+
* wait on the left and the deadline on the right.
|
|
161
|
+
*/
|
|
162
|
+
function PixFooter({ deadline, children }: { deadline: string; children: JSX.Element }): JSX.Element {
|
|
223
163
|
const { Text } = useCheckoutComponents();
|
|
224
|
-
|
|
164
|
+
return (
|
|
165
|
+
<Box
|
|
166
|
+
sx={{
|
|
167
|
+
display: "flex",
|
|
168
|
+
flexDirection: "column",
|
|
169
|
+
alignItems: "center",
|
|
170
|
+
gap: 1,
|
|
171
|
+
textAlign: "center",
|
|
172
|
+
[PIX_WIDE]: { flexDirection: "row-reverse", justifyContent: "space-between", textAlign: "left", pt: 2.25, borderTop: "1px solid", borderColor: "divider" },
|
|
173
|
+
}}
|
|
174
|
+
>
|
|
175
|
+
<Text variant="body" size="xs" color="secondary" as="p" data-testid="pix-expiry" style={{ margin: 0 }}>
|
|
176
|
+
{deadline}
|
|
177
|
+
</Text>
|
|
178
|
+
{children}
|
|
179
|
+
</Box>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The poll, bounded by the charge's own expiry (FUT-1170). */
|
|
184
|
+
function usePixPolling(order: CheckoutOrder, onResolved: (status: OrderStatus) => void, pollIntervalMs: number) {
|
|
225
185
|
// Fixed once per charge (FUT-1170): the bound is a span, so recomputing it
|
|
226
186
|
// every render would keep pushing the deadline out — and it is an effect
|
|
227
187
|
// dependency, so it would also restart the wait on every render.
|
|
228
188
|
const expiresAt = order.pix?.expiresAt;
|
|
229
189
|
const maxWaitMs = useMemo(() => pixWaitMs(expiresAt), [expiresAt]);
|
|
230
|
-
const
|
|
190
|
+
const polling = usePaymentPolling(order.orderId, {
|
|
231
191
|
intervalMs: pollIntervalMs,
|
|
232
192
|
slowAfterPolls: PIX_FAST_POLLS,
|
|
233
193
|
// Never FASTER than the opening cadence: a host that opens slowly means it.
|
|
234
194
|
slowIntervalMs: Math.max(pollIntervalMs, PIX_SLOW_INTERVAL_MS),
|
|
235
195
|
maxWaitMs,
|
|
236
196
|
});
|
|
237
|
-
|
|
197
|
+
const { status } = polling;
|
|
238
198
|
// Bubble a terminal status up once, so the parent can advance to the status step.
|
|
239
199
|
useEffect(() => {
|
|
240
|
-
if (status && status !== "AWAITING_PAYMENT")
|
|
241
|
-
onResolved(status);
|
|
242
|
-
}
|
|
200
|
+
if (status && status !== "AWAITING_PAYMENT") onResolved(status);
|
|
243
201
|
}, [status, onResolved]);
|
|
202
|
+
return polling;
|
|
203
|
+
}
|
|
244
204
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
205
|
+
function PixBody({ order, pix, poll }: { order: CheckoutOrder; pix: PixCharge; poll: ReturnType<typeof usePixPolling> }): JSX.Element {
|
|
206
|
+
const { Text } = useCheckoutComponents();
|
|
207
|
+
const copy = useCheckoutCopy().screens.pix;
|
|
208
|
+
const stage = usePixStage();
|
|
209
|
+
const [tab, setTab] = useState<PixTab>("copy");
|
|
210
|
+
const { copy: writeClipboard, justCopied } = useClipboardCopy(pix.copyPaste);
|
|
211
|
+
// Leaving the payment step (a method switch, a terminal status) ends the
|
|
212
|
+
// after-copy face with it: the next pane starts on the code.
|
|
213
|
+
const { setAfterCopy } = stage;
|
|
214
|
+
// A clipboard write that settles after the pane is gone must not reopen the
|
|
215
|
+
// after-copy face over whatever replaced it (it would hide the picker).
|
|
216
|
+
const mounted = useRef(true);
|
|
217
|
+
useEffect(() => {
|
|
218
|
+
mounted.current = true;
|
|
219
|
+
return () => {
|
|
220
|
+
mounted.current = false;
|
|
221
|
+
setAfterCopy(false);
|
|
222
|
+
};
|
|
223
|
+
}, [setAfterCopy]);
|
|
253
224
|
|
|
254
225
|
// The locale is the HOST's (FUT-760): it decides what a buyer reads off the
|
|
255
226
|
// clock, so it travels with the sentence it feeds rather than being frozen
|
|
256
227
|
// to the origin host's here.
|
|
257
|
-
const
|
|
258
|
-
const
|
|
259
|
-
const
|
|
228
|
+
const time = new Date(pix.expiresAt).toLocaleTimeString(copy.expiryLocale, { hour: "2-digit", minute: "2-digit" });
|
|
229
|
+
const sentences = pixSentences(copy, pix.confirmation === "MANUAL");
|
|
230
|
+
const footerLine = (awaiting: string): JSX.Element => (
|
|
231
|
+
<PixPollFooter error={poll.error} timedOut={poll.timedOut} onCheckAgain={poll.checkAgain} awaiting={awaiting} />
|
|
232
|
+
);
|
|
260
233
|
|
|
234
|
+
if (stage.afterCopy) {
|
|
235
|
+
return (
|
|
236
|
+
<PixAfterCopy
|
|
237
|
+
pix={pix}
|
|
238
|
+
words={sentences.afterCopy}
|
|
239
|
+
totalLabel={order.totalLabel}
|
|
240
|
+
deadline={time}
|
|
241
|
+
justCopied={justCopied}
|
|
242
|
+
onCopyAgain={() => void writeClipboard()}
|
|
243
|
+
onShowQr={() => {
|
|
244
|
+
setTab("qr");
|
|
245
|
+
setAfterCopy(false);
|
|
246
|
+
}}
|
|
247
|
+
onPreferCard={stage.preferCard}
|
|
248
|
+
footer={footerLine(copy.verifying)}
|
|
249
|
+
/>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
261
252
|
return (
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 2, textAlign: "center" }}
|
|
265
|
-
>
|
|
266
|
-
<Text variant="heading" size="md" weight="bold" as="h2">
|
|
253
|
+
<>
|
|
254
|
+
<Text variant="heading" size="md" weight="bold" as="h2" style={{ margin: 0 }}>
|
|
267
255
|
{copy.heading}
|
|
268
256
|
</Text>
|
|
269
|
-
<
|
|
270
|
-
{
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
<
|
|
257
|
+
<PixPane
|
|
258
|
+
pix={pix}
|
|
259
|
+
tab={tab}
|
|
260
|
+
onTab={setTab}
|
|
261
|
+
onCopy={() => {
|
|
262
|
+
// Only a copy the clipboard TOOK moves on: the next face opens on
|
|
263
|
+
// "Código Pix copiado", and a failed write leaves the code on screen
|
|
264
|
+
// to select by hand.
|
|
265
|
+
void writeClipboard().then((ok) => {
|
|
266
|
+
if (ok && mounted.current) setAfterCopy(true);
|
|
267
|
+
});
|
|
268
|
+
}}
|
|
269
|
+
/>
|
|
270
|
+
<PixFooter deadline={sentences.validUntil(time)}>{footerLine(sentences.awaiting)}</PixFooter>
|
|
271
|
+
</>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
283
274
|
|
|
284
|
-
|
|
285
|
-
|
|
275
|
+
export function PixView({
|
|
276
|
+
order,
|
|
277
|
+
onResolved,
|
|
278
|
+
pollIntervalMs = 2500,
|
|
279
|
+
}: {
|
|
280
|
+
order: CheckoutOrder;
|
|
281
|
+
onResolved: (status: OrderStatus) => void;
|
|
282
|
+
pollIntervalMs?: number;
|
|
283
|
+
}): JSX.Element {
|
|
284
|
+
const { Text } = useCheckoutComponents();
|
|
285
|
+
const copy = useCheckoutCopy().screens.pix;
|
|
286
|
+
const poll = usePixPolling(order, onResolved, pollIntervalMs);
|
|
287
|
+
const pix = order.pix;
|
|
288
|
+
if (!pix) {
|
|
289
|
+
return (
|
|
290
|
+
<Text variant="body" size="sm" color="danger" as="p" data-testid="pix-missing">
|
|
291
|
+
{copy.chargeMissing}
|
|
286
292
|
</Text>
|
|
287
|
-
|
|
288
|
-
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
return (
|
|
296
|
+
<Box data-testid="pix-view" sx={{ ...PIX_CONTAINER, display: "flex", flexDirection: "column", gap: 2 }}>
|
|
297
|
+
<PixBody order={order} pix={pix} poll={poll} />
|
|
289
298
|
</Box>
|
|
290
299
|
);
|
|
291
300
|
}
|