@12-apps/payments-frontend 3.33.0 → 3.34.1
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 +238 -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 +131 -160
- package/src/components/checkout/screens-copy.ts +76 -17
- package/src/components/checkout/screens-en-US.ts +38 -13
- package/src/components/checkout/screens-pt-BR.ts +37 -12
- package/src/index.ts +1 -0
|
@@ -0,0 +1,238 @@
|
|
|
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
|
+
{/*
|
|
167
|
+
The box IS the code's frame, so whatever the host draws for inline code
|
|
168
|
+
(a tinted chip, its own border and padding) is undone inside it:
|
|
169
|
+
otherwise a wrapped code reads as a box inside a box.
|
|
170
|
+
*/}
|
|
171
|
+
<Box
|
|
172
|
+
sx={{
|
|
173
|
+
px: 2,
|
|
174
|
+
py: 1.5,
|
|
175
|
+
border: "1px solid",
|
|
176
|
+
borderColor: "divider",
|
|
177
|
+
borderRadius: 1.5,
|
|
178
|
+
bgcolor: "action.hover",
|
|
179
|
+
"&& > *": { bgcolor: "transparent", border: 0, borderRadius: 0, p: 0 },
|
|
180
|
+
}}
|
|
181
|
+
>
|
|
182
|
+
<Text variant="code" size="xs" as="p" data-testid="pix-code" style={{ margin: 0, wordBreak: "break-all", lineHeight: 1.55 }}>
|
|
183
|
+
{pix.copyPaste}
|
|
184
|
+
</Text>
|
|
185
|
+
</Box>
|
|
186
|
+
{/* The one action on the pane: the prototype's 56px bar (52 wide), whatever the host's lg is. */}
|
|
187
|
+
<Box sx={{ "& > *": { minHeight: 56 }, [PIX_WIDE]: { "& > *": { minHeight: 52 } } }}>
|
|
188
|
+
<Button variant="solid" color="primary" size="lg" fullWidth icon={<ContentCopyIcon fontSize="small" />} onClick={onCopy} dataTestId="pix-copy">
|
|
189
|
+
{copy.copyAction}
|
|
190
|
+
</Button>
|
|
191
|
+
</Box>
|
|
192
|
+
<Text variant="body" size="sm" color="secondary" as="p" style={{ margin: 0 }}>
|
|
193
|
+
<Box component="span" sx={{ display: "block", textAlign: "center", ...NARROW_ONLY }}>
|
|
194
|
+
<Emphasized text={copy.copyPasteHint} />
|
|
195
|
+
</Box>
|
|
196
|
+
<Box component="span" sx={wideOnly("block")}>
|
|
197
|
+
<Emphasized text={copy.internetBankingHint} />
|
|
198
|
+
</Box>
|
|
199
|
+
</Text>
|
|
200
|
+
</Box>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The word between the wide layout's two columns, on a hairline. */
|
|
205
|
+
function OrDivider(): JSX.Element {
|
|
206
|
+
const { Text } = useCheckoutComponents();
|
|
207
|
+
const copy = useCheckoutCopy().screens.pix;
|
|
208
|
+
const line = { width: "1px", height: 56, bgcolor: "divider" };
|
|
209
|
+
return (
|
|
210
|
+
<Box aria-hidden sx={{ ...wideOnly("flex"), flexDirection: "column", alignItems: "center", alignSelf: "center", gap: 1 }}>
|
|
211
|
+
<Box sx={line} />
|
|
212
|
+
<Text variant="body" size="xs" weight="semibold" color="secondary" as="span">
|
|
213
|
+
{copy.or}
|
|
214
|
+
</Text>
|
|
215
|
+
<Box sx={line} />
|
|
216
|
+
</Box>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function PixPane({ pix, tab, onTab, onCopy }: { pix: PixCharge; tab: PixTab; onTab: (tab: PixTab) => void; onCopy: () => void }): JSX.Element {
|
|
221
|
+
const idBase = useId();
|
|
222
|
+
return (
|
|
223
|
+
<>
|
|
224
|
+
<PixTabs tab={tab} onTab={onTab} idBase={idBase} />
|
|
225
|
+
<Box
|
|
226
|
+
sx={{
|
|
227
|
+
display: "grid",
|
|
228
|
+
gap: 2,
|
|
229
|
+
[PIX_WIDE]: { gridTemplateColumns: "minmax(0, 1fr) auto minmax(0, 1fr)", gap: 4, alignItems: "stretch" },
|
|
230
|
+
}}
|
|
231
|
+
>
|
|
232
|
+
<QrPanel pix={pix} shown={tab === "qr"} idBase={idBase} />
|
|
233
|
+
<OrDivider />
|
|
234
|
+
<CopyPanel pix={pix} shown={tab === "copy"} onCopy={onCopy} idBase={idBase} />
|
|
235
|
+
</Box>
|
|
236
|
+
</>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
@@ -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
|
+
}
|