@12-apps/payments-frontend 1.0.0 → 1.2.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.
Files changed (42) hide show
  1. package/package.json +7 -4
  2. package/src/__tests__/provider-priority-list.test.tsx +2 -2
  3. package/src/__tests__/slugged-provider.test.tsx +108 -0
  4. package/src/card/cpf.ts +42 -0
  5. package/src/card/fields.tsx +254 -0
  6. package/src/card/format.ts +103 -0
  7. package/src/card/index.ts +42 -0
  8. package/src/card/stripe-token.ts +81 -0
  9. package/src/card/tokenize.test.ts +194 -0
  10. package/src/card/tokenize.ts +327 -0
  11. package/src/card/types.ts +54 -0
  12. package/src/components/PaymentProviderSettings.tsx +30 -4
  13. package/src/components/checkout/__tests__/card-3ds-handover.test.tsx +147 -0
  14. package/src/components/checkout/__tests__/clear-cart-on-paid.test.tsx +64 -0
  15. package/src/components/checkout/__tests__/hosted-return.test.ts +109 -0
  16. package/src/components/checkout/__tests__/method-capability.test.tsx +120 -0
  17. package/src/components/checkout/__tests__/payments-unavailable.test.tsx +53 -0
  18. package/src/components/checkout/__tests__/save-on-continue.test.tsx +165 -0
  19. package/src/components/checkout/__tests__/second-host.test.tsx +86 -0
  20. package/src/components/checkout/buyer-info-form.tsx +138 -0
  21. package/src/components/checkout/card-view.tsx +128 -0
  22. package/src/components/checkout/checkout-flow.tsx +201 -0
  23. package/src/components/checkout/checkout-steps.tsx +366 -0
  24. package/src/components/checkout/client.ts +157 -0
  25. package/src/components/checkout/hosted-return.ts +92 -0
  26. package/src/components/checkout/icons.tsx +61 -0
  27. package/src/components/checkout/method-capability.ts +69 -0
  28. package/src/components/checkout/method-picker.tsx +153 -0
  29. package/src/components/checkout/mui-defaults.tsx +218 -0
  30. package/src/components/checkout/payer-summary.tsx +81 -0
  31. package/src/components/checkout/payment-status.tsx +256 -0
  32. package/src/components/checkout/payments-unavailable.tsx +79 -0
  33. package/src/components/checkout/pix-view.tsx +179 -0
  34. package/src/components/checkout/types.ts +223 -0
  35. package/src/components/checkout/ui.tsx +171 -0
  36. package/src/components/checkout/use-card-checkout.ts +346 -0
  37. package/src/components/checkout/use-checkout-controller.ts +252 -0
  38. package/src/components/checkout/use-payment-polling.ts +93 -0
  39. package/src/components/settings-state.ts +45 -2
  40. package/src/index.ts +74 -1
  41. package/src/result.ts +11 -0
  42. 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
+ }