@12-apps/payments-frontend 3.34.4 → 3.36.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,206 @@
1
+ import { Box } from "@mui/material";
2
+ import { useId, type JSX, type ReactNode } from "react";
3
+
4
+ import { displayTotals } from "./checkout-totals";
5
+ import { useCheckoutCopy } from "./copy-context";
6
+ import { MethodTabs, methodPanelId, methodTabId } from "./method-tabs";
7
+ import { PANEL_PAD_PHONE } from "./payment-step-layout";
8
+ import {
9
+ OrderSummaryCard,
10
+ type CheckoutDeliverySummary,
11
+ type CheckoutSummaryLine,
12
+ type OrderSummaryParts,
13
+ } from "./payment-summary";
14
+ import { PhoneOrderSummary } from "./payment-summary-phone";
15
+ import type { CheckoutOrder, PaymentMethod } from "./types";
16
+ import { useCheckoutComponents } from "./ui";
17
+
18
+ /**
19
+ * The Pagamento step's frame (FUT-3525): the computer's payment column with
20
+ * the order summary beside it, or the phone's single column with the total
21
+ * and chips on top — around ONE panel that holds the payment body.
22
+ */
23
+
24
+ /** What the step knows about the amount: the charge's, the balance's, the cart's. */
25
+ interface StepMoney {
26
+ order: CheckoutOrder | null;
27
+ cartTotals?: { totalLabel: string; totalItems: number };
28
+ totalOverride?: { label: string; items: number };
29
+ discountLines?: ReactNode;
30
+ }
31
+
32
+ /**
33
+ * The Pagamento step's own money line (FUT-1179), and everything the order
34
+ * summary shows beside it (FUT-3525).
35
+ *
36
+ * THE RAISED ORDER'S TOTAL WINS, exactly as the confirmation screen's does
37
+ * (`statusTotalLabel`). Once a charge exists, that charge's amount is what is
38
+ * being taken — a repriced cart, or an order resumed from a parked entry, makes
39
+ * the cart's total a different number from the one about to leave the buyer's
40
+ * account. Showing the cart's number above a QR for the order's would be this
41
+ * ticket's own defect in a new place: an amount on screen that is not the
42
+ * amount being charged.
43
+ *
44
+ * The item COUNT stays the cart's — an order carries no line count, and the
45
+ * caption is a description of the basket rather than of the charge.
46
+ *
47
+ * `null` when the host supplied no totals and no order — a step that would
48
+ * otherwise print "Total ·" with a blank beside it is worse than the silence
49
+ * FUT-1179 is about. Every host mounting `CheckoutFlow` gets them; only a
50
+ * hand-composed step can be missing them.
51
+ */
52
+ export function summaryParts(
53
+ money: StepMoney,
54
+ extras: SummaryExtras,
55
+ ): OrderSummaryParts | null {
56
+ const { order, cartTotals, totalOverride, discountLines } = money;
57
+ if (!cartTotals && !totalOverride && !order) return null;
58
+ const { label, items } = displayTotals(
59
+ totalOverride,
60
+ cartTotals ?? { totalLabel: "", totalItems: 0 },
61
+ );
62
+ return {
63
+ totalLabel: order?.totalLabel ?? label,
64
+ totalItems: items,
65
+ // Suppressed while settling a balance: those totals come from the frozen
66
+ // ticket, not the cart — and so do its lines.
67
+ discountLines: totalOverride ? undefined : discountLines,
68
+ lines: totalOverride ? undefined : extras.lines,
69
+ payer: extras.payer,
70
+ delivery: extras.delivery,
71
+ orderNote: extras.orderNote,
72
+ };
73
+ }
74
+
75
+ /** The order facts a host may hand the summary, beyond the money (FUT-3525). */
76
+ interface SummaryExtras {
77
+ lines?: CheckoutSummaryLine[];
78
+ payer?: OrderSummaryParts["payer"];
79
+ delivery?: CheckoutDeliverySummary;
80
+ orderNote?: ReactNode;
81
+ }
82
+
83
+ /**
84
+ * The panel under the method tabs: on a computer a card the chosen folder tab
85
+ * opens into (no top border, so the tab and the card are one shape); on a
86
+ * phone a rounded card under the pill. Empty — nothing chosen, nothing raised
87
+ * — it draws nothing, so an unanswered choice is not an empty box.
88
+ */
89
+ function panelSx(wide: boolean, attached: boolean, filled: boolean) {
90
+ const base = {
91
+ display: "flex",
92
+ flexDirection: "column",
93
+ gap: 2,
94
+ minWidth: 0,
95
+ "&:empty": { display: "none" },
96
+ } as const;
97
+ if (!filled) return base;
98
+ if (wide) {
99
+ return {
100
+ ...base,
101
+ p: 3,
102
+ bgcolor: "background.paper",
103
+ border: "1px solid",
104
+ borderColor: "divider",
105
+ borderTop: attached ? 0 : "1px solid",
106
+ borderTopColor: "divider",
107
+ borderRadius: attached ? "0 0 10px 10px" : "10px",
108
+ // Flush under the folder tabs: the column's gap would open a seam
109
+ // between the chosen tab and the card it opens into.
110
+ mt: attached ? -2 : 0,
111
+ } as const;
112
+ }
113
+ return { ...base, p: `${PANEL_PAD_PHONE}px`, bgcolor: "background.paper", border: "1px solid", borderColor: "divider", borderRadius: 4 } as const;
114
+ }
115
+
116
+ /** What the frame needs to draw the method tabs. */
117
+ interface FrameTabs {
118
+ show: boolean;
119
+ method: PaymentMethod | null;
120
+ onChange: (method: PaymentMethod) => void;
121
+ cardUnavailable: boolean;
122
+ offered: PaymentMethod[] | null;
123
+ }
124
+
125
+ /** The computer's heading over the tabs, at the prototype's 26px whatever the host's md is. */
126
+ function PayHeading(): JSX.Element {
127
+ const { Text } = useCheckoutComponents();
128
+ const copy = useCheckoutCopy().screens.layout;
129
+ return (
130
+ <Box sx={{ "&& > *": { fontSize: "1.625rem", lineHeight: 1.2, m: 0 } }}>
131
+ <Text variant="heading" size="md" weight="bold" as="h2">
132
+ {copy.payHeading}
133
+ </Text>
134
+ </Box>
135
+ );
136
+ }
137
+
138
+ const WIDE_GRID = { display: "grid", gridTemplateColumns: "minmax(0, 1fr) 380px", gap: 4, alignItems: "start" } as const;
139
+ const NARROW_COLUMN = { display: "flex", flexDirection: "column", width: "100%", maxWidth: 560, mx: "auto" } as const;
140
+
141
+ /**
142
+ * The panel's label: only a tab that is actually chosen — a card the store
143
+ * cannot charge stays disabled and unselected even while `method` names it.
144
+ */
145
+ function panelLabel(tabs: FrameTabs, idBase: string): string | undefined {
146
+ if (!tabs.show || !tabs.method) return undefined;
147
+ if (tabs.method === "CARD" && tabs.cardUnavailable) return undefined;
148
+ return methodTabId(idBase, tabs.method);
149
+ }
150
+
151
+ /** Over the tabs: the computer's heading, or the phone's total and chips. */
152
+ function StepHead({ wide, parts }: { wide: boolean; parts: OrderSummaryParts | null }): JSX.Element | null {
153
+ if (wide) return <PayHeading />;
154
+ return parts ? <PhoneOrderSummary parts={parts} /> : null;
155
+ }
156
+
157
+ /**
158
+ * The step's frame. The panel — and so the payment body inside it — keeps ONE
159
+ * place in the tree whichever layout is showing: a tablet turned on its side
160
+ * changes the frame, and remounting the card form there would throw away what
161
+ * the buyer typed.
162
+ */
163
+ export function PaymentStepFrame({
164
+ wide,
165
+ parts,
166
+ tabs,
167
+ filled,
168
+ children,
169
+ }: {
170
+ wide: boolean;
171
+ parts: OrderSummaryParts | null;
172
+ tabs: FrameTabs;
173
+ /** A method is chosen or an order raised — the panel has something to draw. */
174
+ filled: boolean;
175
+ children: ReactNode;
176
+ }): JSX.Element {
177
+ const idBase = useId();
178
+ const labelledBy = panelLabel(tabs, idBase);
179
+ return (
180
+ <Box data-testid="payment-step" data-layout={wide ? "wide" : "narrow"} sx={wide ? WIDE_GRID : NARROW_COLUMN}>
181
+ <Box sx={{ display: "flex", flexDirection: "column", gap: wide ? 2 : 2.5, minWidth: 0 }}>
182
+ <StepHead wide={wide} parts={parts} />
183
+ {tabs.show ? (
184
+ <MethodTabs
185
+ idBase={idBase}
186
+ shape={wide ? "folder" : "pill"}
187
+ value={tabs.method}
188
+ onChange={tabs.onChange}
189
+ cardUnavailable={tabs.cardUnavailable}
190
+ offered={tabs.offered}
191
+ />
192
+ ) : null}
193
+ <Box
194
+ id={methodPanelId(idBase)}
195
+ role={labelledBy ? "tabpanel" : undefined}
196
+ aria-labelledby={labelledBy}
197
+ data-testid="checkout-method-panel"
198
+ sx={panelSx(wide, tabs.show, filled)}
199
+ >
200
+ {children}
201
+ </Box>
202
+ </Box>
203
+ {wide && parts ? <OrderSummaryCard parts={parts} /> : null}
204
+ </Box>
205
+ );
206
+ }
@@ -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
+ }