@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
|
@@ -2,15 +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";
|
|
6
5
|
import { CartChangedBoundary, type OrderRefresh } from "./cart-changed";
|
|
7
6
|
import { useCheckoutCopy } from "./copy-context";
|
|
8
7
|
import { useMethodChoice } from "./method-choice";
|
|
9
|
-
import { MethodPicker } from "./method-picker";
|
|
10
8
|
import { PixStageProvider } from "./pix-stage";
|
|
11
|
-
import {
|
|
9
|
+
import { PayBarTotalProvider, useCheckoutLayout, usePaymentPixStage } from "./payment-step-layout";
|
|
12
10
|
import { PaymentErrorPanel } from "./payment-error-panel";
|
|
13
|
-
import {
|
|
11
|
+
import { PaymentStepFrame, summaryParts } from "./payment-step-frame";
|
|
12
|
+
import type { CheckoutDeliverySummary, CheckoutSummaryLine, OrderSummaryParts } from "./payment-summary";
|
|
14
13
|
import { resolveCheckoutScreen } from "./providers/registry";
|
|
15
14
|
import type {
|
|
16
15
|
BuyerField,
|
|
@@ -124,48 +123,6 @@ export function EmptyCart({ copy, onBack }: { copy: EmptyCartCopy; onBack: () =>
|
|
|
124
123
|
);
|
|
125
124
|
}
|
|
126
125
|
|
|
127
|
-
/** What the step knows about the amount: the charge's, the balance's, the cart's. */
|
|
128
|
-
interface StepMoney {
|
|
129
|
-
order: CheckoutOrder | null;
|
|
130
|
-
cartTotals?: { totalLabel: string; totalItems: number };
|
|
131
|
-
totalOverride?: { label: string; items: number };
|
|
132
|
-
discountLines?: ReactNode;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* The Pagamento step's own money line (FUT-1179).
|
|
137
|
-
*
|
|
138
|
-
* THE RAISED ORDER'S TOTAL WINS, exactly as the confirmation screen's does
|
|
139
|
-
* (`statusTotalLabel`). Once a charge exists, that charge's amount is what is
|
|
140
|
-
* being taken — a repriced cart, or an order resumed from a parked entry, makes
|
|
141
|
-
* the cart's total a different number from the one about to leave the buyer's
|
|
142
|
-
* account. Showing the cart's number above a QR for the order's would be this
|
|
143
|
-
* ticket's own defect in a new place: an amount on screen that is not the
|
|
144
|
-
* amount being charged.
|
|
145
|
-
*
|
|
146
|
-
* The item COUNT stays the cart's — an order carries no line count, and the
|
|
147
|
-
* caption is a description of the basket rather than of the charge.
|
|
148
|
-
*
|
|
149
|
-
* Renders nothing at all when the host supplied no totals and no order — a step
|
|
150
|
-
* that would otherwise print "Total ·" with a blank beside it is worse than the
|
|
151
|
-
* silence this ticket is about. Every host mounting `CheckoutFlow` gets them;
|
|
152
|
-
* only a hand-composed step can be missing them.
|
|
153
|
-
*/
|
|
154
|
-
function PaymentStepTotal({ money }: { money: StepMoney }): JSX.Element | null {
|
|
155
|
-
const { order, cartTotals, totalOverride, discountLines } = money;
|
|
156
|
-
if (!cartTotals && !totalOverride && !order) return null;
|
|
157
|
-
const { label, items } = displayTotals(totalOverride, cartTotals ?? { totalLabel: "", totalItems: 0 });
|
|
158
|
-
return (
|
|
159
|
-
<Box data-testid="payment-step-total" sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 2 }}>
|
|
160
|
-
<PayBarTotal totalLabel={order?.totalLabel ?? label} totalItems={items}>
|
|
161
|
-
{/* Suppressed while settling a balance: those totals come from the
|
|
162
|
-
frozen ticket, not the cart. */}
|
|
163
|
-
{totalOverride ? null : discountLines}
|
|
164
|
-
</PayBarTotal>
|
|
165
|
-
</Box>
|
|
166
|
-
);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
126
|
/** Props for {@link PaymentStep} — step 2's inputs, wired by the flow. */
|
|
170
127
|
interface PaymentStepProps {
|
|
171
128
|
method: PaymentMethod | null;
|
|
@@ -221,6 +178,12 @@ interface PaymentStepProps {
|
|
|
221
178
|
totalOverride?: { label: string; items: number };
|
|
222
179
|
/** The host's own rendered discount itemization, under the total (FUT-246). */
|
|
223
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;
|
|
224
187
|
pollIntervalMs?: number;
|
|
225
188
|
/** Retrying a refused card — preselect no saved instrument (FUT-1145). */
|
|
226
189
|
freshInstrument?: boolean;
|
|
@@ -254,6 +217,9 @@ export function PaymentStep({
|
|
|
254
217
|
cartTotals,
|
|
255
218
|
totalOverride,
|
|
256
219
|
discountLines,
|
|
220
|
+
lines,
|
|
221
|
+
delivery,
|
|
222
|
+
orderNote,
|
|
257
223
|
pollIntervalMs,
|
|
258
224
|
freshInstrument,
|
|
259
225
|
basket,
|
|
@@ -265,23 +231,23 @@ export function PaymentStep({
|
|
|
265
231
|
useAutoRaiseOrder(order, method, creating, createError, onGenerate);
|
|
266
232
|
const pixStage = usePaymentPixStage(choice, onMethodChange);
|
|
267
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
|
+
|
|
268
247
|
return (
|
|
269
248
|
<PixStageProvider stage={pixStage}>
|
|
270
|
-
<
|
|
271
|
-
{
|
|
272
|
-
bar of its own: a second bar would put two "pay" controls on one screen. */}
|
|
273
|
-
<PaymentStepTotal money={{ order, cartTotals, totalOverride, discountLines }} />
|
|
274
|
-
|
|
275
|
-
{/* Self-hiding: renders only for a flow whose Dados step was skipped. */}
|
|
276
|
-
<StepColumn>
|
|
277
|
-
<PayerSummary name={buyer.name} taxId={buyer.taxId} onEdit={onEditBuyer} />
|
|
278
|
-
</StepColumn>
|
|
279
|
-
|
|
280
|
-
{choice.atProvider || pixStage.afterCopy ? null : (
|
|
281
|
-
<MethodPicker value={method} onChange={onMethodChange} cardUnavailable={choice.cardUnavailable} offered={choice.offered} />
|
|
282
|
-
)}
|
|
283
|
-
|
|
284
|
-
<StepColumn full={order?.method === "PIX"}>
|
|
249
|
+
<PayBarTotalProvider value={parts?.totalLabel ?? null}>
|
|
250
|
+
<PaymentStepFrame wide={wide} parts={parts} tabs={tabs} filled={Boolean(method || order)}>
|
|
285
251
|
<CartChangedBoundary {...{ order, creating, onGenerate, refresh }} basket={totalOverride ? undefined : basket}>
|
|
286
252
|
<PaymentBody
|
|
287
253
|
order={order}
|
|
@@ -298,9 +264,6 @@ export function PaymentStep({
|
|
|
298
264
|
validateApplePayMerchant={validateApplePayMerchant}
|
|
299
265
|
/>
|
|
300
266
|
</CartChangedBoundary>
|
|
301
|
-
</StepColumn>
|
|
302
|
-
|
|
303
|
-
<StepColumn>
|
|
304
267
|
<RaisingState
|
|
305
268
|
order={order}
|
|
306
269
|
method={method}
|
|
@@ -311,12 +274,17 @@ export function PaymentStep({
|
|
|
311
274
|
onUseEmail={onUseEmail}
|
|
312
275
|
onGenerate={onGenerate}
|
|
313
276
|
/>
|
|
314
|
-
</
|
|
315
|
-
</
|
|
277
|
+
</PaymentStepFrame>
|
|
278
|
+
</PayBarTotalProvider>
|
|
316
279
|
</PixStageProvider>
|
|
317
280
|
);
|
|
318
281
|
}
|
|
319
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
|
+
|
|
320
288
|
/**
|
|
321
289
|
* What the shell says while the order is being raised, and if raising it
|
|
322
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
}
|
|
@@ -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
|
+
}
|