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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user