Dynamic 3/4-step wizard (omits specialist step when auto-assign on), always-visible MonthCalendar, 2-column desktop layout with morning/afternoon slot grouping, confirmation reasons badges. Settings: auto-assign checkbox + business working-hours editor. Employee modal: specialties, efficiency, per-employee working hours. Fix icon/placeholder overlap app-wide via @layer components + pl-10.
936 lines
33 KiB
TypeScript
936 lines
33 KiB
TypeScript
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 { MonthCalendar } from "../../components/MonthCalendar";
|
|
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())}`;
|
|
}
|
|
|
|
function maxBookableDate() {
|
|
const d = new Date();
|
|
d.setMonth(d.getMonth() + 3);
|
|
const pad = (n: number) => String(n).padStart(2, "0");
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
}
|
|
|
|
const FULL_STEPS = [
|
|
{ n: 1, label: "Servicio" },
|
|
{ n: 2, label: "Especialista" },
|
|
{ n: 3, label: "Horario" },
|
|
{ n: 4, label: "Datos" },
|
|
] as const;
|
|
|
|
function stepsFor(autoAssign: boolean) {
|
|
return autoAssign
|
|
? FULL_STEPS.filter((s) => s.label !== "Especialista").map((s, i) => ({ n: i + 1, label: s.label, original: s.n }))
|
|
: FULL_STEPS.map((s, i) => ({ n: i + 1, label: s.label, original: s.n }));
|
|
}
|
|
|
|
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 autoAssign = !!business?.auto_assign_specialist;
|
|
const steps = useMemo(() => stepsFor(autoAssign), [autoAssign]);
|
|
const stepCount = steps.length;
|
|
const currentOriginal = steps.find((s) => s.n === step)?.original;
|
|
|
|
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: currentOriginal === 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} stepCount={4} steps={[]} />
|
|
<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(stepCount, 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((s) => Math.min(stepCount, s + 1));
|
|
};
|
|
|
|
const handleBook = () => {
|
|
if (!selectedService || !selectedSlot) return;
|
|
if (!client.name.trim()) return;
|
|
bookMut.mutate({
|
|
service_id: selectedService.id,
|
|
employee_id: autoAssign ? undefined : 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 =
|
|
(currentOriginal === 1 && !!serviceId) ||
|
|
currentOriginal === 2 ||
|
|
(currentOriginal === 3 && !!selectedSlot) ||
|
|
(currentOriginal === 4 && client.name.trim().length > 0);
|
|
|
|
const currency = business.currency_symbol || "$";
|
|
const primaryLabel = currentOriginal === 4 ? "Confirmar reserva" : "Continuar";
|
|
const onPrimary = currentOriginal === 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} stepCount={stepCount} steps={steps} />
|
|
|
|
<main className={cn(
|
|
"mx-auto w-full flex-1 px-4 pb-32 pt-5 sm:px-6 md:pb-28",
|
|
currentOriginal === 3 ? "max-w-5xl" : "max-w-2xl"
|
|
)}>
|
|
{currentOriginal === 1 && <Hero business={business} />}
|
|
|
|
<div className="mt-4">
|
|
{currentOriginal === 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>
|
|
)}
|
|
|
|
{currentOriginal === 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>
|
|
)}
|
|
|
|
{currentOriginal === 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()}
|
|
max={maxBookableDate()}
|
|
slots={slots}
|
|
loading={slotsQuery.isLoading}
|
|
isError={slotsQuery.isError}
|
|
selectedSlot={selectedSlot}
|
|
onPick={pickSlot}
|
|
/>
|
|
</StepShell>
|
|
)}
|
|
|
|
{currentOriginal === 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)} />
|
|
{!autoAssign && <SummaryRow label="Especialista" value={selectedSlot.employee_name} />}
|
|
</>
|
|
}
|
|
/>
|
|
</StepShell>
|
|
)}
|
|
</div>
|
|
</main>
|
|
|
|
<ActionBar
|
|
hidden={!!result}
|
|
step={step}
|
|
stepCount={stepCount}
|
|
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, stepCount, steps }: { name: string; industry: string; step: number; stepCount: number; steps: { n: number; label: string }[]; }) {
|
|
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-5xl 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 {stepCount}
|
|
</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">{stepCount}</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,
|
|
max,
|
|
slots,
|
|
loading,
|
|
isError,
|
|
selectedSlot,
|
|
onPick,
|
|
}: {
|
|
date: string;
|
|
onDate: (d: string) => void;
|
|
min: string;
|
|
max?: string;
|
|
slots: PublicSlot[];
|
|
loading: boolean;
|
|
isError: boolean;
|
|
selectedSlot: PublicSlot | null;
|
|
onPick: (s: PublicSlot) => void;
|
|
}) {
|
|
const { morning, afternoon } = useMemo(() => {
|
|
const morning: PublicSlot[] = [];
|
|
const afternoon: PublicSlot[] = [];
|
|
for (const s of slots) {
|
|
const m = /(\d{1,2}):(\d{2})/.exec(s.time);
|
|
const h = m ? Number(m[1]) : 0;
|
|
const pm = /PM/i.test(s.time);
|
|
const hour24 = pm && h !== 12 ? h + 12 : !pm && h === 12 ? 0 : h;
|
|
(hour24 < 12 ? morning : afternoon).push(s);
|
|
}
|
|
return { morning, afternoon };
|
|
}, [slots]);
|
|
|
|
return (
|
|
<div className="md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] md:items-start md:gap-8">
|
|
{/* Columna izquierda: calendario */}
|
|
<div className="card p-4">
|
|
<label className="label flex items-center gap-1.5">
|
|
<CalendarDays className="h-3.5 w-3.5 text-slate-400" /> Fecha
|
|
</label>
|
|
<MonthCalendar value={date} min={min} max={max} onSelect={(d) => onDate(d)} />
|
|
<div className="mt-3 flex flex-wrap items-center gap-3 text-[11px] text-slate-500">
|
|
<span className="inline-flex items-center gap-1"><span className="h-2 w-2 rounded-full bg-accent-500" /> Con cupo</span>
|
|
<span className="inline-flex items-center gap-1"><span className="h-2 w-2 rounded-full bg-brand-400" /> Hoy</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Columna derecha: horarios */}
|
|
<div className="mt-4 md:mt-0">
|
|
<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="space-y-4">
|
|
<SlotGroup title="Mañana" slots={morning} selectedSlot={selectedSlot} onPick={onPick} />
|
|
<SlotGroup title="Tarde" slots={afternoon} selectedSlot={selectedSlot} onPick={onPick} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SlotGroup({
|
|
title,
|
|
slots,
|
|
selectedSlot,
|
|
onPick,
|
|
}: {
|
|
title: string;
|
|
slots: PublicSlot[];
|
|
selectedSlot: PublicSlot | null;
|
|
onPick: (s: PublicSlot) => void;
|
|
}) {
|
|
if (slots.length === 0) return null;
|
|
return (
|
|
<div>
|
|
<div className="mb-2 flex items-center gap-2">
|
|
<span className="rounded-full bg-accent-50 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-accent-700">{title}</span>
|
|
<span className="text-[11px] font-medium text-slate-400">{slots.length} opciones</span>
|
|
</div>
|
|
<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>
|
|
);
|
|
}
|
|
|
|
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-10"
|
|
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-10"
|
|
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-10"
|
|
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,
|
|
stepCount,
|
|
goBack,
|
|
onPrimary,
|
|
primaryLabel,
|
|
canContinue,
|
|
busy,
|
|
priceLabel,
|
|
serviceName,
|
|
slotLabel,
|
|
}: {
|
|
hidden: boolean;
|
|
step: number;
|
|
stepCount: 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-5xl 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" : `Paso ${step} de ${stepCount} · 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} stepCount={4} steps={[]} />
|
|
<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} />
|
|
{appointment.reasons && appointment.reasons.length > 0 && (
|
|
<div className="px-3 pb-2 pt-1">
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{appointment.reasons.map((r) => (
|
|
<span key={r} className="inline-flex items-center gap-1 rounded-full bg-brand-50 px-2 py-0.5 text-[11px] font-bold text-brand-700">
|
|
<Sparkles className="h-3 w-3" /> {r}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<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>
|
|
);
|
|
}
|