@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.
- 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,86 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* The second-host proof (FUT-564 acceptance).
|
|
4
|
+
*
|
|
5
|
+
* This package's dependency graph contains NO `@12-apps/ui` — it is not even
|
|
6
|
+
* resolvable from here — so a green run of this suite IS the proof that a host
|
|
7
|
+
* without this repo's design system can mount the buyer checkout: every pixel
|
|
8
|
+
* below renders through the raw-MUI default slots (`mui-defaults.tsx`),
|
|
9
|
+
* because no `components` prop is passed and no provider sits above the flow.
|
|
10
|
+
*
|
|
11
|
+
* The walk is the buyer's own: Dados (CPF form, consent checkbox, pay bar) →
|
|
12
|
+
* Pagamento (method picker) → the card form, with the order raised through the
|
|
13
|
+
* host port. The load-bearing test ids must survive the default slots too —
|
|
14
|
+
* they are the same hooks the storefront journeys click.
|
|
15
|
+
*/
|
|
16
|
+
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
17
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
18
|
+
|
|
19
|
+
import { CheckoutFlow } from "../checkout-flow";
|
|
20
|
+
import type { CheckoutOrder, CreateOrderRequest, CreateOrderResult } from "../types";
|
|
21
|
+
|
|
22
|
+
afterEach(cleanup);
|
|
23
|
+
|
|
24
|
+
const CARD_ORDER: CheckoutOrder = {
|
|
25
|
+
orderId: "o-2nd-host",
|
|
26
|
+
status: "AWAITING_PAYMENT",
|
|
27
|
+
method: "CARD",
|
|
28
|
+
totalCents: 1000,
|
|
29
|
+
subtotalCents: 1000,
|
|
30
|
+
discountTotalCents: 0,
|
|
31
|
+
appliedDiscounts: [],
|
|
32
|
+
totalLabel: "R$ 10,00",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** A valid CPF (passes the client-side check digits). */
|
|
36
|
+
const VALID_CPF = "52998224725";
|
|
37
|
+
|
|
38
|
+
describe("CheckoutFlow with no design system present (raw-MUI default slots)", () => {
|
|
39
|
+
it("walks Dados → Pagamento → card form through the default slots", async () => {
|
|
40
|
+
const createOrder = vi.fn<(input: CreateOrderRequest) => Promise<CreateOrderResult>>(
|
|
41
|
+
async () => ({ ok: true, data: CARD_ORDER }),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
render(
|
|
45
|
+
<CheckoutFlow
|
|
46
|
+
cart={{ empty: false, totalLabel: "R$ 10,00", totalItems: 1 }}
|
|
47
|
+
createOrder={createOrder}
|
|
48
|
+
onExitToMenu={vi.fn()}
|
|
49
|
+
/>,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// Dados renders through the defaults: stepper, CPF field, consent, pay bar.
|
|
53
|
+
expect(screen.getByTestId("checkout-stepper")).toBeTruthy();
|
|
54
|
+
expect(screen.getByTestId("buyer-save-profile")).toBeTruthy();
|
|
55
|
+
expect(screen.getByTestId("checkout-pay-bar")).toBeTruthy();
|
|
56
|
+
|
|
57
|
+
fireEvent.change(screen.getByTestId("buyer-cpf"), { target: { value: VALID_CPF } });
|
|
58
|
+
fireEvent.click(screen.getByTestId("checkout-continue"));
|
|
59
|
+
|
|
60
|
+
// Pagamento: the method picker, with both in-browser methods on offer.
|
|
61
|
+
await waitFor(() => expect(screen.getByTestId("checkout-method")).toBeTruthy());
|
|
62
|
+
expect(screen.getByTestId("checkout-method-PIX")).toBeTruthy();
|
|
63
|
+
|
|
64
|
+
// Choosing card auto-raises the order through the HOST port…
|
|
65
|
+
fireEvent.click(screen.getByTestId("checkout-method-CARD"));
|
|
66
|
+
await waitFor(() => expect(createOrder).toHaveBeenCalledTimes(1));
|
|
67
|
+
expect(createOrder.mock.calls[0]?.[0]).toMatchObject({ method: "CARD", saveProfile: true });
|
|
68
|
+
|
|
69
|
+
// …and the card form renders, again through the default slots.
|
|
70
|
+
await waitFor(() => expect(screen.getByTestId("card-number")).toBeTruthy());
|
|
71
|
+
expect(screen.getByTestId("card-holder")).toBeTruthy();
|
|
72
|
+
expect(screen.getByTestId("card-pay")).toBeTruthy();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("shows the empty-cart state for a cart with nothing to pay", () => {
|
|
76
|
+
render(
|
|
77
|
+
<CheckoutFlow
|
|
78
|
+
cart={{ empty: true, totalLabel: "R$ 0,00", totalItems: 0 }}
|
|
79
|
+
createOrder={vi.fn()}
|
|
80
|
+
onExitToMenu={vi.fn()}
|
|
81
|
+
/>,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
expect(screen.getByTestId("checkout-empty")).toBeTruthy();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { Box } from "@mui/material";
|
|
2
|
+
import type { JSX } from "react";
|
|
3
|
+
|
|
4
|
+
import { formatCpf, validateCpf } from "../../card";
|
|
5
|
+
|
|
6
|
+
import type { BuyerField, BuyerInfo } from "./types";
|
|
7
|
+
import { useCheckoutComponents } from "./ui";
|
|
8
|
+
|
|
9
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
10
|
+
|
|
11
|
+
/** A server-flagged field error (which field + message), or null. */
|
|
12
|
+
type FieldError = { field: BuyerField; message: string } | null;
|
|
13
|
+
|
|
14
|
+
interface BuyerFieldErrors {
|
|
15
|
+
cpf?: string;
|
|
16
|
+
email?: string;
|
|
17
|
+
name?: string;
|
|
18
|
+
phone?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Per-field error, overlaying any server-flagged error on top of the local format
|
|
23
|
+
* checks — so a failed "pay" attempt highlights the exact input.
|
|
24
|
+
*/
|
|
25
|
+
function deriveErrors(value: BuyerInfo, fieldError: FieldError): BuyerFieldErrors {
|
|
26
|
+
const override = (field: BuyerField): string | undefined =>
|
|
27
|
+
fieldError?.field === field ? fieldError.message : undefined;
|
|
28
|
+
const localEmail =
|
|
29
|
+
value.email && !EMAIL_PATTERN.test(value.email) ? "E-mail inválido." : undefined;
|
|
30
|
+
return {
|
|
31
|
+
cpf: override("cpf") ?? (value.taxId ? validateCpf(value.taxId) : undefined),
|
|
32
|
+
email: override("email") ?? localEmail,
|
|
33
|
+
name: override("name"),
|
|
34
|
+
phone: override("phone"),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Buyer contact at checkout. CPF is REQUIRED (PagBank needs it for the charge);
|
|
40
|
+
* name, e-mail and phone are optional and only used for the receipt. A provided
|
|
41
|
+
* e-mail is format-checked. Fully controlled by the parent checkout state.
|
|
42
|
+
*/
|
|
43
|
+
export function BuyerInfoForm({
|
|
44
|
+
value,
|
|
45
|
+
onChange,
|
|
46
|
+
fieldError,
|
|
47
|
+
}: {
|
|
48
|
+
value: BuyerInfo;
|
|
49
|
+
onChange: (buyer: BuyerInfo) => void;
|
|
50
|
+
fieldError?: FieldError;
|
|
51
|
+
}): JSX.Element {
|
|
52
|
+
const { Input, Text } = useCheckoutComponents();
|
|
53
|
+
const errors = deriveErrors(value, fieldError ?? null);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
|
57
|
+
<Text variant="caption" size="xs" color="secondary" as="p">
|
|
58
|
+
Informe seu CPF (obrigatório para o pagamento). Nome, e-mail e telefone são
|
|
59
|
+
opcionais — usados apenas para o comprovante.
|
|
60
|
+
</Text>
|
|
61
|
+
|
|
62
|
+
<Input
|
|
63
|
+
label="CPF"
|
|
64
|
+
type="text"
|
|
65
|
+
inputMode="numeric"
|
|
66
|
+
variant="outlined"
|
|
67
|
+
size="md"
|
|
68
|
+
fullWidth
|
|
69
|
+
required
|
|
70
|
+
autoComplete="off"
|
|
71
|
+
placeholder="000.000.000-00"
|
|
72
|
+
value={value.taxId ?? ""}
|
|
73
|
+
error={Boolean(errors.cpf)}
|
|
74
|
+
helperText={errors.cpf}
|
|
75
|
+
onChange={(event) => onChange({ ...value, taxId: formatCpf(event.target.value) })}
|
|
76
|
+
data-testid="buyer-cpf"
|
|
77
|
+
/>
|
|
78
|
+
|
|
79
|
+
<OptionalContactFields value={value} onChange={onChange} errors={errors} />
|
|
80
|
+
</Box>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The optional receipt fields (name / e-mail / phone). */
|
|
85
|
+
function OptionalContactFields({
|
|
86
|
+
value,
|
|
87
|
+
onChange,
|
|
88
|
+
errors,
|
|
89
|
+
}: {
|
|
90
|
+
value: BuyerInfo;
|
|
91
|
+
onChange: (buyer: BuyerInfo) => void;
|
|
92
|
+
errors: BuyerFieldErrors;
|
|
93
|
+
}): JSX.Element {
|
|
94
|
+
const { Input } = useCheckoutComponents();
|
|
95
|
+
return (
|
|
96
|
+
<>
|
|
97
|
+
<Input
|
|
98
|
+
label="Nome"
|
|
99
|
+
type="text"
|
|
100
|
+
variant="outlined"
|
|
101
|
+
size="md"
|
|
102
|
+
fullWidth
|
|
103
|
+
autoComplete="name"
|
|
104
|
+
value={value.name ?? ""}
|
|
105
|
+
error={Boolean(errors.name)}
|
|
106
|
+
helperText={errors.name}
|
|
107
|
+
onChange={(event) => onChange({ ...value, name: event.target.value })}
|
|
108
|
+
data-testid="buyer-name"
|
|
109
|
+
/>
|
|
110
|
+
<Input
|
|
111
|
+
label="E-mail"
|
|
112
|
+
type="email"
|
|
113
|
+
variant="outlined"
|
|
114
|
+
size="md"
|
|
115
|
+
fullWidth
|
|
116
|
+
autoComplete="email"
|
|
117
|
+
value={value.email ?? ""}
|
|
118
|
+
error={Boolean(errors.email)}
|
|
119
|
+
helperText={errors.email}
|
|
120
|
+
onChange={(event) => onChange({ ...value, email: event.target.value })}
|
|
121
|
+
data-testid="buyer-email"
|
|
122
|
+
/>
|
|
123
|
+
<Input
|
|
124
|
+
label="Telefone"
|
|
125
|
+
type="tel"
|
|
126
|
+
variant="outlined"
|
|
127
|
+
size="md"
|
|
128
|
+
fullWidth
|
|
129
|
+
autoComplete="tel"
|
|
130
|
+
value={value.phone ?? ""}
|
|
131
|
+
error={Boolean(errors.phone)}
|
|
132
|
+
helperText={errors.phone}
|
|
133
|
+
onChange={(event) => onChange({ ...value, phone: event.target.value })}
|
|
134
|
+
data-testid="buyer-phone"
|
|
135
|
+
/>
|
|
136
|
+
</>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { Box } from "@mui/material";
|
|
2
|
+
import type { JSX } from "react";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
CardPayBar,
|
|
6
|
+
NewCardForm,
|
|
7
|
+
SavedCardsPicker,
|
|
8
|
+
type CardTokenizationConfig,
|
|
9
|
+
} from "../../card";
|
|
10
|
+
|
|
11
|
+
import type { BuyerInfo, CheckoutOrder, OrderStatus } from "./types";
|
|
12
|
+
import { useCheckoutComponents } from "./ui";
|
|
13
|
+
import { useCardCheckout } from "./use-card-checkout";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Post-submit confirmation state, error > timeout > spinner (FUT-191): a poll
|
|
17
|
+
* failure is a danger Alert, the healthy-poll cap elapsing is a warning (the
|
|
18
|
+
* order stays AWAITING server-side and is recoverable by webhook/reconcile/
|
|
19
|
+
* backfill), and otherwise the — now bounded — confirmation spinner shows.
|
|
20
|
+
*/
|
|
21
|
+
function SubmittedState({
|
|
22
|
+
pollError,
|
|
23
|
+
pollTimedOut,
|
|
24
|
+
}: {
|
|
25
|
+
pollError: string | null;
|
|
26
|
+
pollTimedOut: boolean;
|
|
27
|
+
}): JSX.Element {
|
|
28
|
+
const { Alert, LoadingState } = useCheckoutComponents();
|
|
29
|
+
if (pollError) {
|
|
30
|
+
return (
|
|
31
|
+
<Alert
|
|
32
|
+
variant="danger"
|
|
33
|
+
title="Não foi possível confirmar o pagamento"
|
|
34
|
+
description={pollError}
|
|
35
|
+
showIcon
|
|
36
|
+
data-testid="card-poll-error"
|
|
37
|
+
/>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
if (pollTimedOut) {
|
|
41
|
+
return (
|
|
42
|
+
<Alert
|
|
43
|
+
variant="warning"
|
|
44
|
+
title="O pagamento está demorando mais que o esperado"
|
|
45
|
+
description="Você pode aguardar ou verificar seu pedido em instantes — não realize um novo pagamento."
|
|
46
|
+
showIcon
|
|
47
|
+
data-testid="card-poll-timeout"
|
|
48
|
+
/>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
return (
|
|
52
|
+
<LoadingState
|
|
53
|
+
variant="spinner"
|
|
54
|
+
size="md"
|
|
55
|
+
message="Processando pagamento…"
|
|
56
|
+
dataTestId="card-processing"
|
|
57
|
+
/>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Card payment view (FUT-58). Card data is validated + formatted client-side, then
|
|
63
|
+
* tokenized (mock PagBank JS SDK) so the PAN never reaches our server; only the
|
|
64
|
+
* token is charged. Supports reusing a saved card and opting to save a new one for
|
|
65
|
+
* future purchases. On an accepted charge it polls for the async confirmation,
|
|
66
|
+
* then bubbles the terminal status up to the parent. All state + the submit
|
|
67
|
+
* handler live in {@link useCardCheckout}; this component is presentational.
|
|
68
|
+
*
|
|
69
|
+
* Tip: the `4000 0000 0000 0002` test card always declines.
|
|
70
|
+
*/
|
|
71
|
+
export function CardView({
|
|
72
|
+
order,
|
|
73
|
+
buyer = {},
|
|
74
|
+
providerConfig,
|
|
75
|
+
tenantSlug,
|
|
76
|
+
onResolved,
|
|
77
|
+
pollIntervalMs = 2500,
|
|
78
|
+
}: {
|
|
79
|
+
order: CheckoutOrder;
|
|
80
|
+
buyer?: BuyerInfo;
|
|
81
|
+
/** The active provider's tokenization protocol + key (FUT-697). */
|
|
82
|
+
providerConfig: CardTokenizationConfig;
|
|
83
|
+
/** Scopes the saved-card list to cards the store's provider can charge. */
|
|
84
|
+
tenantSlug?: string;
|
|
85
|
+
onResolved: (status: OrderStatus) => void;
|
|
86
|
+
pollIntervalMs?: number;
|
|
87
|
+
}): JSX.Element {
|
|
88
|
+
const { Alert, Text } = useCheckoutComponents();
|
|
89
|
+
const cc = useCardCheckout(order, buyer, providerConfig, onResolved, pollIntervalMs, tenantSlug);
|
|
90
|
+
|
|
91
|
+
if (cc.submitted) {
|
|
92
|
+
return <SubmittedState pollError={cc.pollError} pollTimedOut={cc.pollTimedOut} />;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<Box data-testid="card-view" sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
|
97
|
+
<Text variant="heading" size="md" weight="bold" as="h2">
|
|
98
|
+
Pague com cartão
|
|
99
|
+
</Text>
|
|
100
|
+
|
|
101
|
+
{cc.savedCards.length > 0 ? (
|
|
102
|
+
<SavedCardsPicker
|
|
103
|
+
savedCards={cc.savedCards}
|
|
104
|
+
selection={cc.selection}
|
|
105
|
+
onSelect={cc.setSelection}
|
|
106
|
+
/>
|
|
107
|
+
) : null}
|
|
108
|
+
|
|
109
|
+
{cc.usingNewCard ? (
|
|
110
|
+
<NewCardForm
|
|
111
|
+
card={cc.card}
|
|
112
|
+
fieldErrors={cc.fieldErrors}
|
|
113
|
+
brand={cc.brand}
|
|
114
|
+
saveCard={cc.saveCard}
|
|
115
|
+
setCard={cc.setCard}
|
|
116
|
+
setFieldErrors={cc.setFieldErrors}
|
|
117
|
+
onSaveCardChange={cc.setSaveCard}
|
|
118
|
+
/>
|
|
119
|
+
) : null}
|
|
120
|
+
|
|
121
|
+
{cc.error ? (
|
|
122
|
+
<Alert variant="danger" title="Não foi possível pagar" description={cc.error} showIcon data-testid="card-error" />
|
|
123
|
+
) : null}
|
|
124
|
+
|
|
125
|
+
<CardPayBar totalLabel={order.totalLabel} submitting={cc.submitting} onPay={() => void cc.handlePay()} />
|
|
126
|
+
</Box>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { Box } from "@mui/material";
|
|
2
|
+
import type { JSX, ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import { DadosStep, EmptyCart, PaymentStep } from "./checkout-steps";
|
|
5
|
+
import { ArrowBackIcon } from "./icons";
|
|
6
|
+
import { PaymentStatus } from "./payment-status";
|
|
7
|
+
import type { BuyerInfo, CheckoutProviderConfig, ComandaCheckout } from "./types";
|
|
8
|
+
import { CheckoutComponentsProvider, useCheckoutComponents, type CheckoutComponents } from "./ui";
|
|
9
|
+
import { useCheckoutController, type CheckoutHostPorts } from "./use-checkout-controller";
|
|
10
|
+
|
|
11
|
+
const STEPPER_STEPS = [
|
|
12
|
+
{ id: "dados", label: "Dados" },
|
|
13
|
+
{ id: "payment", label: "Pagamento" },
|
|
14
|
+
{ id: "status", label: "Confirmação" },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
/** What the flow reads off the host's cart — display facts, never money math. */
|
|
18
|
+
export interface CheckoutCartView {
|
|
19
|
+
/** Nothing to check out (cart mode only; a comanda settlement ignores it). */
|
|
20
|
+
empty: boolean;
|
|
21
|
+
totalLabel: string;
|
|
22
|
+
totalItems: number;
|
|
23
|
+
/** Host-rendered discount itemization under the pay-bar total (FUT-246). */
|
|
24
|
+
discountLines?: ReactNode;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The full buyer checkout, mounted by a host in one line (FUT-564): the
|
|
29
|
+
* three-step flow — Dados → Pagamento → Confirmação — with the payment step
|
|
30
|
+
* speaking the store's ACTIVE provider protocol (PagBank PIX + card, Stone
|
|
31
|
+
* card, InfinitePay hosted redirect) against the host-mounted `/api/checkout*`
|
|
32
|
+
* surface. Cart, catalog, comanda and order CREATION stay in the host and
|
|
33
|
+
* arrive through {@link CheckoutHostPorts} + {@link CheckoutCartView};
|
|
34
|
+
* pixels render through the slot contract (`components`, see `ui.tsx`).
|
|
35
|
+
*/
|
|
36
|
+
export interface CheckoutFlowProps extends CheckoutHostPorts {
|
|
37
|
+
/** The host's cart, reduced to what the flow displays. */
|
|
38
|
+
cart: CheckoutCartView;
|
|
39
|
+
defaultBuyer?: BuyerInfo;
|
|
40
|
+
/** Present ⇒ this checkout settles a comanda, not the cart (FUT-comandas). */
|
|
41
|
+
comanda?: ComandaCheckout | null;
|
|
42
|
+
/** The buyer has a CPF saved ⇒ open on Pagamento, skipping Dados (FUT-465). */
|
|
43
|
+
taxIdOnFile?: boolean;
|
|
44
|
+
/** The store's active payment protocol (FUT-697); absent while loading. */
|
|
45
|
+
providerConfig?: CheckoutProviderConfig | null;
|
|
46
|
+
/** Scopes the saved-card list to the store being paid. */
|
|
47
|
+
tenantSlug?: string;
|
|
48
|
+
/** Host content shown on the paid confirmation (the storefront's install invite). */
|
|
49
|
+
confirmationExtra?: ReactNode;
|
|
50
|
+
/** Design-system slots; unfilled slots render the raw-MUI defaults. */
|
|
51
|
+
components?: Partial<CheckoutComponents>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The pay-bar total override when settling a comanda (else the cart's own totals). */
|
|
55
|
+
function comandaTotalOverride(
|
|
56
|
+
comanda: ComandaCheckout | null | undefined,
|
|
57
|
+
): { label: string; items: number } | undefined {
|
|
58
|
+
return comanda ? { label: comanda.totalLabel, items: comanda.totalItems } : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The facts the confirmation screen shows beside the total (FUT-593): which
|
|
63
|
+
* order this was, and where its receipt went. Its own function so the optional
|
|
64
|
+
* order stays out of the flow body, which sits at its complexity ceiling.
|
|
65
|
+
*/
|
|
66
|
+
function confirmationFacts(
|
|
67
|
+
order: { orderId: string } | null,
|
|
68
|
+
buyer: BuyerInfo,
|
|
69
|
+
): { orderId?: string; buyerEmail?: string } {
|
|
70
|
+
return { orderId: order?.orderId, buyerEmail: buyer.email };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The confirmation total: the created order's, else the comanda scope's, else the cart's. */
|
|
74
|
+
function statusTotalLabel(
|
|
75
|
+
order: { totalLabel: string } | null,
|
|
76
|
+
comanda: ComandaCheckout | null | undefined,
|
|
77
|
+
cart: { totalLabel: string },
|
|
78
|
+
): string {
|
|
79
|
+
return order?.totalLabel ?? comanda?.totalLabel ?? cart.totalLabel;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The slim checkout header — the flow's only nav: a step-aware back link
|
|
84
|
+
* (Pagamento → Dados, else the menu).
|
|
85
|
+
*
|
|
86
|
+
* Deliberately nothing else. A "limpar carrinho" trash used to sit on the right
|
|
87
|
+
* of the Dados step; it was removed because emptying the cart is a CART action
|
|
88
|
+
* and belongs where the cart is edited — the drawer, which already offers a
|
|
89
|
+
* per-line remove. Offering it here put a destructive control next to the form
|
|
90
|
+
* a buyer is filling in, one tap away from the field they are typing into.
|
|
91
|
+
*/
|
|
92
|
+
function CheckoutHeader({ step, onBack }: { step: string; onBack: () => void }): JSX.Element {
|
|
93
|
+
const { Button } = useCheckoutComponents();
|
|
94
|
+
return (
|
|
95
|
+
<Box sx={{ minHeight: 36, display: "flex", alignItems: "center", gap: 1 }}>
|
|
96
|
+
<Button variant="text" color="neutral" size="sm" icon={<ArrowBackIcon fontSize="small" />} iconPosition="left" onClick={onBack} dataTestId="checkout-back">
|
|
97
|
+
{step === "dados" ? "Continuar comprando" : "Voltar"}
|
|
98
|
+
</Button>
|
|
99
|
+
</Box>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Compact 50px progress header so the form stays above the fold on mobile. */
|
|
104
|
+
function ProgressHeader({ step, completed }: { step: string; completed: Set<string> }): JSX.Element {
|
|
105
|
+
const { Stepper } = useCheckoutComponents();
|
|
106
|
+
return (
|
|
107
|
+
<Box sx={{ height: 50, display: "flex", flexDirection: "column", justifyContent: "center" }}>
|
|
108
|
+
<Stepper steps={STEPPER_STEPS} activeId={step} completed={completed} orientation="horizontal" size="sm" data-testid="checkout-stepper" />
|
|
109
|
+
</Box>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The 3-step flow body, below the slot provider. Ported from the storefront's
|
|
115
|
+
* app-local checkout (FUT-564): the tenant cart provider lives in the HOST's
|
|
116
|
+
* chrome (reduced to {@link CheckoutCartView} here), and the provider SDK +
|
|
117
|
+
* card public key are loaded lazily by the card path (order-scoped REST).
|
|
118
|
+
*/
|
|
119
|
+
function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Element {
|
|
120
|
+
const { cart, defaultBuyer, comanda, taxIdOnFile = false, providerConfig, tenantSlug, confirmationExtra, ...ports } = props;
|
|
121
|
+
const c = useCheckoutController(ports, defaultBuyer, taxIdOnFile);
|
|
122
|
+
|
|
123
|
+
// A comanda settlement pays already-sent kitchen items — the cart is
|
|
124
|
+
// legitimately empty here, so the empty-cart guard only applies to cart mode.
|
|
125
|
+
//
|
|
126
|
+
// The guard cannot key on the Dados step any more: skipping it (FUT-465) makes
|
|
127
|
+
// Pagamento the first screen, so an empty cart would otherwise reach the
|
|
128
|
+
// method picker. It holds until an order exists — once one does, its lines are
|
|
129
|
+
// snapshotted server-side and the cart no longer speaks for it.
|
|
130
|
+
if (!comanda && cart.empty && !c.order && c.step !== "status") {
|
|
131
|
+
return <EmptyCart onBack={c.goToMenu} />;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<Box sx={{ display: "flex", flexDirection: "column", gap: { xs: 2, sm: 3 } }}>
|
|
136
|
+
<CheckoutHeader step={c.step} onBack={c.back} />
|
|
137
|
+
|
|
138
|
+
<ProgressHeader step={c.step} completed={c.completed} />
|
|
139
|
+
|
|
140
|
+
{c.step === "dados" ? (
|
|
141
|
+
<DadosStep
|
|
142
|
+
buyer={c.buyer}
|
|
143
|
+
onBuyerChange={c.setBuyer}
|
|
144
|
+
saveProfile={c.saveProfile}
|
|
145
|
+
onSaveProfileChange={c.setSaveProfile}
|
|
146
|
+
createError={c.createError}
|
|
147
|
+
errorField={c.errorField}
|
|
148
|
+
onContinue={c.goToPayment}
|
|
149
|
+
cartTotals={cart}
|
|
150
|
+
discountLines={cart.discountLines}
|
|
151
|
+
totalOverride={comandaTotalOverride(comanda)}
|
|
152
|
+
/>
|
|
153
|
+
) : null}
|
|
154
|
+
|
|
155
|
+
{c.step === "payment" ? (
|
|
156
|
+
<PaymentStep
|
|
157
|
+
method={c.method}
|
|
158
|
+
onMethodChange={c.setMethod}
|
|
159
|
+
order={c.order}
|
|
160
|
+
buyer={c.buyer}
|
|
161
|
+
creating={c.creating}
|
|
162
|
+
createError={c.createError}
|
|
163
|
+
errorField={c.errorField}
|
|
164
|
+
onGenerate={(chosen) => void c.startPayment(chosen)}
|
|
165
|
+
onUseEmail={c.payWithEmail}
|
|
166
|
+
// Set only for a skipped-Dados flow (the controller decides); the
|
|
167
|
+
// payer block hides itself when it is absent.
|
|
168
|
+
onEditBuyer={c.editBuyer}
|
|
169
|
+
providerConfig={providerConfig}
|
|
170
|
+
tenantSlug={tenantSlug}
|
|
171
|
+
onResolved={c.handleResolved}
|
|
172
|
+
/>
|
|
173
|
+
) : null}
|
|
174
|
+
|
|
175
|
+
{c.step === "status" ? (
|
|
176
|
+
<PaymentStatus
|
|
177
|
+
status={c.finalStatus}
|
|
178
|
+
totalLabel={statusTotalLabel(c.order, comanda, cart)}
|
|
179
|
+
{...confirmationFacts(c.order, c.buyer)}
|
|
180
|
+
onRetry={c.retry}
|
|
181
|
+
onRegenerate={() => { c.setStep("payment"); void c.startPayment("PIX"); }}
|
|
182
|
+
onBackToMenu={c.goToMenu}
|
|
183
|
+
paidExtra={confirmationExtra}
|
|
184
|
+
/>
|
|
185
|
+
) : null}
|
|
186
|
+
</Box>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The one-line mount: fills the design-system slots, then renders the flow.
|
|
192
|
+
* `<CheckoutFlow cart={...} createOrder={...} onExitToMenu={...} />` is a
|
|
193
|
+
* complete buyer checkout; everything else is optional.
|
|
194
|
+
*/
|
|
195
|
+
export function CheckoutFlow({ components, ...props }: CheckoutFlowProps): JSX.Element {
|
|
196
|
+
return (
|
|
197
|
+
<CheckoutComponentsProvider components={components}>
|
|
198
|
+
<CheckoutFlowBody {...props} />
|
|
199
|
+
</CheckoutComponentsProvider>
|
|
200
|
+
);
|
|
201
|
+
}
|