feat(ui): booking redesign + auto-assign settings + specialist fields
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.
This commit is contained in:
@@ -25,6 +25,7 @@ import {
|
||||
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() {
|
||||
@@ -33,12 +34,25 @@ function todayStr() {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
const STEPS = [
|
||||
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 }>();
|
||||
@@ -63,6 +77,11 @@ export default function BookingPage() {
|
||||
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]
|
||||
@@ -71,7 +90,7 @@ export default function BookingPage() {
|
||||
const slotsQuery = useQuery({
|
||||
queryKey: ["public", "slots", slug, serviceId, selectedDate, employeeId],
|
||||
queryFn: () => getSlots(slug!, serviceId!, selectedDate, employeeId ?? undefined),
|
||||
enabled: step >= 3 && !!serviceId && !!selectedDate && !result,
|
||||
enabled: currentOriginal === 3 && !!serviceId && !!selectedDate && !result,
|
||||
});
|
||||
|
||||
const slots = slotsQuery.data?.slots ?? [];
|
||||
@@ -130,7 +149,7 @@ export default function BookingPage() {
|
||||
if (!business.booking_enabled) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-[#f6f7fb]">
|
||||
<TopBar name={business.name} industry={business.industry} step={0} />
|
||||
<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">
|
||||
@@ -151,7 +170,7 @@ export default function BookingPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const goNext = () => setStep((s) => Math.min(4, s + 1));
|
||||
const goNext = () => setStep((s) => Math.min(stepCount, s + 1));
|
||||
const goBack = () => setStep((s) => Math.max(1, s - 1));
|
||||
|
||||
const pickService = (id: number) => {
|
||||
@@ -168,7 +187,7 @@ export default function BookingPage() {
|
||||
|
||||
const pickSlot = (slot: PublicSlot) => {
|
||||
setSelectedSlot(slot);
|
||||
setStep(4);
|
||||
setStep((s) => Math.min(stepCount, s + 1));
|
||||
};
|
||||
|
||||
const handleBook = () => {
|
||||
@@ -176,7 +195,7 @@ export default function BookingPage() {
|
||||
if (!client.name.trim()) return;
|
||||
bookMut.mutate({
|
||||
service_id: selectedService.id,
|
||||
employee_id: selectedSlot.employee_id,
|
||||
employee_id: autoAssign ? undefined : selectedSlot.employee_id,
|
||||
start_at: selectedSlot.iso,
|
||||
client: {
|
||||
name: client.name.trim(),
|
||||
@@ -188,26 +207,27 @@ export default function BookingPage() {
|
||||
};
|
||||
|
||||
const canContinue =
|
||||
(step === 1 && !!serviceId) ||
|
||||
step === 2 ||
|
||||
(step === 3 && !!selectedSlot) ||
|
||||
(step === 4 && client.name.trim().length > 0);
|
||||
(currentOriginal === 1 && !!serviceId) ||
|
||||
currentOriginal === 2 ||
|
||||
(currentOriginal === 3 && !!selectedSlot) ||
|
||||
(currentOriginal === 4 && client.name.trim().length > 0);
|
||||
|
||||
const currency = business.currency_symbol || "$";
|
||||
const primaryLabel = step === 4 ? "Confirmar reserva" : "Continuar";
|
||||
const onPrimary = step === 4 ? handleBook : goNext;
|
||||
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} />
|
||||
<TopBar name={business.name} industry={business.industry} step={step} stepCount={stepCount} steps={steps} />
|
||||
|
||||
<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} />
|
||||
)}
|
||||
<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">
|
||||
{step === 1 && (
|
||||
{currentOriginal === 1 && (
|
||||
<StepShell
|
||||
icon={<Sparkles className="h-4 w-4" />}
|
||||
title="Elige un servicio"
|
||||
@@ -222,7 +242,7 @@ export default function BookingPage() {
|
||||
</StepShell>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
{currentOriginal === 2 && (
|
||||
<StepShell
|
||||
icon={<User className="h-4 w-4" />}
|
||||
title="Elige un especialista"
|
||||
@@ -236,7 +256,7 @@ export default function BookingPage() {
|
||||
</StepShell>
|
||||
)}
|
||||
|
||||
{step === 3 && selectedService && (
|
||||
{currentOriginal === 3 && selectedService && (
|
||||
<StepShell
|
||||
icon={<CalendarDays className="h-4 w-4" />}
|
||||
title="Elige fecha y hora"
|
||||
@@ -249,6 +269,7 @@ export default function BookingPage() {
|
||||
setSelectedSlot(null);
|
||||
}}
|
||||
min={todayStr()}
|
||||
max={maxBookableDate()}
|
||||
slots={slots}
|
||||
loading={slotsQuery.isLoading}
|
||||
isError={slotsQuery.isError}
|
||||
@@ -258,7 +279,7 @@ export default function BookingPage() {
|
||||
</StepShell>
|
||||
)}
|
||||
|
||||
{step === 4 && selectedService && selectedSlot && (
|
||||
{currentOriginal === 4 && selectedService && selectedSlot && (
|
||||
<StepShell
|
||||
icon={<CalendarCheck className="h-4 w-4" />}
|
||||
title="Tus datos"
|
||||
@@ -272,7 +293,7 @@ export default function BookingPage() {
|
||||
<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} />
|
||||
{!autoAssign && <SummaryRow label="Especialista" value={selectedSlot.employee_name} />}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -284,6 +305,7 @@ export default function BookingPage() {
|
||||
<ActionBar
|
||||
hidden={!!result}
|
||||
step={step}
|
||||
stepCount={stepCount}
|
||||
goBack={goBack}
|
||||
onPrimary={onPrimary}
|
||||
primaryLabel={primaryLabel}
|
||||
@@ -301,11 +323,11 @@ export default function BookingPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function TopBar({ name, industry, step }: { name: string; industry: string; step: number }) {
|
||||
const current = STEPS.find((s) => s.n === step);
|
||||
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-3xl items-center justify-between gap-3 px-4 py-3 sm:px-6">
|
||||
<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" />
|
||||
@@ -319,10 +341,10 @@ function TopBar({ name, industry, step }: { name: string; industry: string; step
|
||||
{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
|
||||
Paso {step} de {stepCount}
|
||||
</span>
|
||||
<div className="hidden items-center gap-1 md:flex">
|
||||
{STEPS.map((s) => (
|
||||
{steps.map((s) => (
|
||||
<div
|
||||
key={s.n}
|
||||
className={cn(
|
||||
@@ -342,7 +364,7 @@ function TopBar({ name, industry, step }: { name: string; industry: string; step
|
||||
<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-400">{stepCount}</span>
|
||||
<span className="text-brand-300">·</span>
|
||||
<span>{current?.label}</span>
|
||||
</div>
|
||||
@@ -556,6 +578,7 @@ function DateTimePicker({
|
||||
date,
|
||||
onDate,
|
||||
min,
|
||||
max,
|
||||
slots,
|
||||
loading,
|
||||
isError,
|
||||
@@ -565,28 +588,42 @@ function DateTimePicker({
|
||||
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="space-y-4">
|
||||
<div>
|
||||
<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>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
value={date}
|
||||
min={min}
|
||||
onChange={(e) => e.target.value && onDate(e.target.value)}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
@@ -597,14 +634,12 @@ function DateTimePicker({
|
||||
<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" />
|
||||
@@ -612,27 +647,10 @@ function DateTimePicker({
|
||||
<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 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>
|
||||
@@ -641,6 +659,48 @@ function DateTimePicker({
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -661,7 +721,7 @@ function DetailsForm({
|
||||
<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"
|
||||
className="input pl-10"
|
||||
value={client.name}
|
||||
onChange={(e) => setClient({ ...client, name: e.target.value })}
|
||||
placeholder="Tu nombre"
|
||||
@@ -675,7 +735,7 @@ function DetailsForm({
|
||||
<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"
|
||||
className="input pl-10"
|
||||
inputMode="tel"
|
||||
value={client.phone}
|
||||
onChange={(e) => setClient({ ...client, phone: e.target.value })}
|
||||
@@ -689,7 +749,7 @@ function DetailsForm({
|
||||
<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"
|
||||
className="input pl-10"
|
||||
value={client.email}
|
||||
onChange={(e) => setClient({ ...client, email: e.target.value })}
|
||||
placeholder="[email protected]"
|
||||
@@ -725,6 +785,7 @@ function SummaryRow({ label, value }: { label: string; value: string }) {
|
||||
function ActionBar({
|
||||
hidden,
|
||||
step,
|
||||
stepCount,
|
||||
goBack,
|
||||
onPrimary,
|
||||
primaryLabel,
|
||||
@@ -736,6 +797,7 @@ function ActionBar({
|
||||
}: {
|
||||
hidden: boolean;
|
||||
step: number;
|
||||
stepCount: number;
|
||||
goBack: () => void;
|
||||
onPrimary: () => void;
|
||||
primaryLabel: string;
|
||||
@@ -748,7 +810,7 @@ function ActionBar({
|
||||
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">
|
||||
<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>
|
||||
@@ -764,7 +826,7 @@ function ActionBar({
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm font-medium text-slate-400">
|
||||
{step === 1 ? "Selecciona un servicio" : "Continúa para reservar"}
|
||||
{step === 1 ? "Selecciona un servicio" : `Paso ${step} de ${stepCount} · Continúa para reservar`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -802,7 +864,7 @@ function Confirmation({
|
||||
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} />
|
||||
<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">
|
||||
@@ -825,6 +887,17 @@ function Confirmation({
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user