Elegir "Corte + arreglo de barba" y luego cualquier especialista que no fuera el barbero dejaba el asistente atascado: /slots responde 400 "El especialista no ofrece este servicio" y la UI lo pintaba como "No pudimos cargar los horarios. Intenta otra fecha" — un mensaje que manda a probar días al azar cuando ninguna fecha lo resuelve. La raíz es que el payload público listaba todos los empleados activos sin decir qué servicios ofrece cada uno, así que el cliente no tenía con qué filtrar. - GET /api/public/:slug devuelve ahora `service_ids` por empleado. El JOIN contra employees filtra por negocio: employee_services no tiene business_id propio. - El paso 2 lista solo a los elegibles, y si nadie ofrece el servicio en concreto lo dice en vez de mostrar una lista vacía. - Cambiar de servicio suelta un especialista que ya no aplique, en lugar de arrastrar una combinación que el servidor va a rechazar. - El estado de error muestra el motivo real del servidor. booking-e2e.mjs cubre el contrato: cada empleado publica service_ids, quien ofrece el servicio responde 200 y quien no responde 400. 15/15. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
1091 lines
40 KiB
TypeScript
1091 lines
40 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import { useParams } from "react-router-dom";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
Sparkles,
|
|
Clock,
|
|
CalendarDays,
|
|
User,
|
|
Check,
|
|
ChevronRight,
|
|
ChevronLeft,
|
|
Phone,
|
|
MapPin,
|
|
CalendarCheck,
|
|
PartyPopper,
|
|
AlertCircle,
|
|
Mail,
|
|
RefreshCw,
|
|
Info,
|
|
} 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, hourInTz } 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 [bookError, setBookError] = useState<string | null>(null);
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
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 ?? [];
|
|
|
|
// Toda hora que ve el cliente se rinde en la zona del NEGOCIO. Si se cae a la del
|
|
// navegador, alguien que reserva desde otro huso ve una hora que no es la de su cita.
|
|
const tz = business?.timezone || "America/Mexico_City";
|
|
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]
|
|
);
|
|
|
|
// Un especialista que no ofrece el servicio hace que /slots responda 400. Antes se
|
|
// listaban todos, así que el cliente podía elegir a alguien imposible y solo se
|
|
// enteraba un paso después, con un mensaje que le pedía cambiar la fecha.
|
|
const eligibleEmployees = useMemo(
|
|
() => (serviceId == null ? [] : employees.filter((e) => e.service_ids.includes(serviceId))),
|
|
[employees, 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) => {
|
|
setBookError(null);
|
|
setResult(res);
|
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
},
|
|
onError: (err: Error) => {
|
|
setBookError(err?.message || "No pudimos completar tu reserva. Inténtalo de nuevo.");
|
|
setResult(null);
|
|
queryClient.invalidateQueries({ queryKey: ["public", "slots", slug] });
|
|
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);
|
|
setBookError(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."}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const hasPolicy =
|
|
(business.cancel_penalty_pct ?? 0) > 0 || !!business.require_deposit;
|
|
const policyNote = (
|
|
<PolicyNote
|
|
cancelWindowHours={business.cancel_window_hours}
|
|
cancelPenaltyPct={business.cancel_penalty_pct}
|
|
requireDeposit={business.require_deposit}
|
|
depositPct={business.deposit_pct}
|
|
/>
|
|
);
|
|
|
|
if (result) {
|
|
return (
|
|
<Confirmation
|
|
result={result}
|
|
businessName={business.name}
|
|
clientName={client.name.trim()}
|
|
hasPolicy={hasPolicy}
|
|
policy={policyNote}
|
|
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);
|
|
setBookError(null);
|
|
// El especialista elegido puede no ofrecer el servicio nuevo: soltarlo en vez
|
|
// de arrastrar una combinación que el servidor rechazará.
|
|
const stillValid = employees.some((e) => e.id === employeeId && e.service_ids.includes(id));
|
|
if (!stillValid) setEmployeeId(null);
|
|
}
|
|
};
|
|
|
|
const pickEmployee = (id: number | null) => {
|
|
setEmployeeId(id);
|
|
setSelectedSlot(null);
|
|
setBookError(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;
|
|
setBookError(null);
|
|
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={eligibleEmployees}
|
|
selectedId={employeeId}
|
|
onPick={pickEmployee}
|
|
serviceName={selectedService?.name}
|
|
/>
|
|
</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);
|
|
setBookError(null);
|
|
}}
|
|
min={todayStr()}
|
|
max={maxBookableDate()}
|
|
slots={slots}
|
|
loading={slotsQuery.isLoading}
|
|
isError={slotsQuery.isError}
|
|
errorMessage={slotsQuery.error instanceof Error ? slotsQuery.error.message : undefined}
|
|
selectedSlot={selectedSlot}
|
|
onPick={pickSlot}
|
|
tz={tz}
|
|
/>
|
|
</StepShell>
|
|
)}
|
|
|
|
{currentOriginal === 4 && selectedService && selectedSlot && (
|
|
<StepShell
|
|
icon={<CalendarCheck className="h-4 w-4" />}
|
|
title="Tus datos"
|
|
subtitle="Necesitamos algunos datos para confirmar tu cita."
|
|
>
|
|
{bookError && (
|
|
<div className="mb-4 flex items-start gap-3 rounded-2xl bg-rose-600 px-4 py-3 text-white shadow-soft animate-slide-up">
|
|
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0" />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm font-bold leading-snug">{bookError}</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setBookError(null);
|
|
setSelectedSlot(null);
|
|
setStep(steps.find((s) => s.original === 3)?.n ?? 3);
|
|
queryClient.invalidateQueries({ queryKey: ["public", "slots", slug] });
|
|
}}
|
|
className="mt-2 inline-flex items-center gap-1.5 rounded-lg bg-white/20 px-2.5 py-1 text-xs font-bold backdrop-blur transition-colors hover:bg-white/30"
|
|
>
|
|
<RefreshCw className="h-3.5 w-3.5" /> Intentar de nuevo
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<DetailsForm
|
|
client={client}
|
|
setClient={setClient}
|
|
policy={policyNote}
|
|
summary={
|
|
<>
|
|
<SummaryRow label="Servicio" value={selectedService.name} />
|
|
<SummaryRow label="Fecha" value={formatDate(selectedSlot.iso, { weekday: "long", timeZone: tz })} />
|
|
<SummaryRow label="Hora" value={formatTime(selectedSlot.iso, tz)} />
|
|
{!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", timeZone: tz })} · ${formatTime(selectedSlot.iso, tz)}` : 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,
|
|
serviceName,
|
|
}: {
|
|
employees: { id: number; name: string; role: string; color: string }[];
|
|
selectedId: number | null;
|
|
onPick: (id: number | null) => void;
|
|
serviceName?: string;
|
|
}) {
|
|
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.length === 0 && (
|
|
<p className="rounded-2xl border border-slate-200 bg-slate-50 p-3.5 text-sm text-slate-600">
|
|
{serviceName
|
|
? `Para ${serviceName} asignamos al especialista disponible.`
|
|
: "Asignamos al especialista disponible."}
|
|
</p>
|
|
)}
|
|
|
|
{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,
|
|
errorMessage,
|
|
selectedSlot,
|
|
onPick,
|
|
tz,
|
|
}: {
|
|
date: string;
|
|
onDate: (d: string) => void;
|
|
min: string;
|
|
max?: string;
|
|
slots: PublicSlot[];
|
|
loading: boolean;
|
|
isError: boolean;
|
|
errorMessage?: string;
|
|
selectedSlot: PublicSlot | null;
|
|
onPick: (s: PublicSlot) => void;
|
|
tz: string;
|
|
}) {
|
|
// La hora sale del instante `iso` resuelto en la tz del negocio. Antes se parseaba
|
|
// `s.time`, ya localizado: es-MX rinde "05:00 p.m." y `/PM/i` no casa con "p.m.",
|
|
// así que TODOS los slots de la tarde se agrupaban bajo "Mañana".
|
|
const { morning, afternoon } = useMemo(() => {
|
|
const morning: PublicSlot[] = [];
|
|
const afternoon: PublicSlot[] = [];
|
|
for (const s of slots) {
|
|
(hourInTz(s.iso, tz) < 12 ? morning : afternoon).push(s);
|
|
}
|
|
return { morning, afternoon };
|
|
}, [slots, tz]);
|
|
|
|
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" />
|
|
{/* Se muestra el motivo real del servidor. El texto fijo anterior
|
|
("intenta otra fecha") mandaba al cliente a probar días al azar
|
|
cuando el problema era el especialista, y ninguna fecha lo resolvía. */}
|
|
<p className="text-sm font-medium text-slate-700">
|
|
{errorMessage || "No pudimos cargar los horarios."}
|
|
</p>
|
|
<p className="text-xs text-slate-400">Prueba con otro especialista u 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} tz={tz} />
|
|
<SlotGroup title="Tarde" slots={afternoon} selectedSlot={selectedSlot} onPick={onPick} tz={tz} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SlotGroup({
|
|
title,
|
|
slots,
|
|
selectedSlot,
|
|
onPick,
|
|
tz,
|
|
}: {
|
|
title: string;
|
|
slots: PublicSlot[];
|
|
selectedSlot: PublicSlot | null;
|
|
onPick: (s: PublicSlot) => void;
|
|
tz: string;
|
|
}) {
|
|
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"
|
|
)}
|
|
>
|
|
{formatTime(slot.iso, tz)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DetailsForm({
|
|
client,
|
|
setClient,
|
|
summary,
|
|
policy,
|
|
}: {
|
|
client: { name: string; phone: string; email: string; notes: string };
|
|
setClient: (c: { name: string; phone: string; email: string; notes: string }) => void;
|
|
summary: React.ReactNode;
|
|
policy?: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="card divide-y divide-slate-100 p-2">{summary}</div>
|
|
{policy}
|
|
<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 PolicyNote({
|
|
cancelWindowHours,
|
|
cancelPenaltyPct,
|
|
requireDeposit,
|
|
depositPct,
|
|
}: {
|
|
cancelWindowHours: number;
|
|
cancelPenaltyPct: number;
|
|
requireDeposit: boolean | number;
|
|
depositPct: number;
|
|
}) {
|
|
const lines: string[] = [];
|
|
if ((cancelPenaltyPct ?? 0) > 0) {
|
|
lines.push(`Cancela con al menos ${cancelWindowHours ?? 0} h de anticipación. Si no, aplica un cargo del ${cancelPenaltyPct}%.`);
|
|
}
|
|
if (requireDeposit) {
|
|
lines.push(`Se solicita un depósito del ${depositPct ?? 0}% (te lo indicará el negocio al pagar).`);
|
|
}
|
|
if (lines.length === 0) return null;
|
|
return (
|
|
<div className="flex items-start gap-2 rounded-xl bg-amber-50 px-3 py-2.5 text-xs text-amber-800">
|
|
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
|
|
<div className="space-y-0.5">
|
|
{lines.map((l) => (
|
|
<p key={l}>{l}</p>
|
|
))}
|
|
</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 AgendaMax
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Confirmation({
|
|
result,
|
|
businessName,
|
|
clientName,
|
|
hasPolicy,
|
|
policy,
|
|
onReset,
|
|
}: {
|
|
result: BookResponse;
|
|
businessName: string;
|
|
clientName: string;
|
|
hasPolicy: boolean;
|
|
policy?: React.ReactNode;
|
|
onReset: () => void;
|
|
}) {
|
|
const { appointment } = result;
|
|
const currency = result.business.currency_symbol || "$";
|
|
const showRisk = result.risk_flag === true || (result.no_show_count ?? 0) >= 2;
|
|
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="border-b border-slate-100 bg-slate-50/60 px-5 py-3 text-center">
|
|
<p className="text-sm font-bold text-slate-800">
|
|
{result.client_created === false
|
|
? `¡Gracias por volver, ${clientName}! 👋`
|
|
: `¡Hola, ${clientName}! Tu cita quedó confirmada.`}
|
|
</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>
|
|
|
|
{showRisk && (
|
|
<div className="mt-4 flex items-start gap-2.5 rounded-2xl bg-amber-50 px-4 py-3 text-amber-800 ring-1 ring-amber-200">
|
|
<Info className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
|
|
<p className="text-xs font-medium leading-relaxed">
|
|
Notamos ausencias previas sin avisar. Te enviaremos un recordatorio; reserva y no asistir puede generar un cargo según la política del negocio.
|
|
</p>
|
|
</div>
|
|
)}
|
|
{hasPolicy && policy ? <div className="mt-4">{policy}</div> : null}
|
|
|
|
<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>
|
|
);
|
|
}
|