feat: rebrand AgendaPro -> AgendaMax + calendar fixes + current-week demo seed

- Rebrand all user-facing text to AgendaMax
- Fix sidebar scrolling: card container no longer overflows html (flex h-full min-h-0)
- Fix calendar toolbar hover: active buttons keep readable contrast
- Sticky calendar toolbar (month/week/day always visible on scroll)
- Seed guarantees 2-4 appointments per day for current week (Mon-Sat)
This commit is contained in:
AgendaPro Dev
2026-07-27 10:11:16 -06:00
parent 4227db0c8b
commit d796538fd9
57 changed files with 2477 additions and 322 deletions
+62 -11
View File
@@ -9,9 +9,10 @@ import type {
EventClickArg,
EventDropArg,
} from "@fullcalendar/core";
import type { EventResizeDoneArg } from "@fullcalendar/interaction";
import esLocale from "@fullcalendar/core/locales/es";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CalendarDays, Plus, Filter, RotateCcw } from "lucide-react";
import { CalendarDays, Plus, Filter, RotateCcw, AlertTriangle } from "lucide-react";
import { api } from "../lib/api";
import { useAuth } from "../lib/auth";
import type { Appointment } from "../../shared/types";
@@ -30,6 +31,8 @@ export function CalendarPage() {
const { user } = useAuth();
const qc = useQueryClient();
const calRef = useRef<FullCalendar>(null);
const pendingRevert = useRef<(() => void) | null>(null);
const [moveError, setMoveError] = useState<string | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Appointment | null>(null);
const [draftSlot, setDraftSlot] = useState<{ start: string; end: string } | null>(null);
@@ -100,16 +103,27 @@ export function CalendarPage() {
const moveMutation = useMutation({
mutationFn: async ({ id, start, end }: { id: number; start: string; end: string }) =>
api.appointments.update(id, { start_at: start, end_at: end }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["appointments"] }),
onSuccess: () => {
pendingRevert.current = null;
setMoveError(null);
qc.invalidateQueries({ queryKey: ["appointments"] });
},
onError: () => {
pendingRevert.current?.();
pendingRevert.current = null;
setMoveError("No se pudo reagendar la cita (quizá se ocupó el horario). Se regresó a su lugar.");
},
});
const onDrop = (arg: EventDropArg) => {
pendingRevert.current = arg.revert;
const id = Number(arg.event.id);
const start = arg.event.start!.toISOString();
const end = (arg.event.end ?? new Date(arg.event.start!.getTime() + 60 * 60000)).toISOString();
moveMutation.mutate({ id, start, end });
};
const onResize = (arg: any) => {
const onResize = (arg: EventResizeDoneArg) => {
pendingRevert.current = arg.revert;
const id = Number(arg.event.id);
moveMutation.mutate({
id,
@@ -164,6 +178,21 @@ export function CalendarPage() {
}
/>
{moveError && (
<div className="mx-5 mt-2 flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 sm:mx-7">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
<span className="flex-1">{moveError}</span>
<button
type="button"
onClick={() => setMoveError(null)}
className="shrink-0 text-amber-500 hover:text-amber-700"
aria-label="Cerrar aviso"
>
×
</button>
</div>
)}
<div className="flex flex-wrap items-center gap-3 px-5 pb-3 pt-4 sm:px-7">
{isOwner && (
<div className="flex min-w-0 flex-1 items-center gap-2 sm:flex-none">
@@ -198,7 +227,7 @@ export function CalendarPage() {
</div>
<div className="flex-1 overflow-y-auto px-5 pb-6 sm:px-7">
<div className="card overflow-x-auto p-3 sm:p-4">
<div className="card flex h-full min-h-0 flex-col overflow-auto p-3 sm:p-4">
{isLoading && !data && (
<div className="flex h-64 items-center justify-center">
<Spinner className="h-6 w-6 text-brand-500" />
@@ -252,6 +281,8 @@ export function CalendarPage() {
eventResizableFromStart
eventDurationEditable
eventStartEditable
slotEventOverlap={false}
eventMinHeight={28}
dayMaxEvents={isMobile ? 2 : 3}
eventTimeFormat={{
hour: "2-digit",
@@ -297,17 +328,37 @@ function EventContent({ arg }: { arg: any }) {
const a = arg.event.extendedProps.appointment as Appointment | undefined;
if (!a) return <div>{arg.timeText} {arg.event.title}</div>;
const c = statusColor(a.status);
const client = a.client as (NonNullable<Appointment["client"]> & {
notes?: string | null;
no_show_count?: number;
}) | undefined;
const risky =
(client?.no_show_count ?? 0) >= 2 || !!client?.notes?.startsWith("[Riesgo");
const isTime = arg.view.type !== "dayGridMonth";
// When the event is rendered in a narrow column (because FullCalendar
// splits overlapping events side-by-side) we drop the employee line so
// client + service still fit without bleeding into adjacent slots.
const isNarrow = arg.view.type === "timeGridWeek" || arg.view.type === "timeGridDay";
const dur = a.service?.duration_min;
return (
<div className="flex h-full flex-col gap-0.5 overflow-hidden">
<div className="flex items-center gap-1">
<div className="flex h-full min-h-0 flex-col gap-0.5 overflow-hidden">
<div className="flex items-center gap-1 leading-none">
<span className="h-1.5 w-1.5 shrink-0 rounded-full" style={{ background: c.dot }} />
<span className="text-[10px] font-semibold opacity-70">{arg.timeText}</span>
<span className="truncate text-[10px] font-semibold opacity-70">{arg.timeText}</span>
</div>
<div className="truncate font-bold leading-tight">{a.client?.name}</div>
<div className="truncate text-[10px] opacity-80">{a.service?.name}</div>
{isTime && a.employee && (
<div className="mt-auto truncate text-[10px] opacity-70">con {a.employee.name}</div>
<div className="flex items-center gap-1">
<span className="truncate font-bold leading-tight">{a.client?.name ?? "Cliente"}</span>
{risky && (
<span title="Ausencias previas sin avisar" aria-label="Ausencias previas sin avisar">
<AlertTriangle className="h-3 w-3 shrink-0 text-amber-500" />
</span>
)}
</div>
<div className="truncate text-[10px] leading-tight opacity-80">
{a.service?.name}{dur ? ` (${dur} min)` : ""}
</div>
{isTime && a.employee && !isNarrow && (
<div className="mt-auto truncate text-[10px] leading-tight opacity-70">con {a.employee.name}</div>
)}
</div>
);
+17 -9
View File
@@ -24,6 +24,7 @@ import {
Flame,
Trophy,
Coins,
Wallet,
} from "lucide-react";
import { api } from "../lib/api";
import { useAuth } from "../lib/auth";
@@ -50,8 +51,8 @@ export function DashboardPage() {
const symbol = business?.currency_symbol ?? "$";
const { data: overview, isLoading: ovLoading } = useQuery({
queryKey: ["dashboard", "overview"],
queryFn: () => api.dashboard.overview(),
queryKey: ["dashboard", "overview", range],
queryFn: () => api.dashboard.overview(Number(range)),
});
const { data: emp } = useQuery({
queryKey: ["dashboard", "best-employees", range],
@@ -118,13 +119,20 @@ export function DashboardPage() {
<div className="flex-1 space-y-5 px-5 py-5 sm:px-7">
{/* KPIs */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-5">
<KpiCard
title="Ingresos de hoy"
value={ovLoading ? "…" : formatCurrency(overview?.revenue_today ?? 0, symbol)}
sub="día de hoy"
icon={Wallet}
color="#10b981"
/>
<KpiCard
title="Ingresos del período"
value={ovLoading ? "…" : formatCurrency(overview?.revenue_month ?? 0, symbol)}
sub="últimos 30 días"
value={ovLoading ? "…" : formatCurrency(overview?.revenue_range ?? 0, symbol)}
sub={`últimos ${range} días`}
delta={overview?.revenue_change_pct}
deltaSuffix="% vs mes anterior"
deltaSuffix="% vs período anterior"
icon={DollarSign}
color="#3b66ff"
/>
@@ -133,7 +141,7 @@ export function DashboardPage() {
value={ovLoading ? "…" : formatNumber(overview?.appts_upcoming ?? 0)}
sub="próximas"
icon={CalendarCheck}
color="#10b981"
color="#6366f1"
/>
<KpiCard
title="Ticket promedio"
@@ -143,8 +151,8 @@ export function DashboardPage() {
color="#a855f7"
/>
<KpiCard
title="Ocupación"
value={ovLoading ? "…" : `${overview?.occupancy_pct ?? 0}%`}
title="Finalización"
value={ovLoading ? "…" : `${overview?.completion_pct ?? 0}%`}
sub={`${overview?.cancel_rate ?? 0}% cancelación`}
icon={Flame}
color="#f17616"
+2 -2
View File
@@ -130,9 +130,9 @@ export function EmployeesPage() {
)}
<div className="mt-3 flex items-center gap-2 text-[11px] text-slate-400">
<Activity className="h-3 w-3" />
Utilización {e.stats?.utilization_pct ?? 0}%
% de citas {e.stats?.share_pct ?? 0}%
<div className="ml-1 h-1.5 flex-1 overflow-hidden rounded-full bg-slate-100">
<div className="h-full rounded-full bg-brand-500" style={{ width: `${e.stats?.utilization_pct ?? 0}%` }} />
<div className="h-full rounded-full bg-brand-500" style={{ width: `${e.stats?.share_pct ?? 0}%` }} />
</div>
</div>
</div>
+64 -51
View File
@@ -5,11 +5,13 @@ import { useAuth } from "../lib/auth";
import { api } from "../lib/api";
import { Spinner } from "../components/ui";
const DEMO = import.meta.env.DEV || import.meta.env.VITE_DEMO_UI === "1";
export function LoginPage() {
const { login, user } = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState("[email protected]");
const [password, setPassword] = useState("demo1234");
const [email, setEmail] = useState(DEMO ? "[email protected]" : "");
const [password, setPassword] = useState(DEMO ? "demo1234" : "");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [demoUsers, setDemoUsers] = useState<{ email: string; name: string; role: string; avatar_color: string }[]>([]);
@@ -19,6 +21,7 @@ export function LoginPage() {
}, [user, navigate]);
useEffect(() => {
if (!DEMO) return;
api.auth.demoUsers().then((r) => setDemoUsers(r.users)).catch(() => {});
}, []);
@@ -36,20 +39,24 @@ export function LoginPage() {
}
};
const quickLogin = async (mail: string) => {
setEmail(mail);
setPassword("demo1234");
setError(null);
setLoading(true);
try {
await login(mail, "demo1234");
navigate("/", { replace: true });
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
// Demo-only quick login. Dead-code-eliminated from the prod bundle (DEMO is a
// build-time constant), which is why the demo password lives only inside this branch.
const quickLogin = DEMO
? async (mail: string) => {
setEmail(mail);
setPassword("demo1234");
setError(null);
setLoading(true);
try {
await login(mail, "demo1234");
navigate("/", { replace: true });
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
}
: undefined;
return (
<div className="grid min-h-screen lg:grid-cols-2">
@@ -61,7 +68,7 @@ export function LoginPage() {
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-white/15 backdrop-blur">
<Sparkles className="h-5 w-5" />
</div>
<span className="text-lg font-extrabold tracking-tight">AgendaPro</span>
<span className="text-lg font-extrabold tracking-tight">AgendaMax</span>
</div>
<div className="relative z-10 max-w-md">
@@ -87,7 +94,7 @@ export function LoginPage() {
</div>
<div className="relative z-10 text-xs text-brand-200">
© {new Date().getFullYear()} AgendaPro · Demo
© {new Date().getFullYear()} AgendaMax · Demo
</div>
</div>
@@ -98,7 +105,7 @@ export function LoginPage() {
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-brand-500 to-brand-700 text-white">
<Sparkles className="h-6 w-6" />
</div>
<h1 className="text-xl font-extrabold">AgendaPro</h1>
<h1 className="text-xl font-extrabold">AgendaMax</h1>
</div>
<div className="card p-6 shadow-card">
@@ -149,41 +156,47 @@ export function LoginPage() {
</button>
</form>
<div className="mt-5 flex items-center gap-2 text-xs font-medium text-slate-400">
<span className="h-px flex-1 bg-slate-200" />
cuentas demo
<span className="h-px flex-1 bg-slate-200" />
</div>
{DEMO && (
<>
<div className="mt-5 flex items-center gap-2 text-xs font-medium text-slate-400">
<span className="h-px flex-1 bg-slate-200" />
cuentas demo
<span className="h-px flex-1 bg-slate-200" />
</div>
<div className="mt-3 space-y-1.5">
{demoUsers.slice(0, 4).map((u) => (
<button
key={u.email}
onClick={() => quickLogin(u.email)}
disabled={loading}
className="group flex w-full items-center gap-3 rounded-xl border border-slate-200 bg-white px-3 py-2 text-left transition-colors hover:border-brand-300 hover:bg-brand-50"
>
<div
className="flex h-8 w-8 items-center justify-center rounded-full text-[11px] font-bold text-white"
style={{ background: u.avatar_color }}
>
{u.name.split(" ").map((p) => p[0]).slice(0, 2).join("")}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-bold text-slate-800">{u.name}</div>
<div className="truncate text-[11px] text-slate-500">
{u.role === "owner" ? "Dueño" : "Empleado"} · {u.email}
</div>
</div>
<Wand2 className="h-4 w-4 text-slate-300 transition-colors group-hover:text-brand-500" />
</button>
))}
</div>
<div className="mt-3 space-y-1.5">
{demoUsers.slice(0, 4).map((u) => (
<button
key={u.email}
onClick={() => quickLogin?.(u.email)}
disabled={loading}
className="group flex w-full items-center gap-3 rounded-xl border border-slate-200 bg-white px-3 py-2 text-left transition-colors hover:border-brand-300 hover:bg-brand-50"
>
<div
className="flex h-8 w-8 items-center justify-center rounded-full text-[11px] font-bold text-white"
style={{ background: u.avatar_color }}
>
{u.name.split(" ").map((p) => p[0]).slice(0, 2).join("")}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-bold text-slate-800">{u.name}</div>
<div className="truncate text-[11px] text-slate-500">
{u.role === "owner" ? "Dueño" : "Empleado"} · {u.email}
</div>
</div>
<Wand2 className="h-4 w-4 text-slate-300 transition-colors group-hover:text-brand-500" />
</button>
))}
</div>
</>
)}
</div>
<p className="mt-4 text-center text-xs text-slate-400">
Demo · cualquier cuenta usa la contraseña <code className="font-mono font-semibold text-slate-600">demo1234</code>
</p>
{DEMO && (
<p className="mt-4 text-center text-xs text-slate-400">
Demo · cualquier cuenta usa la contraseña <code className="font-mono font-semibold text-slate-600">demo1234</code>
</p>
)}
</div>
</div>
</div>
+209
View File
@@ -0,0 +1,209 @@
import { useQuery } from "@tanstack/react-query";
import {
CalendarCheck,
Clock,
Wallet,
Star,
Coins,
CalendarClock,
} from "lucide-react";
import { api } from "../lib/api";
import { useAuth } from "../lib/auth";
import { PageHeader, Spinner, EmptyState } from "../components/ui";
import { formatCurrency, formatTime, formatDate } from "../lib/format";
export function MyPerformancePage() {
const { business, user } = useAuth();
const symbol = business?.currency_symbol ?? "$";
const { data: perf, isLoading } = useQuery({
queryKey: ["me", "performance"],
queryFn: () => api.me.performance(),
});
const { data: commissions } = useQuery({
queryKey: ["me", "commissions"],
queryFn: () => api.me.commissions(),
});
const next = perf?.next_appointment ?? null;
const commissionRows = commissions?.rows ?? [];
return (
<div className="flex h-full flex-col">
<PageHeader
title="Mi desempeño"
subtitle={user?.name ? `Hola, ${user.name.split(" ")[0]}` : "Tu actividad y comisiones"}
/>
<div className="flex-1 space-y-5 px-5 py-5 sm:px-7">
{/* KPIs */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<StatCard
title="Citas hoy"
value={isLoading ? "…" : String(perf?.appointments_today ?? 0)}
sub="día de hoy"
icon={CalendarCheck}
color="#3b66ff"
/>
<StatCard
title="Próximas"
value={isLoading ? "…" : String(perf?.upcoming ?? 0)}
sub="programadas"
icon={Clock}
color="#6366f1"
/>
<StatCard
title="Ingresos (30 días)"
value={isLoading ? "…" : formatCurrency(perf?.revenue_30d ?? 0, symbol)}
sub="tus ventas"
icon={Wallet}
color="#10b981"
/>
<StatCard
title="Valoración"
value={isLoading ? "…" : `${perf?.avg_rating ?? 0}`}
sub={`${perf?.share_pct ?? 0}% de las citas del negocio`}
icon={Star}
color="#f59e0b"
/>
</div>
{/* Próxima cita + Comisiones total */}
<div className="grid grid-cols-1 gap-5 lg:grid-cols-3">
{/* Próxima cita */}
<div className="card p-5">
<div className="mb-3 flex items-center gap-2">
<CalendarClock className="h-4 w-4 text-brand-600" />
<h3 className="text-sm font-bold text-slate-900">Próxima cita</h3>
</div>
{!next ? (
<EmptyState title="Sin citas próximas" description="No tienes citas programadas por ahora." />
) : (
<div className="rounded-xl bg-brand-50/60 p-4">
<div className="flex items-baseline gap-2">
<span className="text-2xl font-extrabold tracking-tight text-brand-700">
{formatTime(next.start_at)}
</span>
<span className="text-xs font-semibold text-brand-500">
{formatDate(next.start_at, { weekday: "long", day: "numeric", month: "short" })}
</span>
</div>
<div className="mt-3 space-y-1 text-sm">
<div className="font-bold text-slate-900">{next.service_name}</div>
<div className="text-slate-500">{next.client_name}</div>
</div>
</div>
)}
</div>
{/* Comisiones list */}
<div className="card overflow-hidden lg:col-span-2">
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4">
<div className="flex items-center gap-2">
<Coins className="h-4 w-4 text-emerald-600" />
<h3 className="text-sm font-bold text-slate-900">Mis comisiones (30 días)</h3>
</div>
<div className="text-right">
<div className="text-[10px] font-semibold uppercase tracking-wide text-emerald-700">
Total de comisiones
</div>
<div className="text-lg font-extrabold text-emerald-700">
{formatCurrency(commissions?.total ?? 0, symbol)}
</div>
</div>
</div>
{!commissions ? (
<div className="flex h-24 items-center justify-center">
<Spinner />
</div>
) : commissionRows.length === 0 ? (
<EmptyState title="Sin comisiones" description="Aún no tienes ventas en los últimos 30 días." />
) : (
<>
{/* Mobile cards */}
<div className="divide-y divide-slate-50 sm:hidden">
{commissionRows.slice(0, 20).map((r, i) => (
<div key={`${r.appointment_id ?? "t"}-${i}`} className="flex items-center gap-2 px-4 py-2.5">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-bold text-slate-800">{r.service_name}</div>
<div className="truncate text-[11px] text-slate-500">
{r.client_name} · {formatDate(r.created_at)}
</div>
</div>
<div className="shrink-0 text-right">
<div className="text-sm font-extrabold text-emerald-600">
{formatCurrency(r.commission, symbol)}
</div>
<div className="text-[10px] text-slate-400">{formatCurrency(r.amount, symbol)}</div>
</div>
</div>
))}
</div>
{/* Desktop table */}
<div className="hidden overflow-x-auto sm:block">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-[11px] font-bold uppercase tracking-wider text-slate-400">
<th className="px-4 py-2">Servicio</th>
<th className="px-4 py-2">Cliente</th>
<th className="px-4 py-2">Fecha</th>
<th className="px-4 py-2 text-right">Venta</th>
<th className="px-4 py-2 text-right">Comisión</th>
</tr>
</thead>
<tbody>
{commissionRows.slice(0, 50).map((r, i) => (
<tr key={`${r.appointment_id ?? "t"}-${i}`} className="border-t border-slate-50">
<td className="px-4 py-2 font-semibold text-slate-800">{r.service_name}</td>
<td className="px-4 py-2 text-slate-600">{r.client_name}</td>
<td className="px-4 py-2 text-xs text-slate-500">{formatDate(r.created_at)}</td>
<td className="px-4 py-2 text-right text-slate-600">{formatCurrency(r.amount, symbol)}</td>
<td className="px-4 py-2 text-right font-bold text-emerald-600">
{formatCurrency(r.commission, symbol)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
</div>
</div>
</div>
);
}
function StatCard({
title,
value,
sub,
icon: Icon,
color,
}: {
title: string;
value: string;
sub?: string;
icon: React.ComponentType<{ className?: string }>;
color: string;
}) {
return (
<div className="card relative overflow-hidden p-5">
<div className="absolute -right-6 -top-6 h-24 w-24 rounded-full opacity-10" style={{ background: color }} />
<div className="flex items-start justify-between">
<div className="min-w-0">
<div className="text-xs font-semibold uppercase tracking-wider text-slate-500">{title}</div>
<div className="mt-1 text-2xl font-extrabold tracking-tight text-slate-900">{value}</div>
{sub && <div className="mt-0.5 truncate text-xs text-slate-500">{sub}</div>}
</div>
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl text-white shadow-soft"
style={{ background: color }}
>
<Icon className="h-5 w-5" />
</div>
</div>
</div>
);
}
+1 -1
View File
@@ -32,7 +32,7 @@ export function AdminOverviewPage() {
<div className="flex h-full flex-col">
<PageHeader
title="Resumen de la plataforma"
subtitle="Visión general de todos los negocios en AgendaPro"
subtitle="Visión general de todos los negocios en AgendaMax"
actions={
<Link to="/admin/businesses?new=1" className="btn btn-primary">
<Plus className="h-4 w-4" /> Nuevo negocio
+120 -4
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Sparkles,
Clock,
@@ -15,6 +15,8 @@ import {
PartyPopper,
AlertCircle,
Mail,
RefreshCw,
Info,
} from "lucide-react";
import {
getBusiness,
@@ -64,6 +66,9 @@ export default function BookingPage() {
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],
@@ -98,9 +103,16 @@ export default function BookingPage() {
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(() => {
@@ -115,6 +127,7 @@ export default function BookingPage() {
setSelectedSlot(null);
setClient({ name: "", phone: "", email: "", notes: "" });
setResult(null);
setBookError(null);
bookMut.reset();
};
@@ -142,8 +155,28 @@ export default function BookingPage() {
);
}
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} onReset={reset} />;
return (
<Confirmation
result={result}
businessName={business.name}
clientName={client.name.trim()}
hasPolicy={hasPolicy}
policy={policyNote}
onReset={reset}
/>
);
}
if (!business.booking_enabled) {
@@ -177,12 +210,14 @@ export default function BookingPage() {
if (serviceId !== id) {
setServiceId(id);
setSelectedSlot(null);
setBookError(null);
}
};
const pickEmployee = (id: number | null) => {
setEmployeeId(id);
setSelectedSlot(null);
setBookError(null);
};
const pickSlot = (slot: PublicSlot) => {
@@ -193,6 +228,7 @@ export default function BookingPage() {
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,
@@ -267,6 +303,7 @@ export default function BookingPage() {
onDate={(d) => {
setSelectedDate(d);
setSelectedSlot(null);
setBookError(null);
}}
min={todayStr()}
max={maxBookableDate()}
@@ -285,9 +322,30 @@ export default function BookingPage() {
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} />
@@ -705,15 +763,17 @@ 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>
@@ -773,6 +833,37 @@ function DetailsForm({
);
}
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">
@@ -846,7 +937,7 @@ function ActionBar({
function FooterNote({ name }: { name: string }) {
return (
<div className="border-t border-slate-200/60 bg-white/50 px-4 py-3 text-center text-[11px] text-slate-400">
Reserva en línea · {name} · powered by AgendaPro
Reserva en línea · {name} · powered by AgendaMax
</div>
);
}
@@ -854,14 +945,21 @@ function FooterNote({ name }: { name: string }) {
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={[]} />
@@ -879,6 +977,14 @@ function Confirmation({
</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" />
@@ -908,6 +1014,16 @@ function Confirmation({
</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>