@12-apps/payments-frontend 1.0.0 → 1.2.1
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 +7 -4
- package/src/__tests__/provider-priority-list.test.tsx +2 -2
- package/src/__tests__/slugged-provider.test.tsx +108 -0
- package/src/card/cpf.ts +42 -0
- package/src/card/fields.tsx +254 -0
- package/src/card/format.ts +103 -0
- package/src/card/index.ts +42 -0
- package/src/card/stripe-token.ts +81 -0
- package/src/card/tokenize.test.ts +194 -0
- package/src/card/tokenize.ts +327 -0
- package/src/card/types.ts +54 -0
- package/src/components/PaymentProviderSettings.tsx +30 -4
- package/src/components/checkout/__tests__/card-3ds-handover.test.tsx +147 -0
- package/src/components/checkout/__tests__/clear-cart-on-paid.test.tsx +64 -0
- package/src/components/checkout/__tests__/hosted-return.test.ts +109 -0
- package/src/components/checkout/__tests__/method-capability.test.tsx +120 -0
- package/src/components/checkout/__tests__/payments-unavailable.test.tsx +53 -0
- package/src/components/checkout/__tests__/save-on-continue.test.tsx +165 -0
- package/src/components/checkout/__tests__/second-host.test.tsx +86 -0
- package/src/components/checkout/buyer-info-form.tsx +138 -0
- package/src/components/checkout/card-view.tsx +128 -0
- package/src/components/checkout/checkout-flow.tsx +201 -0
- package/src/components/checkout/checkout-steps.tsx +366 -0
- package/src/components/checkout/client.ts +157 -0
- package/src/components/checkout/hosted-return.ts +92 -0
- package/src/components/checkout/icons.tsx +61 -0
- package/src/components/checkout/method-capability.ts +69 -0
- package/src/components/checkout/method-picker.tsx +153 -0
- package/src/components/checkout/mui-defaults.tsx +218 -0
- package/src/components/checkout/payer-summary.tsx +81 -0
- package/src/components/checkout/payment-status.tsx +256 -0
- package/src/components/checkout/payments-unavailable.tsx +79 -0
- package/src/components/checkout/pix-view.tsx +179 -0
- package/src/components/checkout/types.ts +223 -0
- package/src/components/checkout/ui.tsx +171 -0
- package/src/components/checkout/use-card-checkout.ts +346 -0
- package/src/components/checkout/use-checkout-controller.ts +252 -0
- package/src/components/checkout/use-payment-polling.ts +93 -0
- package/src/components/settings-state.ts +45 -2
- package/src/index.ts +74 -1
- package/src/result.ts +11 -0
- package/src/components/CheckoutFlow.tsx +0 -169
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the store's active provider lets THIS browser do (FUT-697/698) — the
|
|
3
|
+
* capability reads the Pagamento step derives its picker and card path from.
|
|
4
|
+
* Every rule here fails OPEN for the UI and CLOSED for the money: a missing
|
|
5
|
+
* config renders everything, and the tokenizer/server still refuse a charge
|
|
6
|
+
* they cannot honour.
|
|
7
|
+
*/
|
|
8
|
+
import { useEffect } from "react";
|
|
9
|
+
|
|
10
|
+
import { tokenizerFor, type CardTokenizationConfig } from "../../card";
|
|
11
|
+
|
|
12
|
+
import type { CheckoutProviderConfig, PaymentMethod } from "./types";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Whether the ACTIVE provider gives this browser a card path (FUT-697):
|
|
16
|
+
* a scheme {@link tokenizerFor} knows, with a key (or PagBank's on-demand
|
|
17
|
+
* refresh); a hosted page (`REDIRECT` — the provider's own site takes the
|
|
18
|
+
* card); or server-granted stub mode. `null` config (still loading / fetch
|
|
19
|
+
* blip) fails OPEN for the UI — the tokenizer itself still fails CLOSED.
|
|
20
|
+
*/
|
|
21
|
+
export function cardPathAvailable(config: CheckoutProviderConfig | null): boolean {
|
|
22
|
+
if (!config) return true;
|
|
23
|
+
if (config.mockTokenization) return true;
|
|
24
|
+
if (config.tokenization === "REDIRECT") return true;
|
|
25
|
+
const scheme = config.provider ? tokenizerFor(config.provider) : null;
|
|
26
|
+
if (!scheme) return false;
|
|
27
|
+
return config.publicKey !== null || scheme === "pagbank-sdk";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The tokenization slice the card view consumes. A missing config degrades
|
|
32
|
+
* to the legacy PagBank path — per-order key refresh, NO mock permission — so
|
|
33
|
+
* a transient config failure never blocks a healthy PagBank store and never
|
|
34
|
+
* mints a fake token anywhere.
|
|
35
|
+
*/
|
|
36
|
+
export function cardTokenization(config: CheckoutProviderConfig | null): CardTokenizationConfig {
|
|
37
|
+
if (config) return config;
|
|
38
|
+
return { provider: "pagbank", publicKey: null, mockTokenization: false };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The methods the picker may offer, from the chain's declared capabilities
|
|
43
|
+
* (FUT-698). `null` config — still loading, or a fetch blip — fails OPEN like
|
|
44
|
+
* {@link cardPathAvailable}: the picker renders everything and the server
|
|
45
|
+
* still refuses the charge closed. Narrowed to the methods this checkout can
|
|
46
|
+
* actually drive (PIX and CARD; BOLETO is declared by some adapters but has
|
|
47
|
+
* no buyer UI yet), so a capability the UI cannot honour is never offered.
|
|
48
|
+
*/
|
|
49
|
+
export function offeredMethods(config: CheckoutProviderConfig | null): PaymentMethod[] | null {
|
|
50
|
+
if (!config?.methods) return null;
|
|
51
|
+
return config.methods.filter(
|
|
52
|
+
(method): method is PaymentMethod => method === "PIX" || method === "CARD",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* When the card path is unavailable, PIX is the only choice left — choose it
|
|
58
|
+
* (FUT-697 review): a one-option radiogroup that still demands a tap is a
|
|
59
|
+
* click that buys the buyer nothing.
|
|
60
|
+
*/
|
|
61
|
+
export function usePreselectSoleMethod(
|
|
62
|
+
cardUnavailable: boolean,
|
|
63
|
+
method: PaymentMethod | null,
|
|
64
|
+
onMethodChange: (method: PaymentMethod) => void,
|
|
65
|
+
): void {
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (cardUnavailable && method === null) onMethodChange("PIX");
|
|
68
|
+
}, [cardUnavailable, method, onMethodChange]);
|
|
69
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { Box } from "@mui/material";
|
|
2
|
+
import type { JSX } from "react";
|
|
3
|
+
|
|
4
|
+
import { CreditCardIcon, PixIcon } from "./icons";
|
|
5
|
+
import type { PaymentMethod } from "./types";
|
|
6
|
+
import { useCheckoutComponents } from "./ui";
|
|
7
|
+
|
|
8
|
+
interface MethodOption {
|
|
9
|
+
value: PaymentMethod;
|
|
10
|
+
label: string;
|
|
11
|
+
description: string;
|
|
12
|
+
icon: JSX.Element;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const OPTIONS: MethodOption[] = [
|
|
16
|
+
{ value: "PIX", label: "PIX", description: "Aprovação imediata", icon: <PixIcon /> },
|
|
17
|
+
{ value: "CARD", label: "Cartão", description: "Crédito à vista", icon: <CreditCardIcon /> },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/** Text/icon color for a tile's state — one place, so the ternaries stay flat. */
|
|
21
|
+
function tileColor(unavailable: boolean, selected: boolean, muted: string): string {
|
|
22
|
+
if (unavailable) return "text.disabled";
|
|
23
|
+
return selected ? "primary.main" : muted;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** One selectable (or disabled) payment-method tile of the segmented control. */
|
|
27
|
+
function MethodTile({
|
|
28
|
+
option,
|
|
29
|
+
selected,
|
|
30
|
+
unavailable,
|
|
31
|
+
onSelect,
|
|
32
|
+
}: {
|
|
33
|
+
option: MethodOption;
|
|
34
|
+
selected: boolean;
|
|
35
|
+
unavailable: boolean;
|
|
36
|
+
onSelect: () => void;
|
|
37
|
+
}): JSX.Element {
|
|
38
|
+
const { Text } = useCheckoutComponents();
|
|
39
|
+
const cursor = unavailable ? "not-allowed" : "pointer";
|
|
40
|
+
return (
|
|
41
|
+
<Box
|
|
42
|
+
component="button"
|
|
43
|
+
type="button"
|
|
44
|
+
role="radio"
|
|
45
|
+
aria-checked={selected}
|
|
46
|
+
disabled={unavailable}
|
|
47
|
+
onClick={unavailable ? undefined : onSelect}
|
|
48
|
+
data-testid={`checkout-method-${option.value}`}
|
|
49
|
+
sx={{
|
|
50
|
+
flex: 1,
|
|
51
|
+
minWidth: 0,
|
|
52
|
+
display: "flex",
|
|
53
|
+
alignItems: "center",
|
|
54
|
+
gap: 1,
|
|
55
|
+
p: 1.25,
|
|
56
|
+
cursor,
|
|
57
|
+
borderRadius: 2,
|
|
58
|
+
border: "2px solid",
|
|
59
|
+
borderColor: selected ? "primary.main" : "divider",
|
|
60
|
+
bgcolor: selected ? "action.selected" : "background.paper",
|
|
61
|
+
color: tileColor(unavailable, selected, "text.primary"),
|
|
62
|
+
opacity: unavailable ? 0.6 : 1,
|
|
63
|
+
textAlign: "left",
|
|
64
|
+
transition: "border-color 0.15s, background-color 0.15s",
|
|
65
|
+
"& *": { cursor },
|
|
66
|
+
"&:hover": { borderColor: unavailable ? "divider" : "primary.main" },
|
|
67
|
+
"&:focus-visible": {
|
|
68
|
+
outline: "2px solid",
|
|
69
|
+
outlineColor: "primary.main",
|
|
70
|
+
outlineOffset: 2,
|
|
71
|
+
},
|
|
72
|
+
}}
|
|
73
|
+
>
|
|
74
|
+
<Box sx={{ display: "flex", color: tileColor(unavailable, selected, "text.secondary") }}>
|
|
75
|
+
{option.icon}
|
|
76
|
+
</Box>
|
|
77
|
+
<Box sx={{ minWidth: 0 }}>
|
|
78
|
+
<Text variant="body" size="sm" weight="semibold" as="span">
|
|
79
|
+
{option.label}
|
|
80
|
+
</Text>
|
|
81
|
+
<Text variant="caption" size="xs" color="secondary" as="p">
|
|
82
|
+
{unavailable ? "Indisponível nesta loja" : option.description}
|
|
83
|
+
</Text>
|
|
84
|
+
</Box>
|
|
85
|
+
</Box>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* PIX / Card payment-method picker as a compact two-button segmented control.
|
|
91
|
+
* Each option is a real focusable `<button role="radio">` (keyboard + AT
|
|
92
|
+
* operable), selected-state shown by the button fill itself — so there's no radio
|
|
93
|
+
* dot taking space and the two always sit side by side.
|
|
94
|
+
*
|
|
95
|
+
* `offered` derives the options from the chain's declared capabilities
|
|
96
|
+
* (FUT-698): a provider that cannot PIX must not offer PIX — the charge walk
|
|
97
|
+
* would refuse the method after the buyer already chose it. `null` (config
|
|
98
|
+
* still loading / fetch blip) fails OPEN and offers everything: the server
|
|
99
|
+
* still fails the charge closed.
|
|
100
|
+
*
|
|
101
|
+
* `cardUnavailable` DISABLES the card option with a caption saying so
|
|
102
|
+
* (FUT-697): a provider with no browser card path must not take the buyer to a
|
|
103
|
+
* form that fails at the last click — and a tile that silently vanished would
|
|
104
|
+
* leave an unexplained one-option "choice". The caller preselects the sole
|
|
105
|
+
* remaining method, so no extra tap is demanded either.
|
|
106
|
+
*/
|
|
107
|
+
function visibleOptions(offered: PaymentMethod[] | null): MethodOption[] {
|
|
108
|
+
return OPTIONS.filter((option) => offered === null || offered.includes(option.value));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function MethodPicker({
|
|
112
|
+
value,
|
|
113
|
+
onChange,
|
|
114
|
+
cardUnavailable = false,
|
|
115
|
+
offered = null,
|
|
116
|
+
}: {
|
|
117
|
+
/** The chosen method, or `null` before the buyer has picked one. */
|
|
118
|
+
value: PaymentMethod | null;
|
|
119
|
+
onChange: (method: PaymentMethod) => void;
|
|
120
|
+
/** True ⇒ the store's active provider has no card path here; disable the tile. */
|
|
121
|
+
cardUnavailable?: boolean;
|
|
122
|
+
/** Methods the store's chain can charge, or `null` while unknown (fail open). */
|
|
123
|
+
offered?: PaymentMethod[] | null;
|
|
124
|
+
}): JSX.Element {
|
|
125
|
+
const { Text } = useCheckoutComponents();
|
|
126
|
+
const options = visibleOptions(offered);
|
|
127
|
+
return (
|
|
128
|
+
<Box>
|
|
129
|
+
<Text variant="body" size="sm" weight="bold" as="p" style={{ marginBottom: 8 }}>
|
|
130
|
+
Forma de pagamento
|
|
131
|
+
</Text>
|
|
132
|
+
<Box
|
|
133
|
+
role="radiogroup"
|
|
134
|
+
aria-label="Forma de pagamento"
|
|
135
|
+
data-testid="checkout-method"
|
|
136
|
+
sx={{ display: "flex", gap: 1 }}
|
|
137
|
+
>
|
|
138
|
+
{options.map((option) => {
|
|
139
|
+
const unavailable = option.value === "CARD" && cardUnavailable;
|
|
140
|
+
return (
|
|
141
|
+
<MethodTile
|
|
142
|
+
key={option.value}
|
|
143
|
+
option={option}
|
|
144
|
+
selected={option.value === value && !unavailable}
|
|
145
|
+
unavailable={unavailable}
|
|
146
|
+
onSelect={() => onChange(option.value)}
|
|
147
|
+
/>
|
|
148
|
+
);
|
|
149
|
+
})}
|
|
150
|
+
</Box>
|
|
151
|
+
</Box>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The raw-MUI fallback for every checkout slot (FUT-564, option 3).
|
|
3
|
+
*
|
|
4
|
+
* These are what a host gets when it fills nothing: functional, plain,
|
|
5
|
+
* accessible — built only on `@mui/material`, which is already a peer. They
|
|
6
|
+
* exist so the package works in a host with NO component library (the
|
|
7
|
+
* second-host proof renders the whole flow through these), not to imitate any
|
|
8
|
+
* particular design system's pixels.
|
|
9
|
+
*
|
|
10
|
+
* Test ids are preserved exactly — behavior tests and e2e selectors must find
|
|
11
|
+
* the same hooks whichever side of the seam renders the pixels.
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
Alert as MuiAlert,
|
|
15
|
+
AlertTitle,
|
|
16
|
+
Box,
|
|
17
|
+
Button as MuiButton,
|
|
18
|
+
Checkbox as MuiCheckbox,
|
|
19
|
+
CircularProgress,
|
|
20
|
+
FormControl,
|
|
21
|
+
FormControlLabel,
|
|
22
|
+
FormLabel,
|
|
23
|
+
Radio,
|
|
24
|
+
RadioGroup as MuiRadioGroup,
|
|
25
|
+
Step,
|
|
26
|
+
StepLabel,
|
|
27
|
+
Stepper as MuiStepper,
|
|
28
|
+
TextField,
|
|
29
|
+
Typography,
|
|
30
|
+
} from '@mui/material';
|
|
31
|
+
import type { JSX } from 'react';
|
|
32
|
+
|
|
33
|
+
import type {
|
|
34
|
+
CheckoutActionBarProps,
|
|
35
|
+
CheckoutAlertProps,
|
|
36
|
+
CheckoutButtonProps,
|
|
37
|
+
CheckoutCheckboxProps,
|
|
38
|
+
CheckoutComponents,
|
|
39
|
+
CheckoutInputProps,
|
|
40
|
+
CheckoutLoadingStateProps,
|
|
41
|
+
CheckoutRadioGroupProps,
|
|
42
|
+
CheckoutStepperProps,
|
|
43
|
+
CheckoutTextProps,
|
|
44
|
+
} from './ui';
|
|
45
|
+
|
|
46
|
+
const TEXT_SIZE = { xs: '0.75rem', sm: '0.875rem', md: '1rem' } as const;
|
|
47
|
+
const TEXT_COLOR = {
|
|
48
|
+
primary: 'primary.main',
|
|
49
|
+
secondary: 'text.secondary',
|
|
50
|
+
success: 'success.main',
|
|
51
|
+
danger: 'error.main',
|
|
52
|
+
} as const;
|
|
53
|
+
|
|
54
|
+
function DefaultText({ variant, size = 'md', weight, color, as, style, children, ...rest }: CheckoutTextProps): JSX.Element {
|
|
55
|
+
return (
|
|
56
|
+
<Typography
|
|
57
|
+
component={as ?? 'span'}
|
|
58
|
+
data-testid={rest['data-testid']}
|
|
59
|
+
style={style}
|
|
60
|
+
sx={{
|
|
61
|
+
fontSize: TEXT_SIZE[size],
|
|
62
|
+
fontWeight: weight === 'bold' ? 700 : weight === 'semibold' ? 600 : variant === 'heading' ? 600 : 400,
|
|
63
|
+
color: color ? TEXT_COLOR[color] : 'text.primary',
|
|
64
|
+
fontFamily: variant === 'code' ? 'monospace' : undefined,
|
|
65
|
+
opacity: variant === 'caption' ? 0.8 : undefined,
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
{children}
|
|
69
|
+
</Typography>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const BUTTON_VARIANT = { solid: 'contained', outline: 'outlined', text: 'text' } as const;
|
|
74
|
+
const BUTTON_SIZE = { sm: 'small', md: 'medium', lg: 'large' } as const;
|
|
75
|
+
|
|
76
|
+
function DefaultButton({ variant = 'solid', color = 'primary', size = 'md', fullWidth, disabled, loading, icon, iconPosition = 'left', onClick, dataTestId, children }: CheckoutButtonProps): JSX.Element {
|
|
77
|
+
const adornment = loading ? <CircularProgress size={16} color="inherit" /> : icon;
|
|
78
|
+
return (
|
|
79
|
+
<MuiButton
|
|
80
|
+
variant={BUTTON_VARIANT[variant]}
|
|
81
|
+
color={color === 'neutral' ? 'inherit' : 'primary'}
|
|
82
|
+
size={BUTTON_SIZE[size]}
|
|
83
|
+
fullWidth={fullWidth}
|
|
84
|
+
disabled={disabled || loading}
|
|
85
|
+
startIcon={iconPosition === 'left' ? adornment : undefined}
|
|
86
|
+
endIcon={iconPosition === 'right' ? adornment : undefined}
|
|
87
|
+
onClick={onClick}
|
|
88
|
+
data-testid={dataTestId}
|
|
89
|
+
sx={{ textTransform: 'none' }}
|
|
90
|
+
>
|
|
91
|
+
{children}
|
|
92
|
+
</MuiButton>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function DefaultInput({ label, type = 'text', inputMode, fullWidth, required, autoComplete, placeholder, maxLength, value, error, helperText, endAdornment, onChange, onBlur, ...rest }: CheckoutInputProps): JSX.Element {
|
|
97
|
+
return (
|
|
98
|
+
<TextField
|
|
99
|
+
label={label}
|
|
100
|
+
type={type}
|
|
101
|
+
size="small"
|
|
102
|
+
fullWidth={fullWidth}
|
|
103
|
+
required={required}
|
|
104
|
+
placeholder={placeholder}
|
|
105
|
+
value={value}
|
|
106
|
+
error={error}
|
|
107
|
+
helperText={helperText}
|
|
108
|
+
onChange={onChange}
|
|
109
|
+
onBlur={onBlur}
|
|
110
|
+
slotProps={{
|
|
111
|
+
htmlInput: { inputMode, maxLength, autoComplete, 'data-testid': rest['data-testid'] },
|
|
112
|
+
input: { endAdornment },
|
|
113
|
+
}}
|
|
114
|
+
/>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function DefaultCheckbox({ checked, onChange, label, ...rest }: CheckoutCheckboxProps): JSX.Element {
|
|
119
|
+
return (
|
|
120
|
+
<FormControlLabel
|
|
121
|
+
control={<MuiCheckbox checked={checked} onChange={onChange} data-testid={rest['data-testid']} />}
|
|
122
|
+
label={label}
|
|
123
|
+
/>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const ALERT_SEVERITY = { info: 'info', warning: 'warning', danger: 'error' } as const;
|
|
128
|
+
|
|
129
|
+
function DefaultAlert({ variant = 'info', title, description, showIcon, ...rest }: CheckoutAlertProps): JSX.Element {
|
|
130
|
+
return (
|
|
131
|
+
<MuiAlert severity={ALERT_SEVERITY[variant]} icon={showIcon ? undefined : false} data-testid={rest['data-testid']}>
|
|
132
|
+
{title ? <AlertTitle>{title}</AlertTitle> : null}
|
|
133
|
+
{description}
|
|
134
|
+
</MuiAlert>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function DefaultLoadingState({ message, dataTestId }: CheckoutLoadingStateProps): JSX.Element {
|
|
139
|
+
return (
|
|
140
|
+
<Box data-testid={dataTestId} sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1, py: 2 }}>
|
|
141
|
+
<CircularProgress size={28} />
|
|
142
|
+
{message ? <Typography variant="body2" color="text.secondary">{message}</Typography> : null}
|
|
143
|
+
</Box>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function DefaultStepper({ steps, activeId, completed, ...rest }: CheckoutStepperProps): JSX.Element {
|
|
148
|
+
const activeStep = steps.findIndex((step) => step.id === activeId);
|
|
149
|
+
return (
|
|
150
|
+
<MuiStepper activeStep={activeStep} alternativeLabel data-testid={rest['data-testid']}>
|
|
151
|
+
{steps.map((step) => (
|
|
152
|
+
<Step key={step.id} completed={completed?.has(step.id)}>
|
|
153
|
+
<StepLabel>{step.label}</StepLabel>
|
|
154
|
+
</Step>
|
|
155
|
+
))}
|
|
156
|
+
</MuiStepper>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function DefaultRadioGroup({ label, value, onChange, options, dataTestId }: CheckoutRadioGroupProps): JSX.Element {
|
|
161
|
+
return (
|
|
162
|
+
<FormControl data-testid={dataTestId}>
|
|
163
|
+
{label ? <FormLabel>{label}</FormLabel> : null}
|
|
164
|
+
<MuiRadioGroup value={value} onChange={onChange}>
|
|
165
|
+
{options.map((option) => (
|
|
166
|
+
<FormControlLabel
|
|
167
|
+
key={option.value}
|
|
168
|
+
value={option.value}
|
|
169
|
+
control={<Radio />}
|
|
170
|
+
label={
|
|
171
|
+
<Box>
|
|
172
|
+
<Typography variant="body2">{option.label}</Typography>
|
|
173
|
+
{option.description ? (
|
|
174
|
+
<Typography variant="caption" color="text.secondary">{option.description}</Typography>
|
|
175
|
+
) : null}
|
|
176
|
+
</Box>
|
|
177
|
+
}
|
|
178
|
+
/>
|
|
179
|
+
))}
|
|
180
|
+
</MuiRadioGroup>
|
|
181
|
+
</FormControl>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function DefaultActionBar({ children, dataTestId }: CheckoutActionBarProps): JSX.Element {
|
|
186
|
+
return (
|
|
187
|
+
<Box
|
|
188
|
+
data-testid={dataTestId}
|
|
189
|
+
sx={{
|
|
190
|
+
position: 'sticky',
|
|
191
|
+
bottom: 0,
|
|
192
|
+
zIndex: 2,
|
|
193
|
+
bgcolor: 'background.paper',
|
|
194
|
+
borderTop: '1px solid',
|
|
195
|
+
borderColor: 'divider',
|
|
196
|
+
py: 1.5,
|
|
197
|
+
display: 'flex',
|
|
198
|
+
alignItems: 'center',
|
|
199
|
+
gap: 2,
|
|
200
|
+
}}
|
|
201
|
+
>
|
|
202
|
+
{children}
|
|
203
|
+
</Box>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The complete raw-MUI slot set — what an empty `components` prop means. */
|
|
208
|
+
export const defaultCheckoutComponents: CheckoutComponents = {
|
|
209
|
+
Text: DefaultText,
|
|
210
|
+
Button: DefaultButton,
|
|
211
|
+
Input: DefaultInput,
|
|
212
|
+
Checkbox: DefaultCheckbox,
|
|
213
|
+
Alert: DefaultAlert,
|
|
214
|
+
LoadingState: DefaultLoadingState,
|
|
215
|
+
Stepper: DefaultStepper,
|
|
216
|
+
RadioGroup: DefaultRadioGroup,
|
|
217
|
+
ActionBar: DefaultActionBar,
|
|
218
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Box } from "@mui/material";
|
|
2
|
+
import type { JSX } from "react";
|
|
3
|
+
|
|
4
|
+
import { formatCpf } from "../../card";
|
|
5
|
+
|
|
6
|
+
import { PersonOutlineIcon } from "./icons";
|
|
7
|
+
import { useCheckoutComponents } from "./ui";
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
/** What the CPF line reads, given whether the buyer typed one this time. */
|
|
11
|
+
function cpfLabel(taxId: string | undefined): string {
|
|
12
|
+
return taxId?.trim() ? `CPF ${formatCpf(taxId)}` : "CPF já cadastrado";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* "Pagando como … " — who the charge will name, shown at the top of Pagamento
|
|
17
|
+
* for a buyer whose Dados step was skipped (FUT-465).
|
|
18
|
+
*
|
|
19
|
+
* Skipping that step is right (it existed to collect a CPF the store already
|
|
20
|
+
* has) but it silently removed the last place a buyer could see WHICH identity
|
|
21
|
+
* is about to be charged, or use a different CPF for one purchase. This states
|
|
22
|
+
* both, and "Alterar" reopens Dados so they can type another — the server
|
|
23
|
+
* prefers a CPF that was sent over the saved one, so a typed CPF simply wins.
|
|
24
|
+
*
|
|
25
|
+
* The saved CPF is never shown, because the client never receives it: the
|
|
26
|
+
* profile API answers `hasTaxId` only. A CPF the buyer typed HERE is theirs and
|
|
27
|
+
* already on screen, so that one is echoed back masked.
|
|
28
|
+
*
|
|
29
|
+
* Renders nothing without `onEdit`, which is the flow's way of saying the buyer
|
|
30
|
+
* filled the Dados step in themselves: they have already seen these details on
|
|
31
|
+
* the previous screen, and the header's back link returns them to it.
|
|
32
|
+
*/
|
|
33
|
+
export function PayerSummary({
|
|
34
|
+
name,
|
|
35
|
+
taxId,
|
|
36
|
+
onEdit,
|
|
37
|
+
}: {
|
|
38
|
+
name?: string;
|
|
39
|
+
/** A CPF typed in this checkout, if any — else the saved one is in play. */
|
|
40
|
+
taxId?: string;
|
|
41
|
+
/** Absent ⇒ Dados was NOT skipped, so there is nothing to restate. */
|
|
42
|
+
onEdit?: () => void;
|
|
43
|
+
}): JSX.Element | null {
|
|
44
|
+
const { Button, Text } = useCheckoutComponents();
|
|
45
|
+
if (!onEdit) return null;
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<Box
|
|
49
|
+
data-testid="checkout-payer"
|
|
50
|
+
sx={{
|
|
51
|
+
display: "flex",
|
|
52
|
+
alignItems: "center",
|
|
53
|
+
gap: 1.5,
|
|
54
|
+
p: 1.5,
|
|
55
|
+
border: "1px solid",
|
|
56
|
+
borderColor: "divider",
|
|
57
|
+
borderRadius: 1,
|
|
58
|
+
bgcolor: "background.paper",
|
|
59
|
+
}}
|
|
60
|
+
>
|
|
61
|
+
<PersonOutlineIcon sx={{ fontSize: 20, color: "text.secondary", flex: "0 0 auto" }} />
|
|
62
|
+
<Box sx={{ flex: 1, minWidth: 0 }}>
|
|
63
|
+
<Text variant="body" size="sm" as="p" data-testid="checkout-payer-name">
|
|
64
|
+
{name ? `Pagando como ${name}` : "Pagando com os seus dados salvos"}
|
|
65
|
+
</Text>
|
|
66
|
+
<Text variant="caption" size="xs" color="secondary" as="p" data-testid="checkout-payer-cpf">
|
|
67
|
+
{cpfLabel(taxId)}
|
|
68
|
+
</Text>
|
|
69
|
+
</Box>
|
|
70
|
+
<Button
|
|
71
|
+
variant="text"
|
|
72
|
+
color="primary"
|
|
73
|
+
size="sm"
|
|
74
|
+
onClick={onEdit}
|
|
75
|
+
dataTestId="checkout-payer-edit"
|
|
76
|
+
>
|
|
77
|
+
Alterar
|
|
78
|
+
</Button>
|
|
79
|
+
</Box>
|
|
80
|
+
);
|
|
81
|
+
}
|