@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.
- package/package.json +1 -1
- package/src/card/fields.tsx +33 -14
- package/src/components/checkout/card-view.tsx +19 -6
- package/src/components/checkout/cart-changed.tsx +24 -3
- package/src/components/checkout/checkout-flow.tsx +63 -79
- package/src/components/checkout/checkout-frame.tsx +175 -0
- package/src/components/checkout/checkout-steps.tsx +34 -66
- package/src/components/checkout/icons.tsx +5 -0
- package/src/components/checkout/method-tabs.tsx +249 -0
- package/src/components/checkout/payment-status-parts.tsx +13 -3
- package/src/components/checkout/payment-step-frame.tsx +206 -0
- package/src/components/checkout/payment-step-layout.tsx +169 -14
- package/src/components/checkout/payment-summary-phone.tsx +158 -0
- package/src/components/checkout/payment-summary.tsx +290 -0
- package/src/components/checkout/pix-after-copy.tsx +11 -4
- package/src/components/checkout/pix-pane.tsx +197 -161
- package/src/components/checkout/pix-parts.tsx +53 -5
- package/src/components/checkout/pix-view.tsx +37 -17
- package/src/components/checkout/screens-copy.ts +38 -3
- package/src/components/checkout/screens-en-US.ts +12 -0
- package/src/components/checkout/screens-pt-BR.ts +12 -0
- package/src/index.ts +2 -0
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
import { Box } from "@mui/material";
|
|
2
|
-
import {
|
|
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(
|
|
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
|
-
<
|
|
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>
|