@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,175 @@
1
+ import { Box } from "@mui/material";
2
+ import { useMemo, type JSX, type ReactNode } from "react";
3
+
4
+ import { useCheckoutCopy } from "./copy-context";
5
+ import { ArrowBackIcon } from "./icons";
6
+ import { CheckoutLayoutProvider, STEP_COLUMN_MAX, useCheckoutLayout, useWideLayout } from "./payment-step-layout";
7
+ import { useCheckoutComponents } from "./ui";
8
+ import type { CheckoutViewCopy } from "./view-copy";
9
+
10
+ /**
11
+ * The flow's frame and header: measured once at the host's full width
12
+ * (FUT-3525), so every step agrees on whether this is the computer's layout or
13
+ * the phone's.
14
+ */
15
+
16
+ /** Step ids are the flow's own contract; the labels beside them are host copy. */
17
+ function stepperSteps(copy: CheckoutViewCopy): { id: string; label: string }[] {
18
+ return [
19
+ { id: "dados", label: copy.steps.dados },
20
+ { id: "payment", label: copy.steps.payment },
21
+ { id: "status", label: copy.steps.status },
22
+ ];
23
+ }
24
+
25
+ /**
26
+ * The slim checkout header — the flow's only nav: a step-aware back link
27
+ * (Pagamento → Dados, else the menu).
28
+ *
29
+ * Deliberately nothing else. A "limpar carrinho" trash used to sit on the right
30
+ * of the Dados step; it was removed because emptying the cart is a CART action
31
+ * and belongs where the cart is edited — the drawer, which already offers a
32
+ * per-line remove. Offering it here put a destructive control next to the form
33
+ * a buyer is filling in, one tap away from the field they are typing into.
34
+ */
35
+ function CheckoutHeader({ copy, step, onBack }: { copy: CheckoutViewCopy; step: string; onBack: () => void }): JSX.Element {
36
+ const { Button } = useCheckoutComponents();
37
+ return (
38
+ <Box sx={{ minHeight: 36, display: "flex", alignItems: "center", gap: 1 }}>
39
+ <Button variant="text" color="neutral" size="sm" icon={<ArrowBackIcon fontSize="small" />} iconPosition="left" onClick={onBack} dataTestId="checkout-back">
40
+ {step === "dados" ? copy.dados.keepShopping : copy.dados.back}
41
+ </Button>
42
+ </Box>
43
+ );
44
+ }
45
+
46
+ /**
47
+ * The phone's header (FUT-3525, layout C): "← Voltar" with "Etapa 2 de 3 ·
48
+ * Pagamento" on the same row, and a thin bar filled to the step — the dots
49
+ * of the computer's stepper cost a phone a row for the same fact.
50
+ */
51
+ function CompactHeader({
52
+ copy,
53
+ step,
54
+ onBack,
55
+ }: {
56
+ copy: CheckoutViewCopy;
57
+ step: string;
58
+ onBack: () => void;
59
+ }): JSX.Element {
60
+ const { Button, Text } = useCheckoutComponents();
61
+ const layout = useCheckoutCopy().screens.layout;
62
+ const steps = stepperSteps(copy);
63
+ const at = Math.max(
64
+ 0,
65
+ steps.findIndex((item) => item.id === step),
66
+ );
67
+ const current = steps[at];
68
+ return (
69
+ <Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
70
+ <Box
71
+ sx={{
72
+ minHeight: 44,
73
+ display: "flex",
74
+ alignItems: "center",
75
+ justifyContent: "space-between",
76
+ gap: 1,
77
+ }}
78
+ >
79
+ {/* Its label on the column's edge, as the prototype draws it: the text
80
+ button's own padding would push it 12px in. */}
81
+ <Box sx={{ ml: -1.5 }}> <Button
82
+ variant="text"
83
+ color="neutral"
84
+ size="sm"
85
+ icon={<ArrowBackIcon fontSize="small" />}
86
+ iconPosition="left"
87
+ onClick={onBack}
88
+ dataTestId="checkout-back"
89
+ >
90
+ {step === "dados" ? copy.dados.keepShopping : copy.dados.back}
91
+ </Button>
92
+ </Box>
93
+ <Text
94
+ variant="body"
95
+ size="xs"
96
+ as="span"
97
+ data-testid="checkout-step-progress"
98
+ >
99
+ {layout.stepProgress(at + 1, steps.length, current?.label ?? "")}
100
+ </Text>
101
+ </Box>
102
+ <Box
103
+ // The bar draws the sentence beside it; a reader hears that once.
104
+ aria-hidden
105
+ data-testid="checkout-stepper"
106
+ sx={{
107
+ height: 4,
108
+ borderRadius: 2,
109
+ bgcolor: "divider",
110
+ overflow: "hidden",
111
+ }}
112
+ >
113
+ <Box
114
+ sx={{
115
+ height: "100%",
116
+ width: `${((at + 1) / steps.length) * 100}%`,
117
+ bgcolor: "primary.main",
118
+ borderRadius: 2,
119
+ }}
120
+ />
121
+ </Box>
122
+ </Box>
123
+ );
124
+ }
125
+
126
+ /** Compact 50px progress header so the form stays above the fold on mobile. */
127
+ function ProgressHeader({ copy, step, completed }: { copy: CheckoutViewCopy; step: string; completed: Set<string> }): JSX.Element {
128
+ const { Stepper } = useCheckoutComponents();
129
+ return (
130
+ <Box sx={{ height: 50, display: "flex", flexDirection: "column", justifyContent: "center" }}>
131
+ <Stepper steps={stepperSteps(copy)} activeId={step} completed={completed} orientation="horizontal" size="sm" data-testid="checkout-stepper" />
132
+ </Box>
133
+ );
134
+ }
135
+
136
+ /** The header for the frame the flow is in: back link and stepper on a computer, one compact row on a phone. */
137
+ export function FlowHeader({
138
+ copy,
139
+ step,
140
+ completed,
141
+ onBack,
142
+ }: {
143
+ copy: CheckoutViewCopy;
144
+ step: string;
145
+ completed: Set<string>;
146
+ onBack: () => void;
147
+ }): JSX.Element {
148
+ const wide = useCheckoutLayout()?.wide ?? false;
149
+ if (!wide) return <CompactHeader copy={copy} step={step} onBack={onBack} />;
150
+ return (
151
+ <>
152
+ <CheckoutHeader copy={copy} step={step} onBack={onBack} />
153
+ <ProgressHeader copy={copy} step={step} completed={completed} />
154
+ </>
155
+ );
156
+ }
157
+
158
+ /**
159
+ * Measured at the HOST's full width: one frame for the whole flow. Pagamento
160
+ * alone takes that width (its summary column); every other step keeps the
161
+ * 672px column it always had.
162
+ */
163
+ export function CheckoutFrame({ payment, children }: { payment: boolean; children: ReactNode }): JSX.Element {
164
+ const [wide, frame] = useWideLayout();
165
+ const layout = useMemo(() => ({ wide }), [wide]);
166
+ return (
167
+ <Box ref={frame} sx={{ width: "100%" }}>
168
+ <CheckoutLayoutProvider value={layout}>
169
+ <Box sx={{ display: "flex", flexDirection: "column", gap: { xs: 2, sm: 3 }, width: "100%", mx: "auto", maxWidth: payment ? "none" : STEP_COLUMN_MAX }}>
170
+ {children}
171
+ </Box>
172
+ </CheckoutLayoutProvider>
173
+ </Box>
174
+ );
175
+ }
@@ -2,14 +2,14 @@ import { Box } from "@mui/material";
2
2
  import { useEffect, useRef, type JSX, type ReactNode } from "react";
3
3
 
4
4
  import type { CheckoutBasketIdentity } from "./basket";
5
- import { displayTotals, PayBarTotal } from "./checkout-totals";
5
+ import { CartChangedBoundary, type OrderRefresh } from "./cart-changed";
6
6
  import { useCheckoutCopy } from "./copy-context";
7
7
  import { useMethodChoice } from "./method-choice";
8
- import { MethodPicker } from "./method-picker";
9
8
  import { PixStageProvider } from "./pix-stage";
10
- import { StepColumn, usePaymentPixStage } from "./payment-step-layout";
9
+ import { PayBarTotalProvider, useCheckoutLayout, usePaymentPixStage } from "./payment-step-layout";
11
10
  import { PaymentErrorPanel } from "./payment-error-panel";
12
- import { PayerSummary } from "./payer-summary";
11
+ import { PaymentStepFrame, summaryParts } from "./payment-step-frame";
12
+ import type { CheckoutDeliverySummary, CheckoutSummaryLine, OrderSummaryParts } from "./payment-summary";
13
13
  import { resolveCheckoutScreen } from "./providers/registry";
14
14
  import type {
15
15
  BuyerField,
@@ -123,48 +123,6 @@ export function EmptyCart({ copy, onBack }: { copy: EmptyCartCopy; onBack: () =>
123
123
  );
124
124
  }
125
125
 
126
- /** What the step knows about the amount: the charge's, the balance's, the cart's. */
127
- interface StepMoney {
128
- order: CheckoutOrder | null;
129
- cartTotals?: { totalLabel: string; totalItems: number };
130
- totalOverride?: { label: string; items: number };
131
- discountLines?: ReactNode;
132
- }
133
-
134
- /**
135
- * The Pagamento step's own money line (FUT-1179).
136
- *
137
- * THE RAISED ORDER'S TOTAL WINS, exactly as the confirmation screen's does
138
- * (`statusTotalLabel`). Once a charge exists, that charge's amount is what is
139
- * being taken — a repriced cart, or an order resumed from a parked entry, makes
140
- * the cart's total a different number from the one about to leave the buyer's
141
- * account. Showing the cart's number above a QR for the order's would be this
142
- * ticket's own defect in a new place: an amount on screen that is not the
143
- * amount being charged.
144
- *
145
- * The item COUNT stays the cart's — an order carries no line count, and the
146
- * caption is a description of the basket rather than of the charge.
147
- *
148
- * Renders nothing at all when the host supplied no totals and no order — a step
149
- * that would otherwise print "Total ·" with a blank beside it is worse than the
150
- * silence this ticket is about. Every host mounting `CheckoutFlow` gets them;
151
- * only a hand-composed step can be missing them.
152
- */
153
- function PaymentStepTotal({ money }: { money: StepMoney }): JSX.Element | null {
154
- const { order, cartTotals, totalOverride, discountLines } = money;
155
- if (!cartTotals && !totalOverride && !order) return null;
156
- const { label, items } = displayTotals(totalOverride, cartTotals ?? { totalLabel: "", totalItems: 0 });
157
- return (
158
- <Box data-testid="payment-step-total" sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 2 }}>
159
- <PayBarTotal totalLabel={order?.totalLabel ?? label} totalItems={items}>
160
- {/* Suppressed while settling a balance: those totals come from the
161
- frozen ticket, not the cart. */}
162
- {totalOverride ? null : discountLines}
163
- </PayBarTotal>
164
- </Box>
165
- );
166
- }
167
-
168
126
  /** Props for {@link PaymentStep} — step 2's inputs, wired by the flow. */
169
127
  interface PaymentStepProps {
170
128
  method: PaymentMethod | null;
@@ -180,6 +138,11 @@ interface PaymentStepProps {
180
138
  */
181
139
  errorCode?: string | null;
182
140
  onGenerate: (method: PaymentMethod) => void;
141
+ /**
142
+ * Re-price the raised order IN PLACE, keeping the card form (FUT-1139). Absent,
143
+ * the step falls back to raising the payment again.
144
+ */
145
+ refresh?: OrderRefresh;
183
146
  onUseEmail: (email: string) => void;
184
147
  /**
185
148
  * Present ⇒ the buyer reached this step without a Dados step (FUT-465), so
@@ -215,6 +178,12 @@ interface PaymentStepProps {
215
178
  totalOverride?: { label: string; items: number };
216
179
  /** The host's own rendered discount itemization, under the total (FUT-246). */
217
180
  discountLines?: ReactNode;
181
+ /** The cart's lines, listed in the computer's order summary (FUT-3525). */
182
+ lines?: CheckoutSummaryLine[];
183
+ /** Where a delivery goes — Entrega only; absent hides the row (FUT-3525). */
184
+ delivery?: CheckoutDeliverySummary;
185
+ /** The host's note control, shown in the summary (FUT-3525). */
186
+ orderNote?: ReactNode;
218
187
  pollIntervalMs?: number;
219
188
  /** Retrying a refused card — preselect no saved instrument (FUT-1145). */
220
189
  freshInstrument?: boolean;
@@ -240,6 +209,7 @@ export function PaymentStep({
240
209
  errorField,
241
210
  errorCode,
242
211
  onGenerate,
212
+ refresh,
243
213
  onUseEmail,
244
214
  onEditBuyer,
245
215
  providerConfig,
@@ -247,6 +217,9 @@ export function PaymentStep({
247
217
  cartTotals,
248
218
  totalOverride,
249
219
  discountLines,
220
+ lines,
221
+ delivery,
222
+ orderNote,
250
223
  pollIntervalMs,
251
224
  freshInstrument,
252
225
  basket,
@@ -258,43 +231,39 @@ export function PaymentStep({
258
231
  useAutoRaiseOrder(order, method, creating, createError, onGenerate);
259
232
  const pixStage = usePaymentPixStage(choice, onMethodChange);
260
233
 
234
+ const wide = useCheckoutLayout()?.wide ?? false;
235
+ const parts = summaryParts(
236
+ { order, cartTotals, totalOverride, discountLines },
237
+ { lines, payer: payerOf(buyer, onEditBuyer), delivery, orderNote },
238
+ );
239
+ const tabs = {
240
+ show: !(choice.atProvider || pixStage.afterCopy),
241
+ method,
242
+ onChange: onMethodChange,
243
+ cardUnavailable: choice.cardUnavailable,
244
+ offered: choice.offered,
245
+ };
246
+
261
247
  return (
262
248
  <PixStageProvider stage={pixStage}>
263
- <Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
264
- {/* THE AMOUNT, before anything asks for it (FUT-1179). At the top rather
265
- than in a sticky bar of its own: this step's actions belong to the
266
- pane below it — the PIX code, the card form's own pay bar, the
267
- hand-off button — and a second bar would put two "pay" controls on
268
- one screen. */}
269
- <PaymentStepTotal money={{ order, cartTotals, totalOverride, discountLines }} />
270
-
271
- {/* Self-hiding: renders only for a flow whose Dados step was skipped. */}
272
- <StepColumn>
273
- <PayerSummary name={buyer.name} taxId={buyer.taxId} onEdit={onEditBuyer} />
274
- </StepColumn>
275
-
276
- {choice.atProvider || pixStage.afterCopy ? null : (
277
- <MethodPicker value={method} onChange={onMethodChange} cardUnavailable={choice.cardUnavailable} offered={choice.offered} />
278
- )}
279
-
280
- <StepColumn full={order?.method === "PIX"}>
281
- <PaymentBody
282
- order={order}
283
- buyer={buyer}
284
- providerConfig={config}
285
- method={method}
286
- tenantSlug={tenantSlug}
287
- onResolved={onResolved}
288
- onStart={choice.onStart}
289
- creating={creating}
290
- pollIntervalMs={pollIntervalMs}
291
- freshInstrument={freshInstrument}
292
- basket={basket}
293
- validateApplePayMerchant={validateApplePayMerchant}
294
- />
295
- </StepColumn>
296
-
297
- <StepColumn>
249
+ <PayBarTotalProvider value={parts?.totalLabel ?? null}>
250
+ <PaymentStepFrame wide={wide} parts={parts} tabs={tabs} filled={Boolean(method || order)}>
251
+ <CartChangedBoundary {...{ order, creating, onGenerate, refresh }} basket={totalOverride ? undefined : basket}>
252
+ <PaymentBody
253
+ order={order}
254
+ buyer={buyer}
255
+ providerConfig={config}
256
+ method={method}
257
+ tenantSlug={tenantSlug}
258
+ onResolved={onResolved}
259
+ onStart={choice.onStart}
260
+ creating={creating}
261
+ pollIntervalMs={pollIntervalMs}
262
+ freshInstrument={freshInstrument}
263
+ basket={basket}
264
+ validateApplePayMerchant={validateApplePayMerchant}
265
+ />
266
+ </CartChangedBoundary>
298
267
  <RaisingState
299
268
  order={order}
300
269
  method={method}
@@ -305,12 +274,17 @@ export function PaymentStep({
305
274
  onUseEmail={onUseEmail}
306
275
  onGenerate={onGenerate}
307
276
  />
308
- </StepColumn>
309
- </Box>
277
+ </PaymentStepFrame>
278
+ </PayBarTotalProvider>
310
279
  </PixStageProvider>
311
280
  );
312
281
  }
313
282
 
283
+ /** The payer line's facts — only for a flow whose Dados step was skipped (FUT-465). */
284
+ function payerOf(buyer: BuyerInfo, onEditBuyer: (() => void) | undefined): OrderSummaryParts["payer"] {
285
+ return onEditBuyer ? { name: buyer.name, taxId: buyer.taxId, onEdit: onEditBuyer } : undefined;
286
+ }
287
+
314
288
  /**
315
289
  * What the shell says while the order is being raised, and if raising it
316
290
  * failed.
@@ -69,3 +69,8 @@ export const QrCodeIcon = makeIcon([
69
69
  ]);
70
70
 
71
71
  export const CheckIcon = makeIcon(['M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z']);
72
+
73
+ export const PlaceOutlinedIcon = makeIcon([
74
+ 'M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7M7 9c0-2.76 2.24-5 5-5s5 2.24 5 5c0 2.88-2.88 7.19-5 9.88C9.92 16.21 7 11.85 7 9',
75
+ 'M12 6.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5',
76
+ ]);
@@ -0,0 +1,249 @@
1
+ import { Box } from "@mui/material";
2
+ import type { JSX, KeyboardEvent } from "react";
3
+
4
+ import { useCheckoutCopy } from "./copy-context";
5
+ import { CreditCardIcon, PixIcon } from "./icons";
6
+ import type { PaymentMethod } from "./types";
7
+ import { useCheckoutComponents } from "./ui";
8
+
9
+ /**
10
+ * The Pagamento step's method selector as TABS (FUT-3525): folder tabs over
11
+ * the payment panel on a computer, a pill with two equal segments on a phone.
12
+ *
13
+ * Tabs rather than the radio tiles of {@link MethodPicker} because what the
14
+ * choice changes is the panel right under it — the Pix pane or the card form
15
+ * — and the folder shape draws exactly that: the chosen tab opens into its
16
+ * panel. Pix and Cartão are always the same width.
17
+ *
18
+ * Everything else is the tiles' behaviour, kept: selecting raises the order
19
+ * (the step's auto-raise), a store without a card path disables that tab and
20
+ * says why under it, and the test ids are `checkout-method-<METHOD>`.
21
+ */
22
+
23
+ type MethodTabsShape = "folder" | "pill";
24
+
25
+ interface MethodTab {
26
+ value: PaymentMethod;
27
+ label: string;
28
+ icon: JSX.Element;
29
+ }
30
+
31
+ /**
32
+ * The DOM id of a method's tab, which its panel names in `aria-labelledby`.
33
+ * Built on the step's own `useId`, so two flows on one page (a stories grid,
34
+ * a host's preview) never share one.
35
+ */
36
+ export function methodTabId(idBase: string, method: PaymentMethod): string {
37
+ return `${idBase}-method-tab-${method}`;
38
+ }
39
+
40
+ /** The DOM id of the panel every method tab controls. */
41
+ export function methodPanelId(idBase: string): string {
42
+ return `${idBase}-method-panel`;
43
+ }
44
+
45
+ /** What a tab's label is drawn in: muted when it cannot be chosen, the brand colour when it is. */
46
+ function tabColor(selected: boolean, unavailable: boolean): string {
47
+ if (unavailable) return "text.disabled";
48
+ return selected ? "primary.main" : "text.primary";
49
+ }
50
+
51
+ const TAB_COMMON = {
52
+ display: "inline-flex",
53
+ alignItems: "center",
54
+ justifyContent: "center",
55
+ gap: 1.25,
56
+ font: "inherit",
57
+ fontWeight: 700,
58
+ "&:focus-visible": { outline: "2px solid", outlineColor: "primary.main", outlineOffset: 2 },
59
+ } as const;
60
+
61
+ /** The phone's segment: half of a pill, raised when chosen. */
62
+ function pillSx(selected: boolean) {
63
+ return {
64
+ flex: 1,
65
+ minHeight: 48,
66
+ border: 0,
67
+ borderRadius: 999,
68
+ bgcolor: selected ? "background.paper" : "transparent",
69
+ boxShadow: selected ? 1 : "none",
70
+ } as const;
71
+ }
72
+
73
+ /** The computer's folder tab: open at the bottom into the panel when chosen. */
74
+ function folderSx(selected: boolean, unavailable: boolean) {
75
+ return {
76
+ flex: "0 1 180px",
77
+ minWidth: 0,
78
+ minHeight: 52,
79
+ px: 3,
80
+ mb: "-1px",
81
+ border: "1px solid",
82
+ borderColor: "divider",
83
+ borderTopWidth: selected ? 4 : 1,
84
+ borderTopColor: selected ? "primary.main" : "divider",
85
+ borderBottomColor: selected ? "background.paper" : "divider",
86
+ borderRadius: "8px 8px 0 0",
87
+ bgcolor: selected ? "background.paper" : "action.hover",
88
+ "&:hover": unavailable || selected ? {} : { bgcolor: "action.selected" },
89
+ } as const;
90
+ }
91
+
92
+ function tabSx(shape: MethodTabsShape, selected: boolean, unavailable: boolean) {
93
+ const state = {
94
+ color: tabColor(selected, unavailable),
95
+ cursor: unavailable ? "not-allowed" : "pointer",
96
+ opacity: unavailable ? 0.6 : 1,
97
+ };
98
+ const own = shape === "pill" ? pillSx(selected) : folderSx(selected, unavailable);
99
+ return { ...TAB_COMMON, ...state, ...own };
100
+ }
101
+
102
+ function listSx(shape: MethodTabsShape) {
103
+ if (shape === "pill") {
104
+ return {
105
+ display: "flex",
106
+ p: 0.5,
107
+ gap: 0.5,
108
+ bgcolor: "action.hover",
109
+ borderRadius: 999,
110
+ } as const;
111
+ }
112
+ return {
113
+ display: "flex",
114
+ gap: 0.75,
115
+ pl: 0.25,
116
+ borderBottom: "1px solid",
117
+ borderColor: "divider",
118
+ } as const;
119
+ }
120
+
121
+ /** Where each key moves from position `at` among `count` enabled tabs (the ARIA tabs pattern). */
122
+ const KEY_STEPS: Record<string, (at: number, count: number) => number> = {
123
+ ArrowRight: (at, count) => (at + 1) % count,
124
+ ArrowLeft: (at, count) => (at - 1 + count) % count,
125
+ Home: () => 0,
126
+ End: (_at, count) => count - 1,
127
+ };
128
+
129
+ /** ←/→ move between the enabled tabs and select; Home and End jump. */
130
+ function nextMethod(key: string, enabled: PaymentMethod[], current: PaymentMethod | null): PaymentMethod | null {
131
+ const step = KEY_STEPS[key];
132
+ if (!step || enabled.length === 0) return null;
133
+ const at = current ? enabled.indexOf(current) : -1;
134
+ return enabled[step(at, enabled.length)] ?? null;
135
+ }
136
+
137
+ /** One tab. A store that cannot take a card keeps the tab, disabled, with the reason in its name. */
138
+ function MethodTabButton({
139
+ tab,
140
+ idBase,
141
+ shape,
142
+ selected,
143
+ unavailable,
144
+ focusable,
145
+ reason,
146
+ onSelect,
147
+ }: {
148
+ tab: MethodTab;
149
+ idBase: string;
150
+ shape: MethodTabsShape;
151
+ selected: boolean;
152
+ unavailable: boolean;
153
+ focusable: boolean;
154
+ reason: string;
155
+ onSelect: () => void;
156
+ }): JSX.Element {
157
+ return (
158
+ <Box
159
+ component="button"
160
+ type="button"
161
+ role="tab"
162
+ id={methodTabId(idBase, tab.value)}
163
+ aria-selected={selected}
164
+ aria-controls={methodPanelId(idBase)}
165
+ aria-label={unavailable ? `${tab.label}, ${reason}` : undefined}
166
+ aria-disabled={unavailable || undefined}
167
+ tabIndex={focusable ? 0 : -1}
168
+ data-testid={`checkout-method-${tab.value}`}
169
+ onClick={unavailable ? undefined : onSelect}
170
+ sx={tabSx(shape, selected, unavailable)}
171
+ >
172
+ <Box sx={{ display: "flex" }}>{tab.icon}</Box>
173
+ {tab.label}
174
+ </Box>
175
+ );
176
+ }
177
+
178
+ /** Why the card tab is disabled, under the tabs (a screen reader has it in the tab's name). */
179
+ function UnavailableReason({ shape, text }: { shape: MethodTabsShape; text: string }): JSX.Element {
180
+ const { Text } = useCheckoutComponents();
181
+ return (
182
+ <Box aria-hidden data-testid="method-reason-CARD" sx={{ textAlign: shape === "pill" ? "right" : "left", px: 1 }}>
183
+ <Text variant="caption" size="xs" color="secondary" as="p" style={{ margin: 0 }}>
184
+ {text}
185
+ </Text>
186
+ </Box>
187
+ );
188
+ }
189
+
190
+ export function MethodTabs({
191
+ idBase,
192
+ shape,
193
+ value,
194
+ onChange,
195
+ cardUnavailable = false,
196
+ offered = null,
197
+ }: {
198
+ /** The step's `useId`, which the tab and panel ids are built on. */
199
+ idBase: string;
200
+ shape: MethodTabsShape;
201
+ /** The chosen method, or `null` before the buyer has picked one. */
202
+ value: PaymentMethod | null;
203
+ onChange: (method: PaymentMethod) => void;
204
+ /** True ⇒ the store's active provider has no card path here; disable that tab. */
205
+ cardUnavailable?: boolean;
206
+ /** Methods the store's chain can charge, or `null` while unknown (fail open). */
207
+ offered?: PaymentMethod[] | null;
208
+ }): JSX.Element {
209
+ const copy = useCheckoutCopy().screens.method;
210
+ const tabs: MethodTab[] = [
211
+ { value: "PIX" as const, label: copy.pixLabel, icon: <PixIcon fontSize="small" /> },
212
+ { value: "CARD" as const, label: copy.cardLabel, icon: <CreditCardIcon fontSize="small" /> },
213
+ ].filter((tab) => offered === null || offered.includes(tab.value));
214
+ const isUnavailable = (method: PaymentMethod): boolean => method === "CARD" && cardUnavailable;
215
+ const enabled = tabs.map((tab) => tab.value).filter((method) => !isUnavailable(method));
216
+ // With nothing chosen yet, the first enabled tab is the one Tab lands on.
217
+ const focusable = value && enabled.includes(value) ? value : enabled[0];
218
+
219
+ const onKeyDown = (event: KeyboardEvent<HTMLElement>): void => {
220
+ const next = nextMethod(event.key, enabled, value);
221
+ if (!next) return;
222
+ event.preventDefault();
223
+ // Selecting raises an order, so a key that lands on the tab already
224
+ // chosen (Home on the first, an arrow with one enabled tab) does nothing.
225
+ if (next !== value) onChange(next);
226
+ document.getElementById(methodTabId(idBase, next))?.focus();
227
+ };
228
+
229
+ return (
230
+ <Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
231
+ <Box role="tablist" aria-label={copy.groupLabel} data-testid="checkout-method" onKeyDown={onKeyDown} sx={listSx(shape)}>
232
+ {tabs.map((tab) => (
233
+ <MethodTabButton
234
+ key={tab.value}
235
+ tab={tab}
236
+ idBase={idBase}
237
+ shape={shape}
238
+ selected={tab.value === value && !isUnavailable(tab.value)}
239
+ unavailable={isUnavailable(tab.value)}
240
+ focusable={tab.value === focusable}
241
+ reason={copy.unavailableHere}
242
+ onSelect={() => onChange(tab.value)}
243
+ />
244
+ ))}
245
+ </Box>
246
+ {cardUnavailable && tabs.some((tab) => tab.value === "CARD") ? <UnavailableReason shape={shape} text={copy.unavailableHere} /> : null}
247
+ </Box>
248
+ );
249
+ }
@@ -206,9 +206,19 @@ function Fact({ label, value, testId }: { label: string; value: string; testId?:
206
206
  <Text variant="body" size="sm" as="span" style={{ opacity: 0.75 }}>
207
207
  {label}
208
208
  </Text>
209
- <Text variant="body" size="sm" weight="bold" as="span" data-testid={testId}>
210
- {value}
211
- </Text>
209
+ {/* A long e-mail breaks rather than pushing the receipt off a 320px
210
+ phone: an address has no spaces to wrap at. */}
211
+ <Box sx={{ minWidth: 0, textAlign: "right", overflowWrap: "anywhere" }}>
212
+ <Text
213
+ variant="body"
214
+ size="sm"
215
+ weight="bold"
216
+ as="span"
217
+ data-testid={testId}
218
+ >
219
+ {value}
220
+ </Text>
221
+ </Box>
212
222
  </Box>
213
223
  );
214
224
  }