Checkout
BetaPago de una inscripción en pasos: datos de facturación, método de pago condicional, resumen pegado, confirmación y estado final.
registry/examples/templates/checkout.tsx"use client";
import { useState } from "react";
import { Alert } from "@/registry/ds/alert";
import { Avatar } from "@/registry/ds/avatar";
import { Chip } from "@/registry/ds/badge";
import { Button } from "@/registry/ds/button";
import { Card, CardBody, CardTitle } from "@/registry/ds/card";
import { Checkbox } from "@/registry/ds/checkbox";
import { Divider } from "@/registry/ds/divider";
import { FileUpload } from "@/registry/ds/file-upload";
import { Footer, FooterBottom, FooterLegal, FooterLink } from "@/registry/ds/footer";
import { CheckCircle, HelpCircle } from "@/registry/ds/icons";
import { Input } from "@/registry/ds/input";
import { Link } from "@/registry/ds/link";
import { Modal, ModalBody, ModalClose, ModalFooter, ModalHeader } from "@/registry/ds/modal";
import { Navbar, NavbarActions, NavbarBrand } from "@/registry/ds/navbar";
import { NumberField } from "@/registry/ds/number-field";
import { Radio, RadioGroup } from "@/registry/ds/radio";
import { Select } from "@/registry/ds/select";
import { Stepper } from "@/registry/ds/stepper";
import { Tooltip, TooltipProvider } from "@/registry/ds/tooltip";
const Logo = () => (
<svg viewBox="0 0 24 24" aria-hidden>
<rect width="24" height="24" rx="6" fill="var(--primary)" />
<path d="M7 12.5l3.5 3.5L17 9" stroke="var(--primary-fg)" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg>
);
const materias = [
{ nombre: "Cálculo II", detalle: "8 créditos · Mañana", precio: 48000 },
{ nombre: "Física I", detalle: "8 créditos · Tarde", precio: 48000 },
{ nombre: "Inglés técnico", detalle: "4 créditos · Virtual", precio: 27000 },
];
const subtotal = materias.reduce((t, m) => t + m.precio, 0);
const beca = subtotal * 0.5;
const total = subtotal - beca;
const pesos = (n: number) => `$ ${n.toLocaleString("es-AR")}`;
const provincias = ["Buenos Aires", "CABA", "Córdoba", "Mendoza", "Santa Fe"].map((p) => ({ label: p, value: p }));
/**
* Template Checkout — Pago de la inscripción en pasos: datos, método de pago condicional, resumen pegado, confirmación en Modal y estado final.
*/
export default function Checkout() {
const [metodo, setMetodo] = useState<"tarjeta" | "transferencia">("tarjeta");
const [cuotas, setCuotas] = useState<number | null>(3);
const [acepta, setAcepta] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [done, setDone] = useState(false);
const steps = [
{ label: "Materias", description: "3 elegidas", onClick: () => {} },
{ label: "Pago", description: "Datos y método" },
{ label: "Confirmación", description: "Comprobante" },
];
return (
<TooltipProvider>
<div className="flex min-h-dvh flex-col bg-bg-subtle">
<Navbar collapse="never">
<NavbarBrand href="#">
<Logo />
Campus
</NavbarBrand>
<NavbarActions>
<Tooltip content="Ayuda con el pago">
<Button variant="text" size="sm" iconOnly={<HelpCircle />} aria-label="Ayuda" />
</Tooltip>
<Avatar name="Julia Arce" size="sm" />
</NavbarActions>
</Navbar>
<main className="flex-1">
<div className="mx-auto w-full max-w-5xl space-y-8 p-4 sm:p-6 lg:p-8">
<header className="space-y-6">
<div>
<h1 className="text-heading-sm font-semibold text-fg">Inscripción al segundo cuatrimestre</h1>
<p className="mt-1 text-sm text-fg-muted">Confirmá tus materias y elegí cómo pagar. Tenés tiempo hasta el 30 de octubre.</p>
</div>
<Stepper steps={steps} current={done ? 3 : 1} label="Pasos de la inscripción" />
</header>
{done ? (
<Card>
<CardBody className="items-center py-10 text-center">
<div className="flex size-14 items-center justify-center rounded-full bg-primary-subtle text-primary-accent">
<CheckCircle className="size-7" />
</div>
<h2 className="text-heading-xs font-semibold text-fg">Inscripción confirmada</h2>
<p className="max-w-md text-sm text-fg-muted">
Te enviamos el comprobante a julia.arce@campus.edu. Las materias ya aparecen en tu panel y el primer vencimiento es el 10 de noviembre.
</p>
<div className="mt-4 flex flex-wrap justify-center gap-3">
<Button variant="stroke">Descargar comprobante</Button>
<Button onClick={() => setDone(false)}>Ir a mis materias</Button>
</div>
</CardBody>
</Card>
) : (
<div className="grid gap-6 lg:grid-cols-5">
<form
className="space-y-6 lg:col-span-3"
onSubmit={(e) => {
e.preventDefault();
setConfirmOpen(true);
}}
>
<Card>
<CardBody className="gap-5">
<CardTitle as="h2">Datos de facturación</CardTitle>
<div className="grid gap-5 sm:grid-cols-2">
<Input label="Nombre y apellido" defaultValue="Julia Arce" autoComplete="name" required />
<Input label="DNI o CUIT" inputMode="numeric" placeholder="Sin puntos" required />
</div>
<Input label="Correo para el comprobante" type="email" defaultValue="julia.arce@campus.edu" autoComplete="email" required />
<div className="grid gap-5 sm:grid-cols-2">
<Select label="Provincia" options={provincias} placeholder="Elegí una" required />
<Input label="Código postal" inputMode="numeric" autoComplete="postal-code" required />
</div>
</CardBody>
</Card>
<Card>
<CardBody className="gap-5">
<CardTitle as="h2">Método de pago</CardTitle>
<RadioGroup aria-label="Método de pago" value={metodo} onValueChange={(v) => setMetodo(v as typeof metodo)}>
<Radio value="tarjeta" label="Tarjeta de crédito o débito" supporting="Hasta 12 cuotas sin interés con bancos adheridos." />
<Radio value="transferencia" label="Transferencia bancaria" supporting="Se acredita en 48 h hábiles. Subís el comprobante acá." />
</RadioGroup>
{metodo === "tarjeta" ? (
<div className="space-y-5 rounded-lg border border-border-subtle bg-bg-subtle p-4">
<Input label="Número de tarjeta" inputMode="numeric" autoComplete="cc-number" placeholder="0000 0000 0000 0000" required />
<div className="grid gap-5 sm:grid-cols-3">
<Input label="Nombre en la tarjeta" autoComplete="cc-name" required className="sm:col-span-1" />
<Input label="Vencimiento" placeholder="MM/AA" inputMode="numeric" autoComplete="cc-exp" required />
<Input label="Código de seguridad" inputMode="numeric" autoComplete="cc-csc" placeholder="CVC" required />
</div>
<NumberField
label="Cuotas"
value={cuotas}
onValueChange={setCuotas}
min={1}
max={12}
hint={cuotas ? `${cuotas} ${cuotas === 1 ? "cuota" : "cuotas"} de ${pesos(Math.round(total / cuotas))} sin interés` : "Entre 1 y 12."}
className="max-w-48"
/>
</div>
) : (
<div className="space-y-5">
<Alert color="info" title="Transferí a la cuenta de la universidad">
CBU 0000003100012345678901 · Alias UNIVERSIDAD.PAGOS · Importe {pesos(total)}. El comprobante puede ser una foto o un PDF.
</Alert>
<FileUpload label="Comprobante de transferencia" accept=".pdf,image/*" maxSize={10 * 1024 * 1024} hint="PDF o imagen, hasta 10 MB.">
o arrastralo acá
</FileUpload>
</div>
)}
</CardBody>
</Card>
<Checkbox
checked={acepta}
onCheckedChange={(c) => setAcepta(Boolean(c))}
label={
<>
Leí y acepto el <Link href="#">reglamento académico</Link> y las condiciones de pago.
</>
}
/>
<div className="flex flex-wrap justify-between gap-3">
<Button type="button" variant="stroke">
Volver a materias
</Button>
<Button type="submit" disabled={!acepta}>
Confirmar inscripción
</Button>
</div>
</form>
<aside className="lg:col-span-2">
<Card className="lg:sticky lg:top-6">
<CardBody className="gap-4">
<CardTitle as="h2">Resumen</CardTitle>
<ul className="space-y-3">
{materias.map((m) => (
<li key={m.nombre} className="flex items-start justify-between gap-3 text-sm">
<div>
<p className="font-medium text-fg">{m.nombre}</p>
<p className="text-xs text-fg-muted">{m.detalle}</p>
</div>
<span className="tabular-nums text-fg">{pesos(m.precio)}</span>
</li>
))}
</ul>
<Divider />
<dl className="space-y-2 text-sm">
<div className="flex justify-between text-fg-muted">
<dt>Subtotal</dt>
<dd className="tabular-nums">{pesos(subtotal)}</dd>
</div>
<div className="flex items-center justify-between text-fg-muted">
<dt className="flex items-center gap-2">
Beca
<Chip size="sm" status="success">
50 %
</Chip>
</dt>
<dd className="tabular-nums">− {pesos(beca)}</dd>
</div>
<div className="border-t border-border" />
<div className="flex justify-between text-md font-semibold text-fg">
<dt>Total</dt>
<dd className="tabular-nums">{pesos(total)}</dd>
</div>
</dl>
<p className="text-xs text-fg-subtle">Los importes son por cuatrimestre e incluyen el seguro estudiantil.</p>
</CardBody>
</Card>
</aside>
</div>
)}
</div>
</main>
<Footer compact>
<FooterBottom>
<p>© 2026 Campus · Universidad</p>
<FooterLegal>
<FooterLink href="#">Condiciones de pago</FooterLink>
<FooterLink href="#">Privacidad</FooterLink>
</FooterLegal>
</FooterBottom>
</Footer>
<Modal open={confirmOpen} onOpenChange={setConfirmOpen} size="sm">
<ModalHeader title="¿Confirmás la inscripción?" subtitle={`Vas a pagar ${pesos(total)} ${metodo === "tarjeta" && cuotas ? `en ${cuotas} ${cuotas === 1 ? "cuota" : "cuotas"}` : "por transferencia"}.`} />
<ModalBody>Después de confirmar podés cambiar de comisión, pero no dar de baja materias sin costo hasta el 15 de noviembre.</ModalBody>
<ModalFooter>
<ModalClose>
<Button variant="stroke">Revisar</Button>
</ModalClose>
<Button
onClick={() => {
setConfirmOpen(false);
setDone(true);
window.scrollTo({ top: 0 });
}}
>
Confirmar
</Button>
</ModalFooter>
</Modal>
</div>
</TooltipProvider>
);
}Para probarlo: cambiá el método a transferencia, tocá las cuotas, aceptá el reglamento y confirmá.
Qué incluye
- Stepper arriba, con el paso anterior clickeable.
- Dos Card de formulario: Input
con
inputModeyautoCompletede tarjeta, Select de provincia. - Radio group para el método; con tarjeta aparece el Number field de cuotas con el importe en el hint, con transferencia un Alert con los datos y un File upload para el comprobante.
- Checkbox de aceptación que habilita la
primaria; Modal de confirmación; estado final con
Chip y Divider
en el resumen; Footer
compact.
Decisiones
- El resumen es
stickydesde 1024 px y va a la derecha: el total se ve mientras se completa el formulario. En móvil baja al final. - Los campos de pago cambian con el método, no se muestran todos deshabilitados. Menos ruido y menos errores.
- Las cuotas calculan el importe en el hint (
3 cuotas de $ 20.500): el número solo no alcanza para decidir. - Confirmar está deshabilitado hasta aceptar el reglamento, y el Modal repite el importe y las cuotas antes de cobrar.
- El éxito reemplaza el formulario y el Stepper marca todo completo; desde ahí hay dos salidas: comprobante y materias.
- Navbar sin navegación (
collapse="never"): en un flujo de pago no se invita a irse a otro lado.
Cómo adaptarlo
- Cada card es un paso candidato: si el flujo crece, dividí facturación y pago en dos pantallas y movés el Stepper.
- Los montos están en pesos con
toLocaleString("es-AR"); cambiá la moneda enpesos(). - El resultado de pago real reemplaza el
setDone(true)del Modal.
Instalación
npx shadcn@latest add https://polen.cdc.cool/r/template-checkout.json