@12-apps/payments-frontend 3.35.0 → 3.37.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.
@@ -1,8 +1,20 @@
1
1
  import { Box } from "@mui/material";
2
- import { useCallback, useMemo, useState, type JSX, type ReactNode } from "react";
2
+ import {
3
+ createContext,
4
+ useCallback,
5
+ useContext,
6
+ useLayoutEffect,
7
+ useMemo,
8
+ useState,
9
+ type JSX,
10
+ type ReactNode,
11
+ } from "react";
12
+
13
+ import { useCheckoutCopy } from "./copy-context";
3
14
 
4
15
  import type { PixStage } from "./pix-stage";
5
16
  import type { PaymentMethod } from "./types";
17
+ import { useCheckoutComponents } from "./ui";
6
18
 
7
19
  /**
8
20
  * The width of the Dados and confirmation steps, and of every non-PIX pane:
@@ -12,19 +24,6 @@ import type { PaymentMethod } from "./types";
12
24
  */
13
25
  export const STEP_COLUMN_MAX = 672;
14
26
 
15
- /**
16
- * The payment step's form column. The host may give this step more room than
17
- * the others (the 2026-10-06 Pix redesign lets the PIX pane put its QR and code
18
- * side by side), and only that pane uses it: the card form, wallets, hand-off,
19
- * the payer line and the raising and error panels keep the 672px they
20
- * always had. `full` lets the PIX pane through.
21
- */
22
- export function StepColumn({ full = false, children }: { full?: boolean; children: ReactNode }): JSX.Element {
23
- // `:empty` keeps a self-hiding child (the payer line, the raising state)
24
- // from leaving a gap in the step's flex column.
25
- return <Box sx={{ width: "100%", mx: "auto", maxWidth: full ? "none" : STEP_COLUMN_MAX, "&:empty": { display: "none" } }}>{children}</Box>;
26
- }
27
-
28
27
  /**
29
28
  * The payment step's half of the PIX pane's after-copy face (the 2026-10-06
30
29
  * Pix redesign): the flag that hides the method picker, and the way to the
@@ -63,3 +62,159 @@ export function usePaymentPixStage(
63
62
  * buys them nothing, but here the tap is the buyer's consent to LEAVE, and
64
63
  * taking it for them would redirect a checkout the moment it rendered.
65
64
  */
65
+
66
+ /**
67
+ * The checkout container's width class, from the 2026-10-11 Pagamento
68
+ * redesign (FUT-3525): `wide` is the computer layout (the payment column with
69
+ * the order summary beside it), `narrow` the phone layout (one column, chips,
70
+ * a sticky pay bar).
71
+ *
72
+ * Read off the flow's own full-width root rather than the viewport, so a host
73
+ * that renders the checkout inside a narrow panel gets the phone layout, and
74
+ * picked ONCE for the whole flow so the header does not change shape between
75
+ * steps.
76
+ */
77
+ export const WIDE_CHECKOUT_MIN = 900;
78
+
79
+ /** The phone panel's padding, in px — the sticky pay bar reaches past it. */
80
+ export const PANEL_PAD_PHONE = 16;
81
+
82
+ interface CheckoutLayout {
83
+ wide: boolean;
84
+ }
85
+
86
+ const CheckoutLayoutContext = createContext<CheckoutLayout | null>(null);
87
+
88
+ /** `null` outside a measured flow: a component composed by hand keeps its old look. */
89
+ export function useCheckoutLayout(): CheckoutLayout | null {
90
+ return useContext(CheckoutLayoutContext);
91
+ }
92
+
93
+ export const CheckoutLayoutProvider = CheckoutLayoutContext.Provider;
94
+
95
+ /**
96
+ * Measure an element's width and say whether it is the wide layout.
97
+ *
98
+ * A CALLBACK ref, so the measuring starts whenever the element mounts — the
99
+ * flow renders its empty-cart screen first while the host's cart is still
100
+ * loading, and a ref object read once in an effect would never see the frame
101
+ * that mounts after it. Measured in a layout effect so the first PAINT is
102
+ * already right; a width of zero (jsdom, a hidden tab) reads as narrow, which
103
+ * is the layout that fits anything.
104
+ */
105
+ export function useWideLayout(): [boolean, (element: HTMLElement | null) => void] {
106
+ const [element, setElement] = useState<HTMLElement | null>(null);
107
+ const [wide, setWide] = useState(false);
108
+ useLayoutEffect(() => {
109
+ if (!element) return;
110
+ const measure = (): void => setWide(element.getBoundingClientRect().width >= WIDE_CHECKOUT_MIN);
111
+ measure();
112
+ if (typeof ResizeObserver === "undefined") return;
113
+ const observer = new ResizeObserver(measure);
114
+ observer.observe(element);
115
+ return () => observer.disconnect();
116
+ }, [element]);
117
+ return [wide, setElement];
118
+ }
119
+
120
+ /** The amount the phone's pay bar shows beside its button; set by the Pagamento step. */
121
+ const PayBarTotalContext = createContext<string | null>(null);
122
+
123
+ export const PayBarTotalProvider = PayBarTotalContext.Provider;
124
+
125
+ /**
126
+ * The phone's sticky pay bar (FUT-3525): "Total" over the amount at its left,
127
+ * the step's ONE primary action filling the rest, pinned to the bottom of the
128
+ * viewport while the page scrolls. On the computer — or outside a measured
129
+ * flow — it is just the action, where the pane put it.
130
+ */
131
+ /**
132
+ * The colour the page itself is painted, for a bar that must read as part of
133
+ * the page. A host may paint its ground outside the theme (the storefront's
134
+ * is the body's, while its `background.default` is white), so the theme role
135
+ * is only the fallback until the first layout reads the real one.
136
+ */
137
+ function usePageGround(): string | undefined {
138
+ const [ground, setGround] = useState<string>();
139
+ useLayoutEffect(() => {
140
+ const painted = getComputedStyle(document.body).backgroundColor;
141
+ if (painted && painted !== "rgba(0, 0, 0, 0)" && painted !== "transparent") setGround(painted);
142
+ }, []);
143
+ return ground;
144
+ }
145
+
146
+ export function StickyPayBar({
147
+ children,
148
+ dataTestId,
149
+ }: {
150
+ children: ReactNode;
151
+ dataTestId?: string;
152
+ }): JSX.Element {
153
+ const layout = useCheckoutLayout();
154
+ const totalLabel = useContext(PayBarTotalContext);
155
+ const { Text } = useCheckoutComponents();
156
+ const copy = useCheckoutCopy().screens.layout;
157
+ const ground = usePageGround();
158
+ if (!layout) return <Box data-testid={dataTestId}>{children}</Box>;
159
+ if (layout.wide) {
160
+ // The computer's action, in the panel: the prototype's 52px bold bar.
161
+ return (
162
+ <Box data-testid={dataTestId} sx={{ "& button": { minHeight: 52 }, "& button, & button *": { fontWeight: 700 } }}>
163
+ {children}
164
+ </Box>
165
+ );
166
+ }
167
+ // The bar sits on the PAGE, not in the card around the pane (the
168
+ // prototype's layout C): it reaches past the panel's padding and border on
169
+ // three sides, so the card reads as ending above it.
170
+ const breakout = `calc(-1 * (${PANEL_PAD_PHONE}px + 1px))`;
171
+ return (
172
+ <Box
173
+ data-testid={dataTestId}
174
+ sx={{
175
+ position: "sticky",
176
+ bottom: 0,
177
+ zIndex: 2,
178
+ mx: breakout,
179
+ mb: breakout,
180
+ mt: 1,
181
+ // Its content on the column's edge, where the card's border was.
182
+ px: "1px",
183
+ pt: 1.5,
184
+ // Clear of a phone's home indicator.
185
+ pb: "calc(16px + env(safe-area-inset-bottom, 0px))",
186
+ display: "flex",
187
+ flexWrap: "wrap",
188
+ alignItems: "center",
189
+ columnGap: 2,
190
+ rowGap: 1,
191
+ bgcolor: ground ?? "background.default",
192
+ }}
193
+ >
194
+ {totalLabel ? (
195
+ <Box sx={{ flex: "none", display: "flex", flexDirection: "column" }}>
196
+ <Text variant="body" size="xs" as="span">
197
+ {copy.total}
198
+ </Text>
199
+ <Box sx={{ "&& > *": { fontSize: "1.125rem", lineHeight: 1.2 } }}>
200
+ <Text variant="body" size="md" weight="bold" as="span" data-testid="pay-bar-sticky-total">
201
+ {totalLabel}
202
+ </Text>
203
+ </Box>
204
+ </Box>
205
+ ) : null}
206
+ {/* The prototype's 56px pill with a bold label, never squeezed to two
207
+ lines: under ~200px beside the total it wraps under it instead. */}
208
+ <Box
209
+ sx={{
210
+ flex: "1 1 200px",
211
+ minWidth: 0,
212
+ "& button": { borderRadius: 999, minHeight: 56, whiteSpace: "nowrap" },
213
+ "& button, & button *": { fontWeight: 700 },
214
+ }}
215
+ >
216
+ {children}
217
+ </Box>
218
+ </Box>
219
+ );
220
+ }
@@ -0,0 +1,158 @@
1
+ import { Box } from "@mui/material";
2
+ import type { JSX, ReactNode } from "react";
3
+
4
+ import { formatCpf } from "../../card";
5
+
6
+ import { useCheckoutCopy } from "./copy-context";
7
+ import { PersonOutlineIcon, PlaceOutlinedIcon } from "./icons";
8
+ import { NoteIcon, TotalAmount, type CheckoutDeliverySummary, type OrderSummaryParts } from "./payment-summary";
9
+ import { useCheckoutComponents } from "./ui";
10
+
11
+ /**
12
+ * The phone's head of the Pagamento step (FUT-3525, layout C): the amount big
13
+ * at the top, then who pays, where it goes and the note as chips that wrap.
14
+ */
15
+
16
+ const CHIP_ICON = { fontSize: 18, color: "text.secondary" } as const;
17
+
18
+ const CHIP_SX = {
19
+ display: "inline-flex",
20
+ alignItems: "center",
21
+ gap: 1,
22
+ minHeight: 44,
23
+ maxWidth: "100%",
24
+ px: 1.75,
25
+ border: "1px solid",
26
+ borderColor: "divider",
27
+ borderRadius: 999,
28
+ bgcolor: "background.paper",
29
+ color: "text.primary",
30
+ font: "inherit",
31
+ fontSize: "0.8125rem",
32
+ cursor: "pointer",
33
+ "&:focus-visible": { outline: "2px solid", outlineColor: "primary.main", outlineOffset: 2 },
34
+ } as const;
35
+
36
+ /** One chip: an icon, a fact, and the word that changes it. */
37
+ function Chip({
38
+ icon,
39
+ text,
40
+ action,
41
+ onClick,
42
+ label,
43
+ testId,
44
+ }: {
45
+ icon: JSX.Element;
46
+ text: ReactNode;
47
+ action: string;
48
+ onClick: () => void;
49
+ label: string;
50
+ testId: string;
51
+ }): JSX.Element {
52
+ return (
53
+ <Box component="button" type="button" onClick={onClick} aria-label={label} data-testid={testId} sx={CHIP_SX}>
54
+ {icon}
55
+ <Box component="span" sx={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", minWidth: 0 }}>
56
+ {text}
57
+ </Box>
58
+ <Box component="span" sx={{ color: "primary.main", fontWeight: 600, flex: "none" }}>
59
+ {action}
60
+ </Box>
61
+ </Box>
62
+ );
63
+ }
64
+
65
+ /** Who pays: the name and "CPF ✓", or the CPF typed this time, masked. */
66
+ function PayerChip({ payer }: { payer: NonNullable<OrderSummaryParts["payer"]> }): JSX.Element {
67
+ const screens = useCheckoutCopy().screens;
68
+ const cpf = payer.taxId?.trim() ? screens.payer.taxId(formatCpf(payer.taxId)) : screens.layout.taxIdOnFileShort;
69
+ const who = payer.name ? screens.payer.payingAs(payer.name) : screens.payer.payingWithSavedDetails;
70
+ return (
71
+ <Box data-testid="checkout-payer" sx={{ display: "contents" }}>
72
+ <Chip
73
+ icon={<PersonOutlineIcon sx={CHIP_ICON} />}
74
+ text={
75
+ <>
76
+ {payer.name ? `${payer.name} · ` : null}
77
+ <span data-testid="checkout-payer-cpf">{cpf}</span>
78
+ </>
79
+ }
80
+ action={screens.payer.changeAction}
81
+ onClick={payer.onEdit}
82
+ label={`${who} — ${screens.payer.changeAction}`}
83
+ testId="checkout-payer-edit"
84
+ />
85
+ </Box>
86
+ );
87
+ }
88
+
89
+ /** Where it goes: the street line only, and the way back to the address step. */
90
+ function AddressChip({ delivery }: { delivery: CheckoutDeliverySummary }): JSX.Element {
91
+ const screens = useCheckoutCopy().screens;
92
+ return (
93
+ <Box data-testid="checkout-address" sx={{ display: "contents" }}>
94
+ <Chip
95
+ icon={<PlaceOutlinedIcon sx={CHIP_ICON} />}
96
+ text={delivery.line1}
97
+ action={screens.payer.changeAction}
98
+ onClick={delivery.onEdit}
99
+ label={`${delivery.line1} — ${screens.layout.changeAddress}`}
100
+ testId="checkout-address-edit"
101
+ />
102
+ </Box>
103
+ );
104
+ }
105
+
106
+ /**
107
+ * The host's note control, drawn as the third chip while it is closed; once it
108
+ * opens into a field, that field takes the whole row.
109
+ */
110
+ function NoteChip({ note }: { note: ReactNode }): JSX.Element {
111
+ return (
112
+ <Box
113
+ data-testid="checkout-note-slot"
114
+ sx={{
115
+ ...CHIP_SX,
116
+ gap: 0.5,
117
+ pr: 0.5,
118
+ cursor: "auto",
119
+ "&:has(textarea)": { flexBasis: "100%", display: "block", p: 0, border: 0, bgcolor: "transparent" },
120
+ "&:has(textarea) > svg": { display: "none" },
121
+ }}
122
+ >
123
+ <NoteIcon size={18} />
124
+ {note}
125
+ </Box>
126
+ );
127
+ }
128
+
129
+ /** The amount, big, under "Total a pagar · N itens", the host's money rows under it. */
130
+ function PhoneTotal({ parts }: { parts: OrderSummaryParts }): JSX.Element {
131
+ const { Text } = useCheckoutComponents();
132
+ const copy = useCheckoutCopy().screens.layout;
133
+ return (
134
+ <Box data-testid="payment-step-total" sx={{ display: "flex", flexDirection: "column", gap: 0.5, py: 1 }}>
135
+ <Text variant="body" size="sm" color="secondary" as="span">
136
+ {copy.totalDueCaption(parts.totalItems)}
137
+ </Text>
138
+ <TotalAmount totalLabel={parts.totalLabel} big />
139
+ {parts.discountLines}
140
+ </Box>
141
+ );
142
+ }
143
+
144
+ export function PhoneOrderSummary({ parts }: { parts: OrderSummaryParts }): JSX.Element {
145
+ const hasChips = Boolean(parts.payer || parts.delivery || parts.orderNote);
146
+ return (
147
+ <Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
148
+ <PhoneTotal parts={parts} />
149
+ {hasChips ? (
150
+ <Box data-testid="checkout-chips" sx={{ display: "flex", flexWrap: "wrap", gap: 1, alignItems: "center" }}>
151
+ {parts.payer ? <PayerChip payer={parts.payer} /> : null}
152
+ {parts.delivery ? <AddressChip delivery={parts.delivery} /> : null}
153
+ {parts.orderNote ? <NoteChip note={parts.orderNote} /> : null}
154
+ </Box>
155
+ ) : null}
156
+ </Box>
157
+ );
158
+ }
@@ -0,0 +1,290 @@
1
+ import { Box } from "@mui/material";
2
+ import type { JSX, ReactNode } from "react";
3
+
4
+ import { formatCpf } from "../../card";
5
+
6
+ import { useCheckoutCopy } from "./copy-context";
7
+ import { PersonOutlineIcon, PlaceOutlinedIcon } from "./icons";
8
+ import { useCheckoutComponents } from "./ui";
9
+
10
+ /**
11
+ * What the Pagamento step says about the ORDER around the payment (FUT-3525):
12
+ * its lines and total, who pays, where it goes and the note. On a computer
13
+ * it is a card beside the payment column ({@link OrderSummaryCard}); on a
14
+ * phone the total heads the page and the rest are chips
15
+ * ({@link PhoneOrderSummary}).
16
+ */
17
+
18
+ /** One cart line, as the host words it — the package does no money math. */
19
+ export interface CheckoutSummaryLine {
20
+ key: string;
21
+ quantity: number;
22
+ name: string;
23
+ /** The line's total, formatted by the host. */
24
+ totalLabel: string;
25
+ }
26
+
27
+ /** Where a delivery goes, and the host's way back to change it. */
28
+ export interface CheckoutDeliverySummary {
29
+ /** "Rua Augusta, 1500 · ap 42" — street, number and complement. */
30
+ line1: string;
31
+ /** "Consolação, São Paulo" — district and city. Optional. */
32
+ line2?: string;
33
+ /** Back to the host's address step. */
34
+ onEdit: () => void;
35
+ }
36
+
37
+ /** Everything the summary shows. Each part is optional and hides itself when absent. */
38
+ export interface OrderSummaryParts {
39
+ totalLabel: string;
40
+ totalItems: number;
41
+ /** The host's discount, fee and delivery lines under the total (FUT-246). */
42
+ discountLines?: ReactNode;
43
+ lines?: CheckoutSummaryLine[];
44
+ /** Who is paying — only for a flow whose Dados step was skipped (FUT-465). */
45
+ payer?: { name?: string; taxId?: string; onEdit: () => void };
46
+ delivery?: CheckoutDeliverySummary;
47
+ /** The host's note control, mounted as it is. */
48
+ orderNote?: ReactNode;
49
+ }
50
+
51
+ /** Read by a screen reader, not drawn. */
52
+ const VISUALLY_HIDDEN = {
53
+ position: "absolute",
54
+ width: "1px",
55
+ height: "1px",
56
+ overflow: "hidden",
57
+ clip: "rect(0 0 0 0)",
58
+ whiteSpace: "nowrap",
59
+ } as const;
60
+
61
+ /** "Alterar" as the prototype's top-aligned link on the card's edge, not a padded button. */
62
+ const EDIT_SX = { flex: "none", alignSelf: "flex-start", mt: -0.75, mr: -1 } as const;
63
+
64
+ const ROW_SX = { display: "flex", gap: 1.5, alignItems: "flex-start" } as const;
65
+ const ICON_SX = {
66
+ fontSize: 20,
67
+ color: "text.secondary",
68
+ flex: "0 0 auto",
69
+ mt: 0.25,
70
+ } as const;
71
+
72
+ export function NoteIcon({ size = 20 }: { size?: number }): JSX.Element {
73
+ return (
74
+ <Box
75
+ component="svg"
76
+ aria-hidden
77
+ viewBox="0 0 24 24"
78
+ sx={{
79
+ width: size,
80
+ height: size,
81
+ flex: "0 0 auto",
82
+ fill: "none",
83
+ stroke: "currentColor",
84
+ strokeWidth: 2,
85
+ color: "text.secondary",
86
+ }}
87
+ >
88
+ <path d="M4 4h16v12H8l-4 4z" />
89
+ </Box>
90
+ );
91
+ }
92
+
93
+ /** The amount, the one number the whole step is about. */
94
+ export function TotalAmount({
95
+ totalLabel,
96
+ big,
97
+ }: {
98
+ totalLabel: string;
99
+ big: boolean;
100
+ }): JSX.Element {
101
+ const { Text } = useCheckoutComponents();
102
+ return (
103
+ <Box
104
+ sx={{
105
+ "&& > *": {
106
+ fontSize: big ? "2.5rem" : "1.625rem",
107
+ lineHeight: 1.1,
108
+ letterSpacing: big ? "-0.02em" : 0,
109
+ },
110
+ }}
111
+ >
112
+ <Text
113
+ variant="heading"
114
+ size="md"
115
+ weight="bold"
116
+ color={big ? undefined : "primary"}
117
+ as="span"
118
+ data-testid="pay-bar-total"
119
+ >
120
+ {totalLabel}
121
+ </Text>
122
+ </Box>
123
+ );
124
+ }
125
+
126
+ function PayerRow({
127
+ payer,
128
+ }: {
129
+ payer: NonNullable<OrderSummaryParts["payer"]>;
130
+ }): JSX.Element {
131
+ const { Button, Text } = useCheckoutComponents();
132
+ const copy = useCheckoutCopy().screens.payer;
133
+ const cpf = payer.taxId?.trim()
134
+ ? copy.taxId(formatCpf(payer.taxId))
135
+ : copy.taxIdAlreadyKnown;
136
+ return (
137
+ <Box
138
+ data-testid="checkout-payer"
139
+ sx={ROW_SX}
140
+ aria-label={
141
+ payer.name ? copy.payingAs(payer.name) : copy.payingWithSavedDetails
142
+ }
143
+ role="group"
144
+ >
145
+ <PersonOutlineIcon sx={ICON_SX} />
146
+ <Box sx={{ flex: 1, minWidth: 0 }}>
147
+ <Text variant="body" size="sm" as="p" data-testid="checkout-payer-name">
148
+ {payer.name ?? copy.payingWithSavedDetails}
149
+ </Text>
150
+ <Text variant="body" size="xs" as="p" data-testid="checkout-payer-cpf">
151
+ {cpf}
152
+ </Text>
153
+ </Box>
154
+ <Box sx={EDIT_SX}>
155
+ <Button
156
+ variant="text"
157
+ color="primary"
158
+ size="sm"
159
+ onClick={payer.onEdit}
160
+ dataTestId="checkout-payer-edit"
161
+ >
162
+ {copy.changeAction}
163
+ </Button>
164
+ </Box>
165
+ </Box>
166
+ );
167
+ }
168
+
169
+ function AddressRow({
170
+ delivery,
171
+ }: {
172
+ delivery: CheckoutDeliverySummary;
173
+ }): JSX.Element {
174
+ const { Button, Text } = useCheckoutComponents();
175
+ const copy = useCheckoutCopy().screens;
176
+ return (
177
+ <Box data-testid="checkout-address" sx={ROW_SX}>
178
+ <PlaceOutlinedIcon sx={ICON_SX} />
179
+ <Box sx={{ flex: 1, minWidth: 0 }}>
180
+ <Text
181
+ variant="body"
182
+ size="sm"
183
+ as="p"
184
+ data-testid="checkout-address-line1"
185
+ >
186
+ {delivery.line1}
187
+ </Text>
188
+ {delivery.line2 ? (
189
+ <Text variant="body" size="xs" as="p">
190
+ {delivery.line2}
191
+ </Text>
192
+ ) : null}
193
+ </Box>
194
+ <Box sx={EDIT_SX}>
195
+ <Button
196
+ variant="text"
197
+ color="primary"
198
+ size="sm"
199
+ onClick={delivery.onEdit}
200
+ dataTestId="checkout-address-edit"
201
+ >
202
+ <span aria-hidden>{copy.payer.changeAction}</span>
203
+ <Box component="span" sx={VISUALLY_HIDDEN}>
204
+ {copy.layout.changeAddress}
205
+ </Box>
206
+ </Button>
207
+ </Box>
208
+ </Box>
209
+ );
210
+ }
211
+
212
+ const DIVIDER = <Box sx={{ height: "1px", bgcolor: "divider" }} />;
213
+
214
+ /** The cart's lines, "2× X-Burger … R$ 36,00", when the host passed them. */
215
+ function SummaryLines({ lines }: { lines: CheckoutSummaryLine[] }): JSX.Element {
216
+ const { Text } = useCheckoutComponents();
217
+ const copy = useCheckoutCopy().screens.layout;
218
+ return (
219
+ <Box component="ul" data-testid="checkout-summary-lines" sx={{ m: 0, p: 0, listStyle: "none", display: "flex", flexDirection: "column", gap: 1 }}>
220
+ {lines.map((line) => (
221
+ <Box component="li" key={line.key} sx={{ display: "flex", justifyContent: "space-between", gap: 2 }}>
222
+ <Text variant="body" size="sm" as="span">
223
+ {copy.lineLabel(line.quantity, line.name)}
224
+ </Text>
225
+ <Text variant="body" size="sm" as="span">
226
+ {line.totalLabel}
227
+ </Text>
228
+ </Box>
229
+ ))}
230
+ </Box>
231
+ );
232
+ }
233
+
234
+ /** "Total" and the amount on one line, the host's money rows under it. */
235
+ function SummaryTotal({ totalLabel, discountLines }: { totalLabel: string; discountLines?: ReactNode }): JSX.Element {
236
+ const { Text } = useCheckoutComponents();
237
+ const copy = useCheckoutCopy().screens.layout;
238
+ return (
239
+ <Box data-testid="payment-step-total" sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
240
+ <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 2 }}>
241
+ <Text variant="body" size="md" weight="semibold" as="span">
242
+ {copy.total}
243
+ </Text>
244
+ <TotalAmount totalLabel={totalLabel} big={false} />
245
+ </Box>
246
+ {discountLines}
247
+ </Box>
248
+ );
249
+ }
250
+
251
+ /** The host's note control, on a row of its own with the note icon. */
252
+ function NoteRow({ note }: { note: ReactNode }): JSX.Element {
253
+ return (
254
+ <Box sx={{ ...ROW_SX, alignItems: "center" }}>
255
+ <NoteIcon />
256
+ <Box sx={{ flex: 1, minWidth: 0, "& .MuiButton-root, & button": { px: 0 } }}>{note}</Box>
257
+ </Box>
258
+ );
259
+ }
260
+
261
+ /**
262
+ * The computer's summary card, beside the payment (FUT-3525, layout A).
263
+ * Sticky, so the total and the address stay in view while the buyer fills
264
+ * the card form.
265
+ */
266
+ export function OrderSummaryCard({ parts }: { parts: OrderSummaryParts }): JSX.Element {
267
+ const { Text } = useCheckoutComponents();
268
+ const copy = useCheckoutCopy().screens.layout;
269
+ const lines = parts.lines ?? [];
270
+ const hasWho = Boolean(parts.payer || parts.delivery || parts.orderNote);
271
+ return (
272
+ <Box
273
+ component="aside"
274
+ aria-label={copy.summaryTitle}
275
+ data-testid="checkout-summary"
276
+ sx={{ position: "sticky", top: 24, display: "flex", flexDirection: "column", gap: 2, p: 2.75, bgcolor: "background.paper", border: "1px solid", borderColor: "divider", borderRadius: 2.5 }}
277
+ >
278
+ <Text variant="heading" size="md" weight="bold" as="h2">
279
+ {copy.summaryTitle}
280
+ </Text>
281
+ {lines.length > 0 ? <SummaryLines lines={lines} /> : null}
282
+ {lines.length > 0 ? DIVIDER : null}
283
+ <SummaryTotal totalLabel={parts.totalLabel} discountLines={parts.discountLines} />
284
+ {hasWho ? DIVIDER : null}
285
+ {parts.payer ? <PayerRow payer={parts.payer} /> : null}
286
+ {parts.delivery ? <AddressRow delivery={parts.delivery} /> : null}
287
+ {parts.orderNote ? <NoteRow note={parts.orderNote} /> : null}
288
+ </Box>
289
+ );
290
+ }
@@ -1,9 +1,10 @@
1
1
  import { Box } from "@mui/material";
2
2
  import type { JSX, ReactNode } from "react";
3
3
 
4
+ import { usePaymentStale } from "./cart-changed";
4
5
  import { useCheckoutCopy } from "./copy-context";
5
6
  import { CheckIcon, ContentCopyIcon, CreditCardIcon, QrCodeIcon, ScheduleIcon } from "./icons";
6
- import { Emphasized, PIX_WIDE, PixQr } from "./pix-parts";
7
+ import { Emphasized, PIX_WIDE, PixQr, StaleVeil } from "./pix-parts";
7
8
  import type { PixAfterCopyCopy } from "./screens-copy";
8
9
  import type { PixCharge } from "./types";
9
10
  import { useCheckoutComponents } from "./ui";
@@ -128,11 +129,14 @@ interface PixAfterCopyProps {
128
129
  function Actions({ justCopied, onCopyAgain, onShowQr, onPreferCard }: Pick<PixAfterCopyProps, "justCopied" | "onCopyAgain" | "onShowQr" | "onPreferCard">): JSX.Element {
129
130
  const { Button } = useCheckoutComponents();
130
131
  const copy = useCheckoutCopy().screens.pix;
132
+ // The code already copied is the OLD order's once the cart changed
133
+ // (FUT-3535): copying it again would only hand the buyer the wrong amount.
134
+ const stale = usePaymentStale();
131
135
  return (
132
- <Box sx={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: 1.25, "& button": { minHeight: 48, height: "100%" }, "& button, & button *": { fontWeight: 700 }, [PIX_WIDE]: { display: "flex", flexWrap: "wrap", alignItems: "center", gap: 1.5, "& button": { height: "auto" } } }}>
136
+ <Box sx={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", gap: 1.25, "& button": { minHeight: 48, height: "100%" }, "& button, & button *": { fontWeight: 700 }, [PIX_WIDE]: { display: "flex", flexWrap: "wrap", alignItems: "center", gap: 1.5, "& button": { height: "auto" } } }}>
133
137
  {/* Narrow, a grid cell; wide, its own width beside the link (the prototype's row). */}
134
138
  <Box sx={{ minWidth: 0, [PIX_WIDE]: { flex: "none", "& > *": { width: "auto" } } }}>
135
- <Button variant="outline" color="neutral" size="md" fullWidth icon={<ContentCopyIcon fontSize="small" />} onClick={onCopyAgain} dataTestId="pix-copy-again">
139
+ <Button variant="outline" color="neutral" size="md" fullWidth icon={<ContentCopyIcon fontSize="small" />} onClick={onCopyAgain} disabled={stale} dataTestId="pix-copy-again">
136
140
  {justCopied ? copy.copiedAction : copy.copyAgainAction}
137
141
  </Button>
138
142
  </Box>
@@ -156,6 +160,7 @@ export function PixAfterCopy(props: PixAfterCopyProps): JSX.Element {
156
160
  const { Button, Text } = useCheckoutComponents();
157
161
  const copy = useCheckoutCopy().screens.pix;
158
162
  const { pix, words, totalLabel, deadline, onPreferCard, footer } = props;
163
+ const stale = usePaymentStale();
159
164
  return (
160
165
  <Box data-testid="pix-after-copy" sx={{ display: "flex", flexWrap: "wrap", gap: 4, alignItems: "flex-start" }}>
161
166
  <Box sx={{ flex: "999 1 420px", minWidth: 0, display: "flex", flexDirection: "column", gap: 2.5 }}>
@@ -175,7 +180,9 @@ export function PixAfterCopy(props: PixAfterCopyProps): JSX.Element {
175
180
  <Text variant="body" size="xs" weight="bold" color="secondary" as="p" style={{ margin: 0, textTransform: "uppercase", letterSpacing: "0.04em" }}>
176
181
  {copy.notPaidYet}
177
182
  </Text>
178
- <PixQr payload={pix.copyPaste} size={168} alt={copy.qrAlt} />
183
+ <StaleVeil stale={stale}>
184
+ <PixQr payload={pix.copyPaste} size={168} alt={copy.qrAlt} />
185
+ </StaleVeil>
179
186
  <Text variant="body" size="xs" color="secondary" as="p" style={{ margin: 0, textAlign: "center" }}>
180
187
  {copy.scanFromPhone}
181
188
  </Text>