@12-apps/payments-frontend 3.33.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.
@@ -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
+ }