AgendaPro v1.0 - multi-tenant SaaS, mobile calendar, public booking

Multi-tenant scheduling SaaS (AgendaPro-equivalent):
- Backend: Node + Express + node:sqlite, multi-tenant (admin/owner/employee),
  versioned migrations, 4 templates (estetica-spa, barberia, clinica, blank).
- Frontend: React 18 + TS + Vite + Tailwind, FullCalendar drag-and-drop,
  Recharts dashboard, mobile-first (day/list views on mobile).
- Features: calendar+services+employees+clients+tickets, dashboard with
  best employee/service, top tickets, top/frequent clients, commissions,
  cancellation policy + no-show tracking, public online booking (/b/:slug),
  cash register (cierre de caja), reminders/notifications center.
- Verification: 65/65 e2e API, 58/58 visual (0 overflow, 0 console errors),
  typecheck clean, vite build OK.
This commit is contained in:
AgendaPro Dev
2026-07-25 13:45:53 -06:00
commit e8d2435cc2
63 changed files with 16539 additions and 0 deletions
+862
View File
@@ -0,0 +1,862 @@
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Sparkles,
Clock,
CalendarDays,
User,
Check,
ChevronRight,
ChevronLeft,
Phone,
MapPin,
CalendarCheck,
PartyPopper,
AlertCircle,
Mail,
} from "lucide-react";
import {
getBusiness,
getSlots,
book,
type PublicService,
type PublicSlot,
type BookResponse,
} from "../../lib/publicApi";
import { Spinner } from "../../components/ui";
import { cn, formatCurrency, formatDate, formatTime } from "../../lib/format";
function todayStr() {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
const STEPS = [
{ n: 1, label: "Servicio" },
{ n: 2, label: "Especialista" },
{ n: 3, label: "Horario" },
{ n: 4, label: "Datos" },
];
export default function BookingPage() {
const { slug } = useParams<{ slug: string }>();
const [step, setStep] = useState(1);
const [serviceId, setServiceId] = useState<number | null>(null);
const [employeeId, setEmployeeId] = useState<number | null>(null);
const [selectedDate, setSelectedDate] = useState<string>(todayStr());
const [selectedSlot, setSelectedSlot] = useState<PublicSlot | null>(null);
const [client, setClient] = useState({ name: "", phone: "", email: "", notes: "" });
const [result, setResult] = useState<BookResponse | null>(null);
const businessQuery = useQuery({
queryKey: ["public", "business", slug],
queryFn: () => getBusiness(slug!),
enabled: !!slug,
retry: 1,
});
const data = businessQuery.data;
const business = data?.business;
const services = data?.services ?? [];
const employees = data?.employees ?? [];
const selectedService = useMemo<PublicService | undefined>(
() => services.find((s) => s.id === serviceId),
[services, serviceId]
);
const slotsQuery = useQuery({
queryKey: ["public", "slots", slug, serviceId, selectedDate, employeeId],
queryFn: () => getSlots(slug!, serviceId!, selectedDate, employeeId ?? undefined),
enabled: step >= 3 && !!serviceId && !!selectedDate && !result,
});
const slots = slotsQuery.data?.slots ?? [];
const bookMut = useMutation({
mutationFn: (payload: Parameters<typeof book>[1]) => book(slug!, payload),
onSuccess: (res) => {
setResult(res);
window.scrollTo({ top: 0, behavior: "smooth" });
},
});
useEffect(() => {
window.scrollTo({ top: 0, behavior: "smooth" });
}, [step]);
const reset = () => {
setStep(1);
setServiceId(null);
setEmployeeId(null);
setSelectedDate(todayStr());
setSelectedSlot(null);
setClient({ name: "", phone: "", email: "", notes: "" });
setResult(null);
bookMut.reset();
};
if (!slug) {
return <CenteredMessage title="Enlace inválido" description="Falta el identificador del negocio." />;
}
if (businessQuery.isLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#f6f7fb]">
<div className="flex flex-col items-center gap-3 text-slate-500">
<Spinner className="h-6 w-6 text-brand-500" />
<span className="text-sm font-medium">Cargando reservas</span>
</div>
</div>
);
}
if (businessQuery.isError || !business || !data) {
return (
<CenteredMessage
title="No encontramos este negocio"
description={businessQuery.error instanceof Error ? businessQuery.error.message : "El enlace puede ser incorrecto o el negocio no está disponible."}
/>
);
}
if (result) {
return <Confirmation result={result} businessName={business.name} onReset={reset} />;
}
if (!business.booking_enabled) {
return (
<div className="flex min-h-screen flex-col bg-[#f6f7fb]">
<TopBar name={business.name} industry={business.industry} step={0} />
<main className="flex flex-1 items-center justify-center px-5 py-10">
<div className="card w-full max-w-md p-8 text-center shadow-card animate-slide-up">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-amber-100 text-amber-600">
<AlertCircle className="h-7 w-7" />
</div>
<h1 className="mt-4 text-xl font-extrabold text-slate-900">Reservas desactivadas</h1>
<p className="mt-2 text-sm text-slate-500">
{business.name} no está recibiendo reservas en línea en este momento.
</p>
{business.phone && (
<a href={`tel:${business.phone}`} className="btn btn-primary mx-auto mt-5">
<Phone className="h-4 w-4" /> Llamar al {business.phone}
</a>
)}
</div>
</main>
</div>
);
}
const goNext = () => setStep((s) => Math.min(4, s + 1));
const goBack = () => setStep((s) => Math.max(1, s - 1));
const pickService = (id: number) => {
if (serviceId !== id) {
setServiceId(id);
setSelectedSlot(null);
}
};
const pickEmployee = (id: number | null) => {
setEmployeeId(id);
setSelectedSlot(null);
};
const pickSlot = (slot: PublicSlot) => {
setSelectedSlot(slot);
setStep(4);
};
const handleBook = () => {
if (!selectedService || !selectedSlot) return;
if (!client.name.trim()) return;
bookMut.mutate({
service_id: selectedService.id,
employee_id: selectedSlot.employee_id,
start_at: selectedSlot.iso,
client: {
name: client.name.trim(),
email: client.email.trim() || undefined,
phone: client.phone.trim() || undefined,
notes: client.notes.trim() || undefined,
},
});
};
const canContinue =
(step === 1 && !!serviceId) ||
step === 2 ||
(step === 3 && !!selectedSlot) ||
(step === 4 && client.name.trim().length > 0);
const currency = business.currency_symbol || "$";
const primaryLabel = step === 4 ? "Confirmar reserva" : "Continuar";
const onPrimary = step === 4 ? handleBook : goNext;
return (
<div className="flex min-h-screen flex-col bg-gradient-to-b from-brand-50/50 via-[#f6f7fb] to-[#f6f7fb]">
<TopBar name={business.name} industry={business.industry} step={step} />
<main className="mx-auto w-full max-w-2xl flex-1 px-4 pb-32 pt-5 sm:px-6 md:pb-28">
{step === 1 && (
<Hero business={business} />
)}
<div className="mt-4">
{step === 1 && (
<StepShell
icon={<Sparkles className="h-4 w-4" />}
title="Elige un servicio"
subtitle="Selecciona el servicio que deseas reservar."
>
<ServiceGrid
services={services}
currency={currency}
selectedId={serviceId}
onPick={pickService}
/>
</StepShell>
)}
{step === 2 && (
<StepShell
icon={<User className="h-4 w-4" />}
title="Elige un especialista"
subtitle="¿Tienes una preferencia? O déjalo en cualquiera."
>
<EmployeeList
employees={employees}
selectedId={employeeId}
onPick={pickEmployee}
/>
</StepShell>
)}
{step === 3 && selectedService && (
<StepShell
icon={<CalendarDays className="h-4 w-4" />}
title="Elige fecha y hora"
subtitle={`${selectedService.name} · ${selectedService.duration_min} min · ${formatCurrency(selectedService.price, currency)}`}
>
<DateTimePicker
date={selectedDate}
onDate={(d) => {
setSelectedDate(d);
setSelectedSlot(null);
}}
min={todayStr()}
slots={slots}
loading={slotsQuery.isLoading}
isError={slotsQuery.isError}
selectedSlot={selectedSlot}
onPick={pickSlot}
/>
</StepShell>
)}
{step === 4 && selectedService && selectedSlot && (
<StepShell
icon={<CalendarCheck className="h-4 w-4" />}
title="Tus datos"
subtitle="Necesitamos algunos datos para confirmar tu cita."
>
<DetailsForm
client={client}
setClient={setClient}
summary={
<>
<SummaryRow label="Servicio" value={selectedService.name} />
<SummaryRow label="Fecha" value={formatDate(selectedSlot.iso, { weekday: "long" })} />
<SummaryRow label="Hora" value={formatTime(selectedSlot.iso)} />
<SummaryRow label="Especialista" value={selectedSlot.employee_name} />
</>
}
/>
</StepShell>
)}
</div>
</main>
<ActionBar
hidden={!!result}
step={step}
goBack={goBack}
onPrimary={onPrimary}
primaryLabel={primaryLabel}
canContinue={canContinue}
busy={bookMut.isPending}
priceLabel={
selectedService ? formatCurrency(selectedService.price, currency) : undefined
}
serviceName={selectedService?.name}
slotLabel={selectedSlot ? `${formatDate(selectedSlot.iso, { weekday: "short", day: "numeric", month: "short" })} · ${selectedSlot.time}` : undefined}
/>
<FooterNote name={business.name} />
</div>
);
}
function TopBar({ name, industry, step }: { name: string; industry: string; step: number }) {
const current = STEPS.find((s) => s.n === step);
return (
<header className="sticky top-0 z-30 border-b border-slate-200/70 bg-white/85 backdrop-blur-md">
<div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-3 px-4 py-3 sm:px-6">
<div className="flex min-w-0 items-center gap-2.5">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-700 text-white shadow-soft">
<Sparkles className="h-4 w-4" />
</div>
<div className="min-w-0">
<div className="truncate text-sm font-extrabold text-slate-900">{name}</div>
<div className="truncate text-[11px] text-slate-500">{industry}</div>
</div>
</div>
{step > 0 && (
<div className="flex items-center gap-1.5">
<span className="hidden text-[11px] font-semibold uppercase tracking-wide text-slate-400 sm:inline">
Paso {step} de 4
</span>
<div className="hidden items-center gap-1 md:flex">
{STEPS.map((s) => (
<div
key={s.n}
className={cn(
"flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-semibold transition-colors",
s.n === step
? "bg-brand-500 text-white"
: s.n < step
? "bg-brand-50 text-brand-700"
: "bg-slate-100 text-slate-400"
)}
>
{s.n < step ? <Check className="h-3 w-3" /> : <span>{s.n}</span>}
<span>{s.label}</span>
</div>
))}
</div>
<div className="flex items-center gap-1.5 rounded-full bg-brand-50 px-3 py-1 text-xs font-bold text-brand-700 md:hidden">
<span>{step}</span>
<span className="font-semibold text-brand-400">/</span>
<span className="text-brand-400">4</span>
<span className="text-brand-300">·</span>
<span>{current?.label}</span>
</div>
</div>
)}
</div>
</header>
);
}
function Hero({ business }: { business: { name: string; industry: string; phone: string | null; address: string | null } }) {
return (
<section className="overflow-hidden rounded-2xl bg-gradient-to-br from-brand-600 via-brand-600 to-brand-800 p-6 text-white shadow-card animate-fade-in sm:p-8">
<div className="relative z-10">
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/15 px-3 py-1 text-xs font-semibold backdrop-blur">
<Sparkles className="h-3 w-3" /> Reservas en línea
</span>
<h1 className="mt-3 text-2xl font-extrabold leading-tight tracking-tight sm:text-3xl">
{business.name}
</h1>
<p className="mt-1.5 max-w-md text-sm text-brand-100">
Reserva tu cita en {business.industry.toLowerCase()} en pocos segundos. Sin llamadas, sin esperas.
</p>
{(business.address || business.phone) && (
<div className="mt-4 flex flex-wrap gap-2 text-xs">
{business.address && (
<span className="inline-flex items-center gap-1.5 rounded-lg bg-white/10 px-2.5 py-1.5 backdrop-blur">
<MapPin className="h-3.5 w-3.5" /> {business.address}
</span>
)}
{business.phone && (
<span className="inline-flex items-center gap-1.5 rounded-lg bg-white/10 px-2.5 py-1.5 backdrop-blur">
<Phone className="h-3.5 w-3.5" /> {business.phone}
</span>
)}
</div>
)}
</div>
</section>
);
}
function StepShell({
icon,
title,
subtitle,
children,
}: {
icon: React.ReactNode;
title: string;
subtitle?: string;
children: React.ReactNode;
}) {
return (
<div className="animate-slide-up">
<div className="mb-4 flex items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-50 text-brand-600">
{icon}
</div>
<div>
<h2 className="text-base font-extrabold text-slate-900 sm:text-lg">{title}</h2>
{subtitle && <p className="text-xs text-slate-500 sm:text-sm">{subtitle}</p>}
</div>
</div>
{children}
</div>
);
}
function ServiceGrid({
services,
currency,
selectedId,
onPick,
}: {
services: PublicService[];
currency: string;
selectedId: number | null;
onPick: (id: number) => void;
}) {
if (services.length === 0) {
return (
<div className="card p-8 text-center text-sm text-slate-500">
Este negocio aún no tiene servicios disponibles para reservar.
</div>
);
}
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{services.map((s) => {
const active = s.id === selectedId;
return (
<button
key={s.id}
type="button"
onClick={() => onPick(s.id)}
className={cn(
"group relative flex flex-col gap-2 rounded-2xl border p-4 text-left transition-all",
active
? "border-brand-500 bg-brand-50/60 shadow-soft ring-2 ring-brand-500/20"
: "border-slate-200 bg-white hover:border-brand-300 hover:shadow-soft"
)}
>
<div className="flex items-start justify-between gap-2">
<span
className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-bold text-white"
style={{ background: s.color || "#3b66ff" }}
>
{s.category}
</span>
{active && (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-brand-500 text-white">
<Check className="h-3 w-3" />
</span>
)}
</div>
<div>
<div className="font-bold text-slate-900">{s.name}</div>
{s.description && (
<p className="mt-0.5 line-clamp-2 text-xs text-slate-500">{s.description}</p>
)}
</div>
<div className="mt-1 flex items-center justify-between">
<span className="inline-flex items-center gap-1 text-xs font-medium text-slate-500">
<Clock className="h-3.5 w-3.5" /> {s.duration_min} min
</span>
<span className="text-base font-extrabold text-brand-700">
{formatCurrency(s.price, currency)}
</span>
</div>
</button>
);
})}
</div>
);
}
function EmployeeList({
employees,
selectedId,
onPick,
}: {
employees: { id: number; name: string; role: string; color: string }[];
selectedId: number | null;
onPick: (id: number | null) => void;
}) {
const anyActive = selectedId === null;
return (
<div className="space-y-2.5">
<button
type="button"
onClick={() => onPick(null)}
className={cn(
"flex w-full items-center gap-3 rounded-2xl border p-3.5 text-left transition-all",
anyActive
? "border-brand-500 bg-brand-50/60 shadow-soft ring-2 ring-brand-500/20"
: "border-slate-200 bg-white hover:border-brand-300"
)}
>
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-brand-500 to-brand-700 text-white">
<Sparkles className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="font-bold text-slate-900">Cualquiera</div>
<div className="text-xs text-slate-500">Asigna al especialista disponible</div>
</div>
{anyActive && (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-brand-500 text-white">
<Check className="h-3 w-3" />
</span>
)}
</button>
{employees.map((e) => {
const active = e.id === selectedId;
return (
<button
key={e.id}
type="button"
onClick={() => onPick(e.id)}
className={cn(
"flex w-full items-center gap-3 rounded-2xl border p-3.5 text-left transition-all",
active
? "border-brand-500 bg-brand-50/60 shadow-soft ring-2 ring-brand-500/20"
: "border-slate-200 bg-white hover:border-brand-300"
)}
>
<div
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white"
style={{ background: e.color || "#3b66ff" }}
>
{e.name.split(" ").filter(Boolean).slice(0, 2).map((p) => p[0]?.toUpperCase()).join("")}
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-bold text-slate-900">{e.name}</div>
<div className="truncate text-xs text-slate-500">{e.role}</div>
</div>
{active && (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-brand-500 text-white">
<Check className="h-3 w-3" />
</span>
)}
</button>
);
})}
</div>
);
}
function DateTimePicker({
date,
onDate,
min,
slots,
loading,
isError,
selectedSlot,
onPick,
}: {
date: string;
onDate: (d: string) => void;
min: string;
slots: PublicSlot[];
loading: boolean;
isError: boolean;
selectedSlot: PublicSlot | null;
onPick: (s: PublicSlot) => void;
}) {
return (
<div className="space-y-4">
<div>
<label className="label flex items-center gap-1.5">
<CalendarDays className="h-3.5 w-3.5 text-slate-400" /> Fecha
</label>
<input
type="date"
className="input"
value={date}
min={min}
onChange={(e) => e.target.value && onDate(e.target.value)}
/>
</div>
<div>
<label className="label flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 text-slate-400" /> Horario disponible
</label>
<div className="card p-4">
{loading && (
<div className="flex items-center justify-center gap-2 py-8 text-slate-400">
<Spinner className="text-brand-500" />
<span className="text-sm font-medium">Buscando horarios</span>
</div>
)}
{!loading && isError && (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<AlertCircle className="h-6 w-6 text-rose-400" />
<p className="text-sm text-slate-500">No pudimos cargar los horarios. Intenta otra fecha.</p>
</div>
)}
{!loading && !isError && slots.length === 0 && (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<CalendarDays className="h-6 w-6 text-slate-300" />
<p className="text-sm font-medium text-slate-600">Sin horarios disponibles</p>
<p className="text-xs text-slate-400">Prueba con otra fecha o especialista.</p>
</div>
)}
{!loading && !isError && slots.length > 0 && (
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
{slots.map((slot) => {
const active = selectedSlot?.iso === slot.iso && selectedSlot?.employee_id === slot.employee_id;
return (
<button
key={`${slot.iso}-${slot.employee_id}`}
type="button"
onClick={() => onPick(slot)}
className={cn(
"rounded-xl border px-2 py-2.5 text-sm font-bold transition-all",
active
? "border-brand-500 bg-brand-500 text-white shadow-soft"
: "border-slate-200 bg-white text-slate-700 hover:border-brand-400 hover:bg-brand-50"
)}
>
{slot.time}
</button>
);
})}
</div>
)}
</div>
</div>
</div>
);
}
function DetailsForm({
client,
setClient,
summary,
}: {
client: { name: string; phone: string; email: string; notes: string };
setClient: (c: { name: string; phone: string; email: string; notes: string }) => void;
summary: React.ReactNode;
}) {
return (
<div className="space-y-4">
<div className="card divide-y divide-slate-100 p-2">{summary}</div>
<div className="card p-4">
<div className="grid gap-3">
<div>
<label className="label">Nombre completo *</label>
<div className="relative">
<User className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
className="input pl-9"
value={client.name}
onChange={(e) => setClient({ ...client, name: e.target.value })}
placeholder="Tu nombre"
autoFocus
/>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="label">Teléfono</label>
<div className="relative">
<Phone className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
className="input pl-9"
inputMode="tel"
value={client.phone}
onChange={(e) => setClient({ ...client, phone: e.target.value })}
placeholder="+52 …"
/>
</div>
</div>
<div>
<label className="label">Correo</label>
<div className="relative">
<Mail className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
type="email"
className="input pl-9"
value={client.email}
onChange={(e) => setClient({ ...client, email: e.target.value })}
placeholder="[email protected]"
/>
</div>
</div>
</div>
<div>
<label className="label">Notas (opcional)</label>
<textarea
className="textarea"
rows={2}
value={client.notes}
onChange={(e) => setClient({ ...client, notes: e.target.value })}
placeholder="Preferencias, alergias, indicaciones…"
/>
</div>
</div>
</div>
</div>
);
}
function SummaryRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between gap-3 px-3 py-2.5">
<span className="text-xs font-semibold uppercase tracking-wide text-slate-400">{label}</span>
<span className="text-right text-sm font-bold text-slate-800">{value}</span>
</div>
);
}
function ActionBar({
hidden,
step,
goBack,
onPrimary,
primaryLabel,
canContinue,
busy,
priceLabel,
serviceName,
slotLabel,
}: {
hidden: boolean;
step: number;
goBack: () => void;
onPrimary: () => void;
primaryLabel: string;
canContinue: boolean;
busy: boolean;
priceLabel?: string;
serviceName?: string;
slotLabel?: string;
}) {
if (hidden) return null;
return (
<div className="sticky bottom-0 z-30 border-t border-slate-200/70 bg-white/90 backdrop-blur-md">
<div className="mx-auto flex w-full max-w-2xl items-center gap-3 px-4 py-3 sm:px-6">
{step > 1 && (
<button onClick={goBack} className="btn btn-ghost shrink-0" type="button">
<ChevronLeft className="h-4 w-4" /> <span className="hidden sm:inline">Atrás</span>
</button>
)}
<div className="min-w-0 flex-1">
{serviceName ? (
<>
<div className="truncate text-sm font-bold text-slate-900">{serviceName}</div>
<div className="truncate text-xs text-slate-500">
{slotLabel ? slotLabel : priceLabel}
</div>
</>
) : (
<div className="text-sm font-medium text-slate-400">
{step === 1 ? "Selecciona un servicio" : "Continúa para reservar"}
</div>
)}
</div>
<button
onClick={onPrimary}
disabled={!canContinue || busy}
className="btn btn-primary shrink-0"
type="button"
>
{busy ? <Spinner /> : <>{primaryLabel} <ChevronRight className="h-4 w-4" /></>}
</button>
</div>
</div>
);
}
function FooterNote({ name }: { name: string }) {
return (
<div className="border-t border-slate-200/60 bg-white/50 px-4 py-3 text-center text-[11px] text-slate-400">
Reserva en línea · {name} · powered by AgendaPro
</div>
);
}
function Confirmation({
result,
businessName,
onReset,
}: {
result: BookResponse;
businessName: string;
onReset: () => void;
}) {
const { appointment } = result;
const currency = result.business.currency_symbol || "$";
return (
<div className="flex min-h-screen flex-col bg-gradient-to-b from-brand-50/60 to-[#f6f7fb]">
<TopBar name={businessName} industry="Reserva confirmada" step={0} />
<main className="flex flex-1 items-center justify-center px-5 py-10">
<div className="w-full max-w-md animate-slide-up">
<div className="card overflow-hidden p-0 text-center shadow-card">
<div className="relative overflow-hidden bg-gradient-to-br from-emerald-500 to-emerald-600 px-6 py-8 text-white">
<div className="absolute -right-10 -top-10 h-40 w-40 rounded-full bg-white/15 blur-2xl" />
<div className="relative z-10 mx-auto flex h-16 w-16 items-center justify-center rounded-2xl bg-white/20 backdrop-blur">
<PartyPopper className="h-8 w-8" />
</div>
<h1 className="relative z-10 mt-3 text-xl font-extrabold">¡Reserva confirmada!</h1>
<p className="relative z-10 mt-1 text-sm text-emerald-50">
Te esperamos en {businessName}.
</p>
</div>
<div className="space-y-1 p-5 text-left">
<SummaryRow label="Servicio" value={appointment.service_name} />
<div className="h-px bg-slate-100" />
<SummaryRow label="Fecha" value={formatDate(appointment.start_at, { weekday: "long" })} />
<div className="h-px bg-slate-100" />
<SummaryRow label="Hora" value={formatTime(appointment.start_at)} />
<div className="h-px bg-slate-100" />
<SummaryRow label="Especialista" value={appointment.employee_name} />
<div className="h-px bg-slate-100" />
<div className="flex items-center justify-between gap-3 px-3 py-3">
<span className="text-xs font-semibold uppercase tracking-wide text-slate-400">Total</span>
<span className="text-lg font-extrabold text-brand-700">
{formatCurrency(appointment.price, currency)}
</span>
</div>
</div>
</div>
<button onClick={onReset} className="btn btn-secondary mt-4 w-full" type="button">
<ChevronLeft className="h-4 w-4" /> Reservar otra cita
</button>
<p className="mt-3 text-center text-xs text-slate-400">
Guarda esta página o toma una captura con los datos de tu cita.
</p>
</div>
</main>
</div>
);
}
function CenteredMessage({ title, description }: { title: string; description: string }) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#f6f7fb] px-5">
<div className="card w-full max-w-md p-8 text-center shadow-card">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-rose-100 text-rose-500">
<AlertCircle className="h-6 w-6" />
</div>
<h1 className="mt-3 text-lg font-extrabold text-slate-900">{title}</h1>
<p className="mt-1.5 text-sm text-slate-500">{description}</p>
</div>
</div>
);
}