@12-apps/payments-frontend 3.36.0 → 3.38.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/components/checkout/card-sources.ts +131 -0
- package/src/components/checkout/card-view.tsx +6 -2
- package/src/components/checkout/cart-changed.tsx +50 -18
- package/src/components/checkout/checkout-actions.ts +18 -0
- package/src/components/checkout/checkout-flow.tsx +24 -77
- package/src/components/checkout/google-pay-button.tsx +4 -4
- package/src/components/checkout/list/buyer-inline.tsx +125 -0
- package/src/components/checkout/list/charge-runners.tsx +156 -0
- package/src/components/checkout/list/choice-list.tsx +156 -0
- package/src/components/checkout/list/choice.ts +193 -0
- package/src/components/checkout/list/context.tsx +55 -0
- package/src/components/checkout/list/en-US.ts +61 -0
- package/src/components/checkout/list/list-bar.tsx +186 -0
- package/src/components/checkout/list/list-face.tsx +315 -0
- package/src/components/checkout/list/list-pix.tsx +206 -0
- package/src/components/checkout/list/online-content.tsx +137 -0
- package/src/components/checkout/list/option-row.tsx +123 -0
- package/src/components/checkout/list/payment-list-step.tsx +166 -0
- package/src/components/checkout/list/pt-BR.ts +77 -0
- package/src/components/checkout/list/types.ts +187 -0
- package/src/components/checkout/list/use-capability.ts +53 -0
- package/src/components/checkout/list/use-list-charge.ts +161 -0
- package/src/components/checkout/list/use-list-state.ts +152 -0
- package/src/components/checkout/payment-phase.tsx +119 -0
- package/src/components/checkout/pix-after-copy.tsx +10 -3
- package/src/components/checkout/pix-pane.tsx +33 -22
- package/src/components/checkout/pix-parts.tsx +50 -1
- package/src/components/checkout/pix-view.tsx +53 -20
- package/src/components/checkout/screens-copy.ts +6 -0
- package/src/components/checkout/screens-en-US.ts +1 -0
- package/src/components/checkout/screens-pt-BR.ts +1 -0
- package/src/components/checkout/use-card-checkout.ts +11 -78
- package/src/components/checkout/use-checkout-controller.ts +3 -1
- package/src/flows/create-payment-flows.tsx +7 -0
- package/src/flows/types.ts +7 -0
- package/src/index.ts +4 -5
- package/src/locales.ts +13 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { Box, useTheme } from "@mui/material";
|
|
2
|
+
import type { JSX, KeyboardEvent, MouseEvent, ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import { usePaymentRefresh, usePaymentStale } from "../cart-changed";
|
|
5
|
+
import { useCheckoutCopy } from "../copy-context";
|
|
6
|
+
import { PixAfterCopy } from "../pix-after-copy";
|
|
7
|
+
import { PixQr, StaleVeil } from "../pix-parts";
|
|
8
|
+
import type { PixAfterCopyCopy } from "../screens-copy";
|
|
9
|
+
import type { CheckoutOrder, PixCharge } from "../types";
|
|
10
|
+
import { useCheckoutComponents } from "../ui";
|
|
11
|
+
|
|
12
|
+
import type { PaymentListMode } from "./context";
|
|
13
|
+
import { ListBar, ActionButton, WaitingLine } from "./list-bar";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The Pix, in the one list (FUT-3592 #3): "Gerar Pix" already created the
|
|
17
|
+
* order and the code, so the pane opens on the code itself.
|
|
18
|
+
*
|
|
19
|
+
* Before the copy — the code in a tinted block that copies on a tap (the same
|
|
20
|
+
* as the bar's "Copiar código Pix"), "Válido até …", and two text links: the
|
|
21
|
+
* QR for paying from another phone, and the way back to the list. On a
|
|
22
|
+
* computer the QR stands beside the code instead. After the copy — ADR 0129's
|
|
23
|
+
* face, as today, with the bar saying what it is waiting for.
|
|
24
|
+
*
|
|
25
|
+
* The side-by-side switch is a container query on the pane's own width, never
|
|
26
|
+
* the window's (the 512px column a computer gives the checkout is still wider
|
|
27
|
+
* than any phone's).
|
|
28
|
+
*/
|
|
29
|
+
const WIDE = "@container listpix (min-width: 460px)";
|
|
30
|
+
|
|
31
|
+
/** Select the code and copy it — a tap on it is the bar's button. */
|
|
32
|
+
function selectAll(target: HTMLElement): void {
|
|
33
|
+
try {
|
|
34
|
+
const selection = window.getSelection();
|
|
35
|
+
selection?.removeAllRanges();
|
|
36
|
+
selection?.selectAllChildren(target);
|
|
37
|
+
} catch {
|
|
38
|
+
// Selecting is a courtesy; the copy below is what matters.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function TappableCode({ pix, label, onCopy }: { pix: PixCharge; label: string; onCopy: () => void }): JSX.Element {
|
|
43
|
+
const stale = usePaymentStale();
|
|
44
|
+
const { Text } = useCheckoutComponents();
|
|
45
|
+
const press = (event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) => {
|
|
46
|
+
selectAll(event.currentTarget);
|
|
47
|
+
onCopy();
|
|
48
|
+
};
|
|
49
|
+
// Under the veil the code is not a control at all — no role, no Tab stop,
|
|
50
|
+
// nothing a tap does (ADR 0121: the veil covers it, so it may not be one).
|
|
51
|
+
const control = stale
|
|
52
|
+
? {}
|
|
53
|
+
: {
|
|
54
|
+
role: "button",
|
|
55
|
+
tabIndex: 0,
|
|
56
|
+
"aria-label": label,
|
|
57
|
+
onClick: press,
|
|
58
|
+
onKeyDown: (event: KeyboardEvent<HTMLElement>) => {
|
|
59
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
60
|
+
event.preventDefault();
|
|
61
|
+
press(event);
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
return (
|
|
66
|
+
<StaleVeil stale={stale}>
|
|
67
|
+
<Box
|
|
68
|
+
{...control}
|
|
69
|
+
data-testid="list-pix-code"
|
|
70
|
+
sx={{ cursor: stale ? "default" : "pointer", bgcolor: "action.hover", borderRadius: 2, p: 1.25, wordBreak: "break-all" }}
|
|
71
|
+
>
|
|
72
|
+
<Text variant="code" size="xs" as="span">
|
|
73
|
+
{pix.copyPaste}
|
|
74
|
+
</Text>
|
|
75
|
+
</Box>
|
|
76
|
+
</StaleVeil>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function TextLink({ onClick, testId, children }: { onClick: () => void; testId: string; children: ReactNode }): JSX.Element {
|
|
81
|
+
const { Button } = useCheckoutComponents();
|
|
82
|
+
return (
|
|
83
|
+
<Box sx={{ alignSelf: "flex-start", ml: -1, "& button": { textDecoration: "underline", textUnderlineOffset: (t) => t.spacing(0.375), minHeight: (t) => t.spacing(5) } }}>
|
|
84
|
+
<Button variant="text" color="primary" size="md" onClick={onClick} dataTestId={testId}>
|
|
85
|
+
{children}
|
|
86
|
+
</Button>
|
|
87
|
+
</Box>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function BeforeCopy({ mode, pix, time, onCopy, onChangeMethod, footer }: {
|
|
92
|
+
mode: PaymentListMode;
|
|
93
|
+
pix: PixCharge;
|
|
94
|
+
time: string;
|
|
95
|
+
onCopy: () => void;
|
|
96
|
+
onChangeMethod: () => void;
|
|
97
|
+
footer: ReactNode;
|
|
98
|
+
}): JSX.Element {
|
|
99
|
+
const { Text } = useCheckoutComponents();
|
|
100
|
+
const copy = mode.list.copy.pixFace;
|
|
101
|
+
const qrAlt = useCheckoutCopy().screens.pix.qrAlt;
|
|
102
|
+
// The prototype's 150px QR, on the theme's spacing scale (the QR draws in px).
|
|
103
|
+
const qrSize = Number.parseFloat(useTheme().spacing(18.75));
|
|
104
|
+
const stale = usePaymentStale();
|
|
105
|
+
return (
|
|
106
|
+
<Box sx={{ containerType: "inline-size", containerName: "listpix" }}>
|
|
107
|
+
<Box sx={{ display: "flex", gap: 2, alignItems: "flex-start", flexDirection: "column", [WIDE]: { flexDirection: "row" } }}>
|
|
108
|
+
<Box data-testid="list-pix-qr" sx={{ display: mode.qrOpen ? "block" : "none", alignSelf: "center", flex: "none", [WIDE]: { display: "block", alignSelf: "flex-start" } }}>
|
|
109
|
+
<StaleVeil stale={stale}>
|
|
110
|
+
<PixQr payload={pix.copyPaste} size={qrSize} alt={qrAlt} />
|
|
111
|
+
</StaleVeil>
|
|
112
|
+
</Box>
|
|
113
|
+
<Box sx={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", gap: 1 }}>
|
|
114
|
+
<TappableCode pix={pix} label={copy.copyCode} onCopy={onCopy} />
|
|
115
|
+
<Text variant="body" size="sm" color="secondary" as="p" style={{ margin: 0 }} data-testid="list-pix-valid">
|
|
116
|
+
{copy.validUntil(time)}
|
|
117
|
+
</Text>
|
|
118
|
+
<Box sx={{ [WIDE]: { display: "none" } }}>
|
|
119
|
+
<TextLink onClick={() => mode.setQrOpen(!mode.qrOpen)} testId="list-pix-toggle-qr">
|
|
120
|
+
{mode.qrOpen ? copy.hideQr : copy.showQr}
|
|
121
|
+
</TextLink>
|
|
122
|
+
</Box>
|
|
123
|
+
<TextLink onClick={onChangeMethod} testId="list-pix-change-method">
|
|
124
|
+
{copy.changeMethod}
|
|
125
|
+
</TextLink>
|
|
126
|
+
{footer}
|
|
127
|
+
</Box>
|
|
128
|
+
</Box>
|
|
129
|
+
</Box>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface ListPixFaceProps {
|
|
134
|
+
order: CheckoutOrder;
|
|
135
|
+
pix: PixCharge;
|
|
136
|
+
/** The code's expiry, in the host's locale. */
|
|
137
|
+
time: string;
|
|
138
|
+
afterCopy: boolean;
|
|
139
|
+
words: PixAfterCopyCopy;
|
|
140
|
+
justCopied: boolean;
|
|
141
|
+
/** Copy, then show the after-copy face. */
|
|
142
|
+
onCopy: () => void;
|
|
143
|
+
onCopyAgain: () => void;
|
|
144
|
+
/** Back from the after-copy face to the code, QR open. */
|
|
145
|
+
onShowQr: () => void;
|
|
146
|
+
onPreferCard?: () => void;
|
|
147
|
+
/** The poll's stalled panel, when it has one. */
|
|
148
|
+
footer: ReactNode;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The Pix's bar: the host's hold first (a step reopened above), then — over a
|
|
153
|
+
* code the cart has outgrown — "Atualizar pagamento" (FUT-3535, E20), then
|
|
154
|
+
* the copy before it and the wait after it.
|
|
155
|
+
*/
|
|
156
|
+
function ListPixBar({ mode, afterCopy, onCopy }: { mode: PaymentListMode; afterCopy: boolean; onCopy: () => void }): JSX.Element {
|
|
157
|
+
const copy = mode.list.copy.pixFace;
|
|
158
|
+
const stale = usePaymentStale();
|
|
159
|
+
const refresh = usePaymentRefresh();
|
|
160
|
+
const refreshLabel = useCheckoutCopy().screens.cartChanged.refreshAction;
|
|
161
|
+
const { hold } = mode.list;
|
|
162
|
+
const action = (): JSX.Element => {
|
|
163
|
+
if (hold) return <ActionButton label={hold.action} total={null} disabled onClick={() => undefined} />;
|
|
164
|
+
if (stale && refresh) return <ActionButton label={refreshLabel} total={null} disabled={false} onClick={refresh} />;
|
|
165
|
+
if (afterCopy) return <WaitingLine text={copy.waiting} />;
|
|
166
|
+
return <ActionButton label={copy.copyCode} total={null} disabled={stale} onClick={onCopy} />;
|
|
167
|
+
};
|
|
168
|
+
return (
|
|
169
|
+
<ListBar summary={mode.list.summary} reason={hold?.reason ?? null} live={afterCopy && !hold && !stale ? copy.waiting : null}>
|
|
170
|
+
{action()}
|
|
171
|
+
</ListBar>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Both faces of the list's Pix, and the bar under them. */
|
|
176
|
+
export function ListPixFace(props: ListPixFaceProps & { mode: PaymentListMode }): JSX.Element {
|
|
177
|
+
const { Text } = useCheckoutComponents();
|
|
178
|
+
const { mode, order, pix, afterCopy } = props;
|
|
179
|
+
const copy = mode.list.copy.pixFace;
|
|
180
|
+
return (
|
|
181
|
+
<Box data-testid="list-pix" sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
|
182
|
+
<Text variant="body" size="md" weight="bold" as="p" style={{ margin: 0 }} data-testid="list-pix-title">
|
|
183
|
+
{copy.title(order.totalLabel)}
|
|
184
|
+
</Text>
|
|
185
|
+
{afterCopy ? (
|
|
186
|
+
<PixAfterCopy
|
|
187
|
+
pix={pix}
|
|
188
|
+
words={props.words}
|
|
189
|
+
totalLabel={order.totalLabel}
|
|
190
|
+
deadline={props.time}
|
|
191
|
+
justCopied={props.justCopied}
|
|
192
|
+
onCopyAgain={props.onCopyAgain}
|
|
193
|
+
onShowQr={() => {
|
|
194
|
+
mode.setQrOpen(true);
|
|
195
|
+
props.onShowQr();
|
|
196
|
+
}}
|
|
197
|
+
onPreferCard={props.onPreferCard}
|
|
198
|
+
footer={props.footer}
|
|
199
|
+
/>
|
|
200
|
+
) : (
|
|
201
|
+
<BeforeCopy mode={mode} pix={pix} time={props.time} onCopy={props.onCopy} onChangeMethod={mode.onLeavePix} footer={props.footer} />
|
|
202
|
+
)}
|
|
203
|
+
<ListPixBar mode={mode} afterCopy={afterCopy} onCopy={props.onCopy} />
|
|
204
|
+
</Box>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { Box, Chip } from "@mui/material";
|
|
2
|
+
import { useRef, type JSX, type KeyboardEvent } from "react";
|
|
3
|
+
|
|
4
|
+
import { NEW_CARD, NewCardForm, detectBrand, onlyDigits } from "../../../card";
|
|
5
|
+
import { ChargeFailure } from "../card-view";
|
|
6
|
+
import { UNRESOLVED_CODE } from "../failure-codes";
|
|
7
|
+
import type { BuyerInfo, CheckoutCustomerField } from "../types";
|
|
8
|
+
|
|
9
|
+
import { BuyerInline } from "./buyer-inline";
|
|
10
|
+
import { cardName } from "./choice";
|
|
11
|
+
import type { ListChargeFailure } from "./charge-runners";
|
|
12
|
+
import type { PaymentListCopy } from "./types";
|
|
13
|
+
import type { ListCard } from "./use-list-state";
|
|
14
|
+
|
|
15
|
+
/** Where an arrow, Home or End moves from `at` among `count` chips, or `null` for another key. */
|
|
16
|
+
function nextChip(key: string, at: number, count: number): number | null {
|
|
17
|
+
if (key === "ArrowRight" || key === "ArrowDown") return (at + 1) % count;
|
|
18
|
+
if (key === "ArrowLeft" || key === "ArrowUp") return (at - 1 + count) % count;
|
|
19
|
+
if (key === "Home") return 0;
|
|
20
|
+
if (key === "End") return count - 1;
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The saved cards as chips — "Visa final 4242", "Outro cartão" — the
|
|
26
|
+
* storefront's chip look (the owner's "use the chips system is using today"):
|
|
27
|
+
* MUI's own `Chip`, which the host's theme already dresses, filled in the
|
|
28
|
+
* primary colour when chosen.
|
|
29
|
+
*
|
|
30
|
+
* One radio group, one Tab stop: only the checked chip (or the first) is
|
|
31
|
+
* tabbable, and the arrows, Home and End move AND select, as radios do.
|
|
32
|
+
*/
|
|
33
|
+
function SavedChips({ card, copy }: { card: ListCard; copy: PaymentListCopy }): JSX.Element | null {
|
|
34
|
+
const group = useRef<HTMLDivElement | null>(null);
|
|
35
|
+
if (card.saved.length === 0) return null;
|
|
36
|
+
const options = [...card.saved.map((saved) => ({ id: saved.id, label: cardName(copy, saved) })), { id: NEW_CARD, label: copy.otherCard }];
|
|
37
|
+
const checked = options.findIndex((option) => option.id === card.selection);
|
|
38
|
+
const stop = checked === -1 ? 0 : checked;
|
|
39
|
+
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
40
|
+
const to = nextChip(event.key, stop, options.length);
|
|
41
|
+
const target = to === null ? undefined : options[to];
|
|
42
|
+
if (to === null || !target) return;
|
|
43
|
+
event.preventDefault();
|
|
44
|
+
card.setSelection(target.id);
|
|
45
|
+
group.current?.querySelectorAll<HTMLElement>('[role="radio"]')[to]?.focus();
|
|
46
|
+
};
|
|
47
|
+
return (
|
|
48
|
+
<Box ref={group} role="radiogroup" aria-label={copy.card.label} onKeyDown={onKeyDown} sx={{ display: "flex", flexWrap: "wrap", gap: 1 }}>
|
|
49
|
+
{options.map((option, index) => {
|
|
50
|
+
const on = option.id === card.selection;
|
|
51
|
+
return (
|
|
52
|
+
<Chip
|
|
53
|
+
key={option.id}
|
|
54
|
+
label={option.label}
|
|
55
|
+
clickable
|
|
56
|
+
color={on ? "primary" : "default"}
|
|
57
|
+
variant="filled"
|
|
58
|
+
role="radio"
|
|
59
|
+
aria-checked={on}
|
|
60
|
+
tabIndex={index === stop ? 0 : -1}
|
|
61
|
+
onClick={() => card.setSelection(option.id)}
|
|
62
|
+
data-testid={option.id === NEW_CARD ? "list-card-new" : `list-card-${option.id}`}
|
|
63
|
+
sx={{
|
|
64
|
+
fontWeight: on ? 600 : 400,
|
|
65
|
+
// The keyboard's place among the chips: the rows' own 2px ring (5.1 item 6).
|
|
66
|
+
"&.Mui-focusVisible, &:focus-visible": { outline: (t) => `${t.spacing(0.25)} solid ${t.palette.primary.main}`, outlineOffset: (t) => t.spacing(0.25) },
|
|
67
|
+
}}
|
|
68
|
+
/>
|
|
69
|
+
);
|
|
70
|
+
})}
|
|
71
|
+
</Box>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Whether a chosen online row has anything to open — a row with nothing opens no box (L5). */
|
|
76
|
+
export function opensContent(method: string, asked: readonly CheckoutCustomerField[], failure: ListChargeFailure | null): boolean {
|
|
77
|
+
return method === "CARD" || asked.length > 0 || failure !== null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Move the focus to the card number — the next thing to type once the CPF is complete. */
|
|
81
|
+
function focusCardNumber(scope: HTMLElement | null): void {
|
|
82
|
+
scope?.querySelector<HTMLInputElement>('input[data-testid="card-number"]')?.focus();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* What opens inside a chosen ONLINE option, top to bottom (M11, FN:180-202):
|
|
87
|
+
* the buyer's missing details, then — for the card — the saved-card chips and
|
|
88
|
+
* the card fields, then a refusal of the last charge. No left indent: it all
|
|
89
|
+
* starts where the row's own content starts.
|
|
90
|
+
*/
|
|
91
|
+
export function OnlineContent({
|
|
92
|
+
method,
|
|
93
|
+
asked,
|
|
94
|
+
buyer,
|
|
95
|
+
onBuyerChange,
|
|
96
|
+
card,
|
|
97
|
+
copy,
|
|
98
|
+
provider,
|
|
99
|
+
failure,
|
|
100
|
+
}: {
|
|
101
|
+
method: "PIX" | "CARD" | "GOOGLE_PAY" | "HAND_OFF";
|
|
102
|
+
asked: readonly CheckoutCustomerField[];
|
|
103
|
+
buyer: BuyerInfo;
|
|
104
|
+
onBuyerChange: (buyer: BuyerInfo) => void;
|
|
105
|
+
card: ListCard;
|
|
106
|
+
copy: PaymentListCopy;
|
|
107
|
+
provider: string | null;
|
|
108
|
+
failure: ListChargeFailure | null;
|
|
109
|
+
}): JSX.Element {
|
|
110
|
+
const scope = useRef<HTMLDivElement | null>(null);
|
|
111
|
+
const isCard = method === "CARD";
|
|
112
|
+
return (
|
|
113
|
+
<Box ref={scope} sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
|
114
|
+
<BuyerInline
|
|
115
|
+
asked={asked}
|
|
116
|
+
buyer={buyer}
|
|
117
|
+
onBuyerChange={onBuyerChange}
|
|
118
|
+
copy={copy.buyer}
|
|
119
|
+
provider={provider}
|
|
120
|
+
onComplete={isCard ? () => focusCardNumber(scope.current) : undefined}
|
|
121
|
+
/>
|
|
122
|
+
{isCard ? <SavedChips card={card} copy={copy} /> : null}
|
|
123
|
+
{isCard && card.selection === NEW_CARD ? (
|
|
124
|
+
<NewCardForm
|
|
125
|
+
card={card.card}
|
|
126
|
+
fieldErrors={card.fieldErrors}
|
|
127
|
+
brand={detectBrand(onlyDigits(card.card.number))}
|
|
128
|
+
saveCard={card.saveCard}
|
|
129
|
+
setCard={card.setCard}
|
|
130
|
+
setFieldErrors={card.setFieldErrors}
|
|
131
|
+
onSaveCardChange={card.setSaveCard}
|
|
132
|
+
/>
|
|
133
|
+
) : null}
|
|
134
|
+
{failure ? <ChargeFailure message={failure.message} unresolved={failure.code === UNRESOLVED_CODE} /> : null}
|
|
135
|
+
</Box>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { Box, Radio } from "@mui/material";
|
|
2
|
+
import { alpha, type Theme } from "@mui/material/styles";
|
|
3
|
+
import type { JSX, ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
import { useCheckoutComponents } from "../ui";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One row of the one list (FUT-3591): a radio, its label and hint, and — while
|
|
9
|
+
* chosen — what opens inside it.
|
|
10
|
+
*
|
|
11
|
+
* Chosen, the row takes a tint of the primary colour and a thin primary
|
|
12
|
+
* border; otherwise it is the surface with a neutral one (the prototype's
|
|
13
|
+
* `.opt` / `.opt.on`). What opens inside starts at the row's own padding,
|
|
14
|
+
* under the radio, never indented past it (the owner's "remove this gap").
|
|
15
|
+
*
|
|
16
|
+
* The radio is MUI's own: the slot contract's `RadioGroup` lays out a group of
|
|
17
|
+
* label-only options and has no room for content opened inside one, and MUI
|
|
18
|
+
* is already this package's peer. Every row shares one `name`, so the online
|
|
19
|
+
* and the door options are ONE group to the keyboard and to a screen reader.
|
|
20
|
+
*/
|
|
21
|
+
const chosenTint = (theme: Theme): string => alpha(theme.palette.primary.main, theme.palette.action.selectedOpacity);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The fields opened inside a chosen row stand on the surface, not on its tint
|
|
25
|
+
* (the prototype's `.inp`, `background: var(--surface)`). The floating label
|
|
26
|
+
* keeps no fill of its own: a filled label would stand above the outline as a
|
|
27
|
+
* tab of surface on the tint.
|
|
28
|
+
*/
|
|
29
|
+
const FIELDS_ON_SURFACE = {
|
|
30
|
+
"& .MuiOutlinedInput-root": { bgcolor: "background.paper" },
|
|
31
|
+
// A field's complaint stays on the tint, under the field: at night the error
|
|
32
|
+
// role's lighter tone (MUI dark #f44336 is 4.05:1 on the 16% tint).
|
|
33
|
+
"& .MuiFormHelperText-root.Mui-error": {
|
|
34
|
+
color: (t: Theme) => (t.palette.mode === "dark" ? t.palette.error.light : t.palette.error.main),
|
|
35
|
+
},
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
38
|
+
export function OptionRow({
|
|
39
|
+
name,
|
|
40
|
+
value,
|
|
41
|
+
chosen,
|
|
42
|
+
disabled = false,
|
|
43
|
+
label,
|
|
44
|
+
hint,
|
|
45
|
+
onChoose,
|
|
46
|
+
testId,
|
|
47
|
+
children,
|
|
48
|
+
}: {
|
|
49
|
+
/** The group's name, shared by every row. */
|
|
50
|
+
name: string;
|
|
51
|
+
value: string;
|
|
52
|
+
chosen: boolean;
|
|
53
|
+
disabled?: boolean;
|
|
54
|
+
label: string;
|
|
55
|
+
hint?: string;
|
|
56
|
+
onChoose: () => void;
|
|
57
|
+
testId: string;
|
|
58
|
+
/** Rendered only while chosen. */
|
|
59
|
+
children?: ReactNode;
|
|
60
|
+
}): JSX.Element {
|
|
61
|
+
const { Text } = useCheckoutComponents();
|
|
62
|
+
return (
|
|
63
|
+
<Box
|
|
64
|
+
data-testid={testId}
|
|
65
|
+
data-chosen={chosen ? "true" : "false"}
|
|
66
|
+
sx={{
|
|
67
|
+
border: "1px solid",
|
|
68
|
+
borderColor: chosen ? "primary.main" : "divider",
|
|
69
|
+
bgcolor: chosen ? chosenTint : "background.paper",
|
|
70
|
+
borderRadius: 2.5,
|
|
71
|
+
px: 1.5,
|
|
72
|
+
opacity: disabled ? 0.6 : 1,
|
|
73
|
+
// The keyboard's place in the group, drawn on the whole row (5.1 item 6).
|
|
74
|
+
"&:has(input:focus-visible)": { outline: (t) => `${t.spacing(0.25)} solid ${t.palette.primary.main}`, outlineOffset: (t) => t.spacing(0.25) },
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
<Box
|
|
78
|
+
component="label"
|
|
79
|
+
// 48 + the border = the prototype's 50px row, with or without a hint (L3).
|
|
80
|
+
sx={{ display: "flex", alignItems: "center", gap: 1.5, minHeight: (t) => t.spacing(6), py: 0, cursor: disabled ? "default" : "pointer" }}
|
|
81
|
+
>
|
|
82
|
+
<Radio
|
|
83
|
+
name={name}
|
|
84
|
+
value={value}
|
|
85
|
+
checked={chosen}
|
|
86
|
+
disabled={disabled}
|
|
87
|
+
onChange={onChoose}
|
|
88
|
+
size="small"
|
|
89
|
+
sx={{ p: 0, flex: "none" }}
|
|
90
|
+
inputProps={{ "data-testid": `${testId}-radio` } as Record<string, string>}
|
|
91
|
+
/>
|
|
92
|
+
<Box sx={{ display: "flex", flexDirection: "column", minWidth: 0, flex: 1 }}>
|
|
93
|
+
<Text variant="body" size="md" as="span">
|
|
94
|
+
{label}
|
|
95
|
+
</Text>
|
|
96
|
+
{hint ? (
|
|
97
|
+
<Text variant="body" size="sm" color="secondary" as="span">
|
|
98
|
+
{hint}
|
|
99
|
+
</Text>
|
|
100
|
+
) : null}
|
|
101
|
+
</Box>
|
|
102
|
+
</Box>
|
|
103
|
+
{/* Only a row with something to open draws the box for it — no empty band. */}
|
|
104
|
+
{chosen && children ? (
|
|
105
|
+
<Box data-testid={`${testId}-content`} sx={{ display: "flex", flexDirection: "column", gap: 1.25, pb: 1.5, ...FIELDS_ON_SURFACE }}>
|
|
106
|
+
{children}
|
|
107
|
+
</Box>
|
|
108
|
+
) : null}
|
|
109
|
+
</Box>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* "PAGAR AGORA, PELO APP" / "PAGAR NA ENTREGA" — the theme's overline (small
|
|
115
|
+
* capitals, its own tracking), bold and muted. `id` labels the group under it.
|
|
116
|
+
*/
|
|
117
|
+
export function GroupHeading({ id, children }: { id: string; children: string }): JSX.Element {
|
|
118
|
+
return (
|
|
119
|
+
<Box component="p" id={id} sx={{ m: 0, typography: "overline", lineHeight: 1.5, fontWeight: 700, color: "text.secondary" }}>
|
|
120
|
+
{children}
|
|
121
|
+
</Box>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState, type JSX } from "react";
|
|
2
|
+
|
|
3
|
+
import type { CheckoutBasketIdentity } from "../basket";
|
|
4
|
+
import { CartChangedBoundary } from "../cart-changed";
|
|
5
|
+
import { displayTotals } from "../checkout-totals";
|
|
6
|
+
import { usePaymentPixStage } from "../payment-step-layout";
|
|
7
|
+
import { PixStageProvider } from "../pix-stage";
|
|
8
|
+
import { PixView } from "../pix-view";
|
|
9
|
+
import { HostedLinkScreen } from "../providers/hosted-link";
|
|
10
|
+
import type { BuyerContact, BuyerInfo, CheckoutProviderConfig, PaymentMethod } from "../types";
|
|
11
|
+
import type { useCheckoutController } from "../use-checkout-controller";
|
|
12
|
+
|
|
13
|
+
import { PaymentListProvider } from "./context";
|
|
14
|
+
import { ListBar, WaitingLine } from "./list-bar";
|
|
15
|
+
import { ListFace } from "./list-face";
|
|
16
|
+
import type { ListCapability } from "./use-capability";
|
|
17
|
+
import type { ListCharge } from "./use-list-charge";
|
|
18
|
+
import type { ListState } from "./use-list-state";
|
|
19
|
+
import type { CheckoutPaymentList } from "./types";
|
|
20
|
+
import { useListCapability } from "./use-capability";
|
|
21
|
+
import { useListCharge } from "./use-list-charge";
|
|
22
|
+
import { useListState } from "./use-list-state";
|
|
23
|
+
|
|
24
|
+
type Controller = ReturnType<typeof useCheckoutController>;
|
|
25
|
+
|
|
26
|
+
interface PaymentListStepProps {
|
|
27
|
+
c: Controller;
|
|
28
|
+
list: CheckoutPaymentList;
|
|
29
|
+
cart: { totalLabel: string; totalItems: number; identity?: CheckoutBasketIdentity };
|
|
30
|
+
/** Settling a balance: its totals win, and its basket is no signal. */
|
|
31
|
+
totalOverride?: { label: string; items: number };
|
|
32
|
+
providerConfig: CheckoutProviderConfig | null | undefined;
|
|
33
|
+
tenantSlug: string | undefined;
|
|
34
|
+
taxIdOnFile: boolean;
|
|
35
|
+
/** What the buyer arrived with — the asked fields are decided against it. */
|
|
36
|
+
defaultBuyer: BuyerInfo | undefined;
|
|
37
|
+
saveBuyerContact?: (contact: BuyerContact) => void;
|
|
38
|
+
/** The Dados step's consent sentence — the list asks it beside the details it asks. */
|
|
39
|
+
consentLabel: string;
|
|
40
|
+
pollIntervalMs?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The store's provider name, as its chain head published it — `null` when it did not. */
|
|
44
|
+
function providerName(config: CheckoutProviderConfig | null): string | null {
|
|
45
|
+
return config?.chain?.[0]?.displayName?.trim() || null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** "Abrindo a página da InfinitePay…": the hand-off is under way, the bar says what it waits for. */
|
|
49
|
+
function HandOffFace({ props, method }: { props: PaymentListStepProps; method: PaymentMethod }): JSX.Element {
|
|
50
|
+
const config = props.providerConfig ?? null;
|
|
51
|
+
const { c, list } = props;
|
|
52
|
+
return (
|
|
53
|
+
<>
|
|
54
|
+
<HostedLinkScreen order={null} buyer={c.buyer} config={config} method={method} onResolved={c.handleResolved} />
|
|
55
|
+
<ListBar summary={list.summary}>
|
|
56
|
+
<WaitingLine text={list.copy.handOffWaiting(providerName(config))} />
|
|
57
|
+
</ListBar>
|
|
58
|
+
</>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The hand-off was started, until the raise for it is refused. */
|
|
63
|
+
function useHandingOff(createError: string | null): [boolean, () => void] {
|
|
64
|
+
const [handingOff, setHandingOff] = useState(false);
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (createError) setHandingOff(false);
|
|
67
|
+
}, [createError]);
|
|
68
|
+
return [handingOff, useCallback(() => setHandingOff(true), [])];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** "Prefere pagar com cartão?" — back to the list, with that method chosen. */
|
|
72
|
+
function usePreferMethod(c: Controller, choose: ListState["choose"]): (method: PaymentMethod) => void {
|
|
73
|
+
const { setMethod } = c;
|
|
74
|
+
return useCallback(
|
|
75
|
+
(method: PaymentMethod) => {
|
|
76
|
+
setMethod(method);
|
|
77
|
+
choose({ kind: "online", method: method === "PIX" ? "PIX" : "CARD" });
|
|
78
|
+
},
|
|
79
|
+
[setMethod, choose],
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The list face, with what the step gathered for it. */
|
|
84
|
+
function ListFaceFor({ props, state, charge, cap, onHandOff }: {
|
|
85
|
+
props: PaymentListStepProps;
|
|
86
|
+
state: ListState;
|
|
87
|
+
charge: ListCharge;
|
|
88
|
+
cap: ListCapability;
|
|
89
|
+
onHandOff: () => void;
|
|
90
|
+
}): JSX.Element {
|
|
91
|
+
const { c, cart, totalOverride } = props;
|
|
92
|
+
const config = props.providerConfig ?? null;
|
|
93
|
+
return (
|
|
94
|
+
<ListFace
|
|
95
|
+
c={c}
|
|
96
|
+
list={props.list}
|
|
97
|
+
state={state}
|
|
98
|
+
charge={charge}
|
|
99
|
+
cap={cap}
|
|
100
|
+
config={config}
|
|
101
|
+
total={displayTotals(totalOverride, cart).label}
|
|
102
|
+
provider={providerName(config)}
|
|
103
|
+
taxIdOnFile={props.taxIdOnFile}
|
|
104
|
+
tenantSlug={props.tenantSlug}
|
|
105
|
+
basket={totalOverride ? undefined : cart.identity}
|
|
106
|
+
pollIntervalMs={props.pollIntervalMs ?? 2500}
|
|
107
|
+
saveBuyerContact={props.saveBuyerContact}
|
|
108
|
+
onHandOff={onHandOff}
|
|
109
|
+
/>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The Pagamento step as ONE list (FUT-3591, FUT-3592) — see `./types.ts`.
|
|
115
|
+
*
|
|
116
|
+
* Three faces, chosen from what the controller holds: the raised PIX (its code
|
|
117
|
+
* and the wait), a hand-off on its way to the provider's page, and otherwise
|
|
118
|
+
* the list with its bar. Nothing here raises on a pick; the bar's button is
|
|
119
|
+
* the only way an order is made, and the card's 5-second bar is the buyer's
|
|
120
|
+
* chance to stop it (no confirmation dialog: owner, 2026-10-14).
|
|
121
|
+
*/
|
|
122
|
+
export function PaymentListStep(props: PaymentListStepProps): JSX.Element {
|
|
123
|
+
const { c, list } = props;
|
|
124
|
+
const config = props.providerConfig ?? null;
|
|
125
|
+
const cap = useListCapability(config, list.totalCents);
|
|
126
|
+
const state = useListState({
|
|
127
|
+
config,
|
|
128
|
+
capability: cap,
|
|
129
|
+
tenantSlug: props.tenantSlug,
|
|
130
|
+
fresh: c.freshInstrument,
|
|
131
|
+
arrivedWith: props.defaultBuyer,
|
|
132
|
+
taxIdOnFile: props.taxIdOnFile,
|
|
133
|
+
onChoiceChange: list.onChoiceChange,
|
|
134
|
+
});
|
|
135
|
+
const charge = useListCharge({ ...c, cartTotal: displayTotals(props.totalOverride, props.cart).label });
|
|
136
|
+
const [handingOff, startHandOff] = useHandingOff(c.createError);
|
|
137
|
+
const pixStage = usePaymentPixStage(cap, usePreferMethod(c, state.choose));
|
|
138
|
+
const pix = c.order?.method === "PIX" ? c.order : null;
|
|
139
|
+
const consent = useMemo(
|
|
140
|
+
() => ({ label: props.consentLabel, checked: c.saveProfile, onChange: c.setSaveProfile }),
|
|
141
|
+
[props.consentLabel, c.saveProfile, c.setSaveProfile],
|
|
142
|
+
);
|
|
143
|
+
const face = pix ? (
|
|
144
|
+
<PixView order={pix} onResolved={c.handleResolved} pollIntervalMs={props.pollIntervalMs} />
|
|
145
|
+
) : handingOff ? (
|
|
146
|
+
<HandOffFace props={props} method={c.method ?? "PIX"} />
|
|
147
|
+
) : (
|
|
148
|
+
<ListFaceFor props={props} state={state} charge={charge} cap={cap} onHandOff={startHandOff} />
|
|
149
|
+
);
|
|
150
|
+
return (
|
|
151
|
+
<PaymentListProvider list={list} onLeavePix={c.dropOrder} consent={consent}>
|
|
152
|
+
<PixStageProvider stage={pixStage}>
|
|
153
|
+
<CartChangedBoundary
|
|
154
|
+
order={c.order}
|
|
155
|
+
creating={c.creating}
|
|
156
|
+
onGenerate={(method) => void c.startPayment(method)}
|
|
157
|
+
refresh={c.refresh}
|
|
158
|
+
basket={props.totalOverride ? undefined : props.cart.identity}
|
|
159
|
+
actionInBar={Boolean(pix)}
|
|
160
|
+
>
|
|
161
|
+
{face}
|
|
162
|
+
</CartChangedBoundary>
|
|
163
|
+
</PixStageProvider>
|
|
164
|
+
</PaymentListProvider>
|
|
165
|
+
);
|
|
166
|
+
}
|