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:
AgendaPro Dev
2026-07-26 15:59:41 -06:00
parent ed608656e0
commit 5410e27c89
7 changed files with 481 additions and 88 deletions
+118
View File
@@ -0,0 +1,118 @@
// src/components/MonthCalendar.tsx
import { useMemo, useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "../lib/format";
const WD = ["Lun", "Mar", "Mié", "Jue", "Vie", "Sáb", "Dom"];
const MONTHS = ["Enero","Febrero","Marzo","Abril","Mayo","Junio","Julio","Agosto","Septiembre","Octubre","Noviembre","Diciembre"];
function toIso(d: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
function isoToDate(iso: string): Date {
const [y, m, d] = iso.split("-").map(Number);
return new Date(y, (m || 1) - 1, d || 1);
}
function atMidnight(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
export function MonthCalendar({
value,
min,
max,
availableDates,
onSelect,
}: {
value: string;
min?: string;
max?: string;
availableDates?: Set<string>;
onSelect: (iso: string) => void;
}) {
const initial = value ? isoToDate(value) : new Date();
const [cursor, setCursor] = useState(new Date(initial.getFullYear(), initial.getMonth(), 1));
const todayIso = toIso(new Date());
const minDate = min ? atMidnight(isoToDate(min)) : null;
const maxDate = max ? atMidnight(isoToDate(max)) : null;
const cells = useMemo(() => {
const first = new Date(cursor.getFullYear(), cursor.getMonth(), 1);
const lead = (first.getDay() + 6) % 7; // Lun primero
const start = new Date(first);
start.setDate(first.getDate() - lead);
const out: Date[] = [];
for (let i = 0; i < 42; i++) {
const d = new Date(start);
d.setDate(start.getDate() + i);
out.push(d);
}
return out;
}, [cursor]);
const inMonth = (d: Date) => d.getMonth() === cursor.getMonth();
const selectable = (d: Date) => {
const dm = atMidnight(d);
if (minDate && dm < minDate) return false;
if (maxDate && dm > maxDate) return false;
return true;
};
const move = (delta: number) => setCursor(new Date(cursor.getFullYear(), cursor.getMonth() + delta, 1));
const firstOfMonth = new Date(cursor.getFullYear(), cursor.getMonth(), 1);
const canPrev = !minDate || new Date(cursor.getFullYear(), cursor.getMonth(), 0) >= firstOfMonth;
const canNext = !maxDate || new Date(cursor.getFullYear(), cursor.getMonth() + 2, 0) <= new Date(maxDate.getFullYear(), maxDate.getMonth() + 1, 0);
return (
<div className="select-none">
<div className="mb-3 flex items-center justify-between">
<button type="button" onClick={() => move(-1)} disabled={!canPrev}
className="flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-600 transition-colors hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent"
aria-label="Mes anterior">
<ChevronLeft className="h-4 w-4" />
</button>
<div className="text-sm font-extrabold text-slate-800">{MONTHS[cursor.getMonth()]} {cursor.getFullYear()}</div>
<button type="button" onClick={() => move(1)} disabled={!canNext}
className="flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-600 transition-colors hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent"
aria-label="Mes siguiente">
<ChevronRight className="h-4 w-4" />
</button>
</div>
<div className="mb-1 grid grid-cols-7 gap-1">
{WD.map((w) => (
<div key={w} className="text-center text-[11px] font-bold uppercase tracking-wide text-slate-400">{w}</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{cells.map((d, i) => {
const dIso = toIso(d);
const sel = dIso === value;
const on = selectable(d);
const inM = inMonth(d);
const isToday = dIso === todayIso;
const hasAvail = availableDates?.has(dIso);
const weekend = d.getDay() === 0 || d.getDay() === 6;
return (
<button key={i} type="button" disabled={!on} onClick={() => onSelect(dIso)}
className={cn(
"relative flex aspect-square items-center justify-center rounded-xl text-sm font-bold transition-all",
!inM && "text-slate-300",
inM && !sel && on && "text-slate-700 hover:bg-brand-50 hover:text-brand-700",
inM && !on && "cursor-not-allowed text-slate-300",
sel && "bg-brand-500 text-white shadow-soft ring-2 ring-brand-500/30",
weekend && inM && !sel && on && "text-slate-500"
)}>
{d.getDate()}
{hasAvail && !sel && (
<span className="absolute bottom-1 h-1.5 w-1.5 rounded-full bg-accent-500" aria-hidden />
)}
{isToday && !sel && (
<span className="absolute top-1 h-1.5 w-1.5 rounded-full bg-brand-400" aria-hidden />
)}
</button>
);
})}
</div>
</div>
);
}
+20 -18
View File
@@ -258,24 +258,26 @@ body {
background: #fecaca; background: #fecaca;
} }
.input, @layer components {
.select, .input,
.textarea { .select,
width: 100%; .textarea {
background: #ffffff; width: 100%;
border: 1px solid #e5e7eb; background: #ffffff;
border-radius: 0.65rem; border: 1px solid #e5e7eb;
padding: 0.55rem 0.75rem; border-radius: 0.65rem;
font-size: 0.875rem; padding: 0.55rem 0.75rem;
color: #0f172a; font-size: 0.875rem;
transition: border-color 0.15s, box-shadow 0.15s; color: #0f172a;
outline: none; transition: border-color 0.15s, box-shadow 0.15s;
} outline: none;
.input:focus, }
.select:focus, .input:focus,
.textarea:focus { .select:focus,
border-color: #3b66ff; .textarea:focus {
box-shadow: 0 0 0 3px rgba(59, 102, 255, 0.15); border-color: #3b66ff;
box-shadow: 0 0 0 3px rgba(59, 102, 255, 0.15);
}
} }
.label { .label {
display: block; display: block;
+4 -1
View File
@@ -12,6 +12,7 @@ export interface PublicBusiness {
cancel_penalty_pct: number; cancel_penalty_pct: number;
require_deposit: boolean; require_deposit: boolean;
deposit_pct: number; deposit_pct: number;
auto_assign_specialist: number | boolean;
} }
export interface PublicService { export interface PublicService {
@@ -58,7 +59,7 @@ export interface BookClient {
export interface BookPayload { export interface BookPayload {
service_id: number; service_id: number;
employee_id: number; employee_id?: number;
start_at: string; start_at: string;
client: BookClient; client: BookClient;
} }
@@ -69,7 +70,9 @@ export interface BookResponse {
start_at: string; start_at: string;
price: number; price: number;
service_name: string; service_name: string;
employee_id: number;
employee_name: string; employee_name: string;
reasons: string[];
}; };
business: { name: string; currency_symbol: string }; business: { name: string; currency_symbol: string };
client_created: boolean; client_created: boolean;
+17
View File
@@ -0,0 +1,17 @@
export const DEFAULT_WH: Record<number, { start: string; end: string } | null> = {
1: { start: "09:00", end: "20:00" }, 2: { start: "09:00", end: "20:00" },
3: { start: "09:00", end: "20:00" }, 4: { start: "09:00", end: "20:00" },
5: { start: "09:00", end: "20:00" }, 6: null, 7: null,
};
export const DAYS = [
{ n: 1, label: "Lunes" }, { n: 2, label: "Martes" }, { n: 3, label: "Miércoles" },
{ n: 4, label: "Jueves" }, { n: 5, label: "Viernes" }, { n: 6, label: "Sábado" }, { n: 7, label: "Domingo" },
];
export function parseWh(v: any): Record<number, { start: string; end: string } | null> {
let obj = v;
if (typeof v === "string") { try { obj = JSON.parse(v); } catch { obj = null; } }
if (!obj || typeof obj !== "object") return { ...DEFAULT_WH };
const out: Record<number, any> = {};
for (let d = 1; d <= 7; d++) out[d] = obj[String(d)] ?? null;
return out;
}
+130 -1
View File
@@ -16,6 +16,7 @@ import type { Employee } from "../../shared/types";
import { PageHeader, Avatar, Spinner, EmptyState } from "../components/ui"; import { PageHeader, Avatar, Spinner, EmptyState } from "../components/ui";
import { Modal } from "../components/Modal"; import { Modal } from "../components/Modal";
import { formatCurrency, cn } from "../lib/format"; import { formatCurrency, cn } from "../lib/format";
import { DEFAULT_WH, DAYS, parseWh } from "../lib/workingHours";
const COLORS = ["#3b66ff", "#f17616", "#10b981", "#a855f7", "#ec4899", "#14b8a6", "#f59e0b", "#6366f1"]; const COLORS = ["#3b66ff", "#f17616", "#10b981", "#a855f7", "#ec4899", "#14b8a6", "#f59e0b", "#6366f1"];
@@ -193,6 +194,11 @@ function EmployeeModal({
const [color, setColor] = useState(COLORS[0]); const [color, setColor] = useState(COLORS[0]);
const [active, setActive] = useState(true); const [active, setActive] = useState(true);
const [svcIds, setSvcIds] = useState<number[]>([]); const [svcIds, setSvcIds] = useState<number[]>([]);
const [specialties, setSpecialties] = useState<string[]>([]);
const [specialtyInput, setSpecialtyInput] = useState("");
const [efficiency, setEfficiency] = useState(50);
const [inheritHours, setInheritHours] = useState(true);
const [wh, setWh] = useState<Record<number, { start: string; end: string } | null>>({ ...DEFAULT_WH });
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -204,6 +210,12 @@ function EmployeeModal({
setColor(employee.color); setColor(employee.color);
setActive(!!employee.active); setActive(!!employee.active);
setSvcIds(employee.service_ids ?? []); setSvcIds(employee.service_ids ?? []);
setSpecialties(Array.isArray(employee.specialties) ? employee.specialties : []);
setEfficiency(typeof employee.efficiency_score === "number" ? employee.efficiency_score : 50);
const parsed = parseWh(employee.working_hours);
const hasOwn = !!employee.working_hours && Object.values(parsed).some((v) => v !== null);
setInheritHours(!hasOwn);
setWh(hasOwn ? parsed : { ...DEFAULT_WH });
} else { } else {
setName(""); setName("");
setRole("Especialista"); setRole("Especialista");
@@ -212,12 +224,28 @@ function EmployeeModal({
setColor(COLORS[Math.floor(Math.random() * COLORS.length)]); setColor(COLORS[Math.floor(Math.random() * COLORS.length)]);
setActive(true); setActive(true);
setSvcIds([]); setSvcIds([]);
setSpecialties([]);
setSpecialtyInput("");
setEfficiency(50);
setInheritHours(true);
setWh({ ...DEFAULT_WH });
} }
}, [open, employee]); }, [open, employee]);
const mut = useMutation({ const mut = useMutation({
mutationFn: async () => { mutationFn: async () => {
const payload = { name, role, email, phone, color, active, service_ids: svcIds }; const payload = {
name,
role,
email,
phone,
color,
active,
service_ids: svcIds,
specialties,
efficiency_score: efficiency,
working_hours: inheritHours ? null : wh,
};
if (employee) return api.employees.update(employee.id, payload); if (employee) return api.employees.update(employee.id, payload);
return api.employees.create(payload); return api.employees.create(payload);
}, },
@@ -303,6 +331,107 @@ function EmployeeModal({
})} })}
</div> </div>
</div> </div>
<div>
<label className="label">Especialidades</label>
<div className="flex flex-wrap items-center gap-1.5 rounded-xl border border-slate-100 p-2">
{specialties.map((sp) => (
<span key={sp} className="chip bg-brand-50 text-brand-700">
{sp}
<button
type="button"
onClick={() => setSpecialties((a) => a.filter((x) => x !== sp))}
className="ml-0.5 text-brand-400 hover:text-brand-700"
>
×
</button>
</span>
))}
<input
className="min-w-[120px] flex-1 bg-transparent text-sm outline-none"
placeholder="Ej. Coloración y pulsa Enter"
value={specialtyInput}
onChange={(e) => setSpecialtyInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && specialtyInput.trim()) {
e.preventDefault();
setSpecialties((a) => [...new Set([...a, specialtyInput.trim()])]);
setSpecialtyInput("");
}
}}
/>
</div>
<p className="mt-1 text-[11px] text-slate-500">Etiquetas que el algoritmo compara con la categoría y el nombre del servicio.</p>
</div>
<div>
<label className="label">
Eficiencia <span className="ml-1 text-brand-700">{efficiency}</span>
</label>
<input
type="range"
min={0}
max={100}
value={efficiency}
onChange={(e) => setEfficiency(Number(e.target.value))}
className="w-full accent-brand-500"
/>
<p className="mt-1 text-[11px] text-slate-500">Qué tan rápido/hábil es. El algoritmo premia valores altos cuando hay empate por disponibilidad.</p>
</div>
<div>
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
className="h-4 w-4 accent-brand-500"
checked={inheritHours}
onChange={(e) => setInheritHours(e.target.checked)}
/>
<span className="text-sm font-bold text-slate-800">Heredar horario del negocio</span>
</label>
{!inheritHours && (
<div className="mt-2 space-y-1.5">
{DAYS.map(({ n, label }) => {
const on = !!wh[n];
return (
<div key={n} className="flex items-center gap-2 rounded-lg border border-slate-100 px-2 py-1.5">
<label className="flex w-24 cursor-pointer items-center gap-2 text-xs font-semibold text-slate-700">
<input
type="checkbox"
className="h-4 w-4 accent-brand-500"
checked={on}
onChange={() =>
setWh({ ...wh, [n]: wh[n] ? null : { start: "09:00", end: "18:00" } })
}
/>
{label}
</label>
{on ? (
<div className="flex items-center gap-1.5">
<input
type="time"
className="input !w-auto !py-1 text-xs"
value={wh[n]!.start}
onChange={(e) =>
setWh({ ...wh, [n]: { ...(wh[n] as { start: string; end: string }), start: e.target.value } })
}
/>
<span className="text-xs text-slate-400">a</span>
<input
type="time"
className="input !w-auto !py-1 text-xs"
value={wh[n]!.end}
onChange={(e) =>
setWh({ ...wh, [n]: { ...(wh[n] as { start: string; end: string }), end: e.target.value } })
}
/>
</div>
) : (
<span className="text-xs font-medium text-slate-400">Cerrado</span>
)}
</div>
);
})}
</div>
)}
</div>
<label className="flex items-center gap-2 text-sm font-medium text-slate-700"> <label className="flex items-center gap-2 text-sm font-medium text-slate-700">
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} /> <input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
Activo (puede recibir citas) Activo (puede recibir citas)
+52 -1
View File
@@ -10,9 +10,12 @@ import {
Copy, Copy,
Check, Check,
ExternalLink, ExternalLink,
Wand2,
Clock,
} from "lucide-react"; } from "lucide-react";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { PageHeader, Spinner } from "../components/ui"; import { PageHeader, Spinner } from "../components/ui";
import { DEFAULT_WH, DAYS, parseWh } from "../lib/workingHours";
export function SettingsPage() { export function SettingsPage() {
const qc = useQueryClient(); const qc = useQueryClient();
@@ -20,9 +23,10 @@ export function SettingsPage() {
const [f, setF] = useState<any>(null); const [f, setF] = useState<any>(null);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
const [wh, setWh] = useState<Record<number, { start: string; end: string } | null>>({ ...DEFAULT_WH });
useEffect(() => { useEffect(() => {
if (data?.settings) setF(data.settings); if (data?.settings) { setF(data.settings); setWh(parseWh(data.settings.working_hours)); }
}, [data]); }, [data]);
const mut = useMutation({ const mut = useMutation({
@@ -39,6 +43,8 @@ export function SettingsPage() {
require_deposit: f.require_deposit ? 1 : 0, require_deposit: f.require_deposit ? 1 : 0,
deposit_pct: Number(f.deposit_pct), deposit_pct: Number(f.deposit_pct),
timezone: f.timezone, timezone: f.timezone,
auto_assign_specialist: f.auto_assign_specialist ? 1 : 0,
working_hours: wh,
}), }),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["settings"] }); qc.invalidateQueries({ queryKey: ["settings"] });
@@ -97,6 +103,19 @@ export function SettingsPage() {
<input className="input" value={f.slug} onChange={(e) => set("slug", e.target.value)} placeholder="mi-negocio" /> <input className="input" value={f.slug} onChange={(e) => set("slug", e.target.value)} placeholder="mi-negocio" />
</div> </div>
</div> </div>
<label className="flex cursor-pointer items-start gap-3 rounded-xl border border-slate-200 p-3">
<input type="checkbox" className="mt-0.5 h-4 w-4 accent-brand-500" checked={!!f.auto_assign_specialist} onChange={(e) => set("auto_assign_specialist", e.target.checked)} />
<div>
<div className="flex items-center gap-1.5 text-sm font-bold text-slate-800"><Wand2 className="h-4 w-4 text-brand-500" /> Asignar especialista automáticamente</div>
<p className="text-xs text-slate-500">El cliente no elige especialista: el sistema lo asigna según especialidad, eficiencia y disponibilidad. Evita dobles reservas.</p>
</div>
</label>
<div>
<label className="label flex items-center gap-1.5"><Clock className="h-3.5 w-3.5 text-slate-400" /> Horario del negocio</label>
<WorkingHoursEditor value={wh} onChange={setWh} />
<p className="mt-1 text-[11px] text-slate-500">Define qué días y horas se ofrecen citas. Los especialistas pueden tener su propio horario.</p>
</div>
</div> </div>
</div> </div>
@@ -175,3 +194,35 @@ function SectionTitle({ icon: Icon, color, title, subtitle }: { icon: any; color
</div> </div>
); );
} }
function WorkingHoursEditor({ value, onChange }: { value: Record<number, { start: string; end: string } | null>; onChange: (v: Record<number, { start: string; end: string } | null>) => void }) {
const toggle = (n: number) => onChange({ ...value, [n]: value[n] ? null : { start: "09:00", end: "18:00" } });
const set = (n: number, field: "start" | "end", v: string) => {
const cur = value[n] ?? { start: "09:00", end: "18:00" };
onChange({ ...value, [n]: { ...cur, [field]: v } });
};
return (
<div className="space-y-1.5">
{DAYS.map(({ n, label }) => {
const on = !!value[n];
return (
<div key={n} className="flex items-center gap-2 rounded-lg border border-slate-100 px-2 py-1.5">
<label className="flex w-24 cursor-pointer items-center gap-2 text-xs font-semibold text-slate-700">
<input type="checkbox" className="h-4 w-4 accent-brand-500" checked={on} onChange={() => toggle(n)} />
{label}
</label>
{on ? (
<div className="flex items-center gap-1.5">
<input type="time" className="input !w-auto !py-1 text-xs" value={value[n]!.start} onChange={(e) => set(n, "start", e.target.value)} />
<span className="text-xs text-slate-400">a</span>
<input type="time" className="input !w-auto !py-1 text-xs" value={value[n]!.end} onChange={(e) => set(n, "end", e.target.value)} />
</div>
) : (
<span className="text-xs font-medium text-slate-400">Cerrado</span>
)}
</div>
);
})}
</div>
);
}
+140 -67
View File
@@ -25,6 +25,7 @@ import {
type BookResponse, type BookResponse,
} from "../../lib/publicApi"; } from "../../lib/publicApi";
import { Spinner } from "../../components/ui"; import { Spinner } from "../../components/ui";
import { MonthCalendar } from "../../components/MonthCalendar";
import { cn, formatCurrency, formatDate, formatTime } from "../../lib/format"; import { cn, formatCurrency, formatDate, formatTime } from "../../lib/format";
function todayStr() { function todayStr() {
@@ -33,12 +34,25 @@ function todayStr() {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; 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: 1, label: "Servicio" },
{ n: 2, label: "Especialista" }, { n: 2, label: "Especialista" },
{ n: 3, label: "Horario" }, { n: 3, label: "Horario" },
{ n: 4, label: "Datos" }, { 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() { export default function BookingPage() {
const { slug } = useParams<{ slug: string }>(); const { slug } = useParams<{ slug: string }>();
@@ -63,6 +77,11 @@ export default function BookingPage() {
const services = data?.services ?? []; const services = data?.services ?? [];
const employees = data?.employees ?? []; 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>( const selectedService = useMemo<PublicService | undefined>(
() => services.find((s) => s.id === serviceId), () => services.find((s) => s.id === serviceId),
[services, serviceId] [services, serviceId]
@@ -71,7 +90,7 @@ export default function BookingPage() {
const slotsQuery = useQuery({ const slotsQuery = useQuery({
queryKey: ["public", "slots", slug, serviceId, selectedDate, employeeId], queryKey: ["public", "slots", slug, serviceId, selectedDate, employeeId],
queryFn: () => getSlots(slug!, serviceId!, selectedDate, employeeId ?? undefined), queryFn: () => getSlots(slug!, serviceId!, selectedDate, employeeId ?? undefined),
enabled: step >= 3 && !!serviceId && !!selectedDate && !result, enabled: currentOriginal === 3 && !!serviceId && !!selectedDate && !result,
}); });
const slots = slotsQuery.data?.slots ?? []; const slots = slotsQuery.data?.slots ?? [];
@@ -130,7 +149,7 @@ export default function BookingPage() {
if (!business.booking_enabled) { if (!business.booking_enabled) {
return ( return (
<div className="flex min-h-screen flex-col bg-[#f6f7fb]"> <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"> <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="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"> <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 goBack = () => setStep((s) => Math.max(1, s - 1));
const pickService = (id: number) => { const pickService = (id: number) => {
@@ -168,7 +187,7 @@ export default function BookingPage() {
const pickSlot = (slot: PublicSlot) => { const pickSlot = (slot: PublicSlot) => {
setSelectedSlot(slot); setSelectedSlot(slot);
setStep(4); setStep((s) => Math.min(stepCount, s + 1));
}; };
const handleBook = () => { const handleBook = () => {
@@ -176,7 +195,7 @@ export default function BookingPage() {
if (!client.name.trim()) return; if (!client.name.trim()) return;
bookMut.mutate({ bookMut.mutate({
service_id: selectedService.id, service_id: selectedService.id,
employee_id: selectedSlot.employee_id, employee_id: autoAssign ? undefined : selectedSlot.employee_id,
start_at: selectedSlot.iso, start_at: selectedSlot.iso,
client: { client: {
name: client.name.trim(), name: client.name.trim(),
@@ -188,26 +207,27 @@ export default function BookingPage() {
}; };
const canContinue = const canContinue =
(step === 1 && !!serviceId) || (currentOriginal === 1 && !!serviceId) ||
step === 2 || currentOriginal === 2 ||
(step === 3 && !!selectedSlot) || (currentOriginal === 3 && !!selectedSlot) ||
(step === 4 && client.name.trim().length > 0); (currentOriginal === 4 && client.name.trim().length > 0);
const currency = business.currency_symbol || "$"; const currency = business.currency_symbol || "$";
const primaryLabel = step === 4 ? "Confirmar reserva" : "Continuar"; const primaryLabel = currentOriginal === 4 ? "Confirmar reserva" : "Continuar";
const onPrimary = step === 4 ? handleBook : goNext; const onPrimary = currentOriginal === 4 ? handleBook : goNext;
return ( return (
<div className="flex min-h-screen flex-col bg-gradient-to-b from-brand-50/50 via-[#f6f7fb] to-[#f6f7fb]"> <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"> <main className={cn(
{step === 1 && ( "mx-auto w-full flex-1 px-4 pb-32 pt-5 sm:px-6 md:pb-28",
<Hero business={business} /> currentOriginal === 3 ? "max-w-5xl" : "max-w-2xl"
)} )}>
{currentOriginal === 1 && <Hero business={business} />}
<div className="mt-4"> <div className="mt-4">
{step === 1 && ( {currentOriginal === 1 && (
<StepShell <StepShell
icon={<Sparkles className="h-4 w-4" />} icon={<Sparkles className="h-4 w-4" />}
title="Elige un servicio" title="Elige un servicio"
@@ -222,7 +242,7 @@ export default function BookingPage() {
</StepShell> </StepShell>
)} )}
{step === 2 && ( {currentOriginal === 2 && (
<StepShell <StepShell
icon={<User className="h-4 w-4" />} icon={<User className="h-4 w-4" />}
title="Elige un especialista" title="Elige un especialista"
@@ -236,7 +256,7 @@ export default function BookingPage() {
</StepShell> </StepShell>
)} )}
{step === 3 && selectedService && ( {currentOriginal === 3 && selectedService && (
<StepShell <StepShell
icon={<CalendarDays className="h-4 w-4" />} icon={<CalendarDays className="h-4 w-4" />}
title="Elige fecha y hora" title="Elige fecha y hora"
@@ -249,6 +269,7 @@ export default function BookingPage() {
setSelectedSlot(null); setSelectedSlot(null);
}} }}
min={todayStr()} min={todayStr()}
max={maxBookableDate()}
slots={slots} slots={slots}
loading={slotsQuery.isLoading} loading={slotsQuery.isLoading}
isError={slotsQuery.isError} isError={slotsQuery.isError}
@@ -258,7 +279,7 @@ export default function BookingPage() {
</StepShell> </StepShell>
)} )}
{step === 4 && selectedService && selectedSlot && ( {currentOriginal === 4 && selectedService && selectedSlot && (
<StepShell <StepShell
icon={<CalendarCheck className="h-4 w-4" />} icon={<CalendarCheck className="h-4 w-4" />}
title="Tus datos" title="Tus datos"
@@ -272,7 +293,7 @@ export default function BookingPage() {
<SummaryRow label="Servicio" value={selectedService.name} /> <SummaryRow label="Servicio" value={selectedService.name} />
<SummaryRow label="Fecha" value={formatDate(selectedSlot.iso, { weekday: "long" })} /> <SummaryRow label="Fecha" value={formatDate(selectedSlot.iso, { weekday: "long" })} />
<SummaryRow label="Hora" value={formatTime(selectedSlot.iso)} /> <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 <ActionBar
hidden={!!result} hidden={!!result}
step={step} step={step}
stepCount={stepCount}
goBack={goBack} goBack={goBack}
onPrimary={onPrimary} onPrimary={onPrimary}
primaryLabel={primaryLabel} primaryLabel={primaryLabel}
@@ -301,11 +323,11 @@ export default function BookingPage() {
); );
} }
function TopBar({ name, industry, step }: { name: string; industry: string; step: number }) { 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); const current = steps.find((s) => s.n === step);
return ( return (
<header className="sticky top-0 z-30 border-b border-slate-200/70 bg-white/85 backdrop-blur-md"> <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 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"> <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" /> <Sparkles className="h-4 w-4" />
@@ -319,10 +341,10 @@ function TopBar({ name, industry, step }: { name: string; industry: string; step
{step > 0 && ( {step > 0 && (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="hidden text-[11px] font-semibold uppercase tracking-wide text-slate-400 sm:inline"> <span className="hidden text-[11px] font-semibold uppercase tracking-wide text-slate-400 sm:inline">
Paso {step} de 4 Paso {step} de {stepCount}
</span> </span>
<div className="hidden items-center gap-1 md:flex"> <div className="hidden items-center gap-1 md:flex">
{STEPS.map((s) => ( {steps.map((s) => (
<div <div
key={s.n} key={s.n}
className={cn( 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"> <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>{step}</span>
<span className="font-semibold text-brand-400">/</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 className="text-brand-300">·</span>
<span>{current?.label}</span> <span>{current?.label}</span>
</div> </div>
@@ -556,6 +578,7 @@ function DateTimePicker({
date, date,
onDate, onDate,
min, min,
max,
slots, slots,
loading, loading,
isError, isError,
@@ -565,28 +588,42 @@ function DateTimePicker({
date: string; date: string;
onDate: (d: string) => void; onDate: (d: string) => void;
min: string; min: string;
max?: string;
slots: PublicSlot[]; slots: PublicSlot[];
loading: boolean; loading: boolean;
isError: boolean; isError: boolean;
selectedSlot: PublicSlot | null; selectedSlot: PublicSlot | null;
onPick: (s: PublicSlot) => void; 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 ( return (
<div className="space-y-4"> <div className="md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] md:items-start md:gap-8">
<div> {/* Columna izquierda: calendario */}
<div className="card p-4">
<label className="label flex items-center gap-1.5"> <label className="label flex items-center gap-1.5">
<CalendarDays className="h-3.5 w-3.5 text-slate-400" /> Fecha <CalendarDays className="h-3.5 w-3.5 text-slate-400" /> Fecha
</label> </label>
<input <MonthCalendar value={date} min={min} max={max} onSelect={(d) => onDate(d)} />
type="date" <div className="mt-3 flex flex-wrap items-center gap-3 text-[11px] text-slate-500">
className="input" <span className="inline-flex items-center gap-1"><span className="h-2 w-2 rounded-full bg-accent-500" /> Con cupo</span>
value={date} <span className="inline-flex items-center gap-1"><span className="h-2 w-2 rounded-full bg-brand-400" /> Hoy</span>
min={min} </div>
onChange={(e) => e.target.value && onDate(e.target.value)}
/>
</div> </div>
<div> {/* Columna derecha: horarios */}
<div className="mt-4 md:mt-0">
<label className="label flex items-center gap-1.5"> <label className="label flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 text-slate-400" /> Horario disponible <Clock className="h-3.5 w-3.5 text-slate-400" /> Horario disponible
</label> </label>
@@ -597,14 +634,12 @@ function DateTimePicker({
<span className="text-sm font-medium">Buscando horarios</span> <span className="text-sm font-medium">Buscando horarios</span>
</div> </div>
)} )}
{!loading && isError && ( {!loading && isError && (
<div className="flex flex-col items-center gap-2 py-8 text-center"> <div className="flex flex-col items-center gap-2 py-8 text-center">
<AlertCircle className="h-6 w-6 text-rose-400" /> <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> <p className="text-sm text-slate-500">No pudimos cargar los horarios. Intenta otra fecha.</p>
</div> </div>
)} )}
{!loading && !isError && slots.length === 0 && ( {!loading && !isError && slots.length === 0 && (
<div className="flex flex-col items-center gap-2 py-8 text-center"> <div className="flex flex-col items-center gap-2 py-8 text-center">
<CalendarDays className="h-6 w-6 text-slate-300" /> <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> <p className="text-xs text-slate-400">Prueba con otra fecha o especialista.</p>
</div> </div>
)} )}
{!loading && !isError && slots.length > 0 && ( {!loading && !isError && slots.length > 0 && (
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4"> <div className="space-y-4">
{slots.map((slot) => { <SlotGroup title="Mañana" slots={morning} selectedSlot={selectedSlot} onPick={onPick} />
const active = selectedSlot?.iso === slot.iso && selectedSlot?.employee_id === slot.employee_id; <SlotGroup title="Tarde" slots={afternoon} selectedSlot={selectedSlot} onPick={onPick} />
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>
@@ -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({ function DetailsForm({
client, client,
setClient, setClient,
@@ -661,7 +721,7 @@ function DetailsForm({
<div className="relative"> <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" /> <User className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input <input
className="input pl-9" className="input pl-10"
value={client.name} value={client.name}
onChange={(e) => setClient({ ...client, name: e.target.value })} onChange={(e) => setClient({ ...client, name: e.target.value })}
placeholder="Tu nombre" placeholder="Tu nombre"
@@ -675,7 +735,7 @@ function DetailsForm({
<div className="relative"> <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" /> <Phone className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input <input
className="input pl-9" className="input pl-10"
inputMode="tel" inputMode="tel"
value={client.phone} value={client.phone}
onChange={(e) => setClient({ ...client, phone: e.target.value })} 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" /> <Mail className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input <input
type="email" type="email"
className="input pl-9" className="input pl-10"
value={client.email} value={client.email}
onChange={(e) => setClient({ ...client, email: e.target.value })} onChange={(e) => setClient({ ...client, email: e.target.value })}
placeholder="[email protected]" placeholder="[email protected]"
@@ -725,6 +785,7 @@ function SummaryRow({ label, value }: { label: string; value: string }) {
function ActionBar({ function ActionBar({
hidden, hidden,
step, step,
stepCount,
goBack, goBack,
onPrimary, onPrimary,
primaryLabel, primaryLabel,
@@ -736,6 +797,7 @@ function ActionBar({
}: { }: {
hidden: boolean; hidden: boolean;
step: number; step: number;
stepCount: number;
goBack: () => void; goBack: () => void;
onPrimary: () => void; onPrimary: () => void;
primaryLabel: string; primaryLabel: string;
@@ -748,7 +810,7 @@ function ActionBar({
if (hidden) return null; if (hidden) return null;
return ( return (
<div className="sticky bottom-0 z-30 border-t border-slate-200/70 bg-white/90 backdrop-blur-md"> <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 && ( {step > 1 && (
<button onClick={goBack} className="btn btn-ghost shrink-0" type="button"> <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> <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"> <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>
)} )}
</div> </div>
@@ -802,7 +864,7 @@ function Confirmation({
const currency = result.business.currency_symbol || "$"; const currency = result.business.currency_symbol || "$";
return ( return (
<div className="flex min-h-screen flex-col bg-gradient-to-b from-brand-50/60 to-[#f6f7fb]"> <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"> <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="w-full max-w-md animate-slide-up">
<div className="card overflow-hidden p-0 text-center shadow-card"> <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)} /> <SummaryRow label="Hora" value={formatTime(appointment.start_at)} />
<div className="h-px bg-slate-100" /> <div className="h-px bg-slate-100" />
<SummaryRow label="Especialista" value={appointment.employee_name} /> <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="h-px bg-slate-100" />
<div className="flex items-center justify-between gap-3 px-3 py-3"> <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-xs font-semibold uppercase tracking-wide text-slate-400">Total</span>