@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,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
+ }