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
+3 -1
View File
@@ -13,6 +13,7 @@ import { ClientDetailPage } from "./pages/ClientDetailPage";
import { CashPage } from "./pages/CashPage";
import { NotificationsPage } from "./pages/NotificationsPage";
import { SettingsPage } from "./pages/SettingsPage";
import { MyPerformancePage } from "./pages/MyPerformancePage";
import { AdminOverviewPage } from "./pages/admin/AdminOverviewPage";
import { AdminBusinessesPage } from "./pages/admin/AdminBusinessesPage";
import { AdminBusinessDetailPage } from "./pages/admin/AdminBusinessDetailPage";
@@ -25,7 +26,7 @@ function Protected() {
<div className="flex h-screen items-center justify-center bg-[#f6f7fb]">
<div className="flex flex-col items-center gap-3 text-slate-500">
<div className="h-9 w-9 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
<span className="text-sm font-medium">Cargando AgendaPro</span>
<span className="text-sm font-medium">Cargando AgendaMax</span>
</div>
</div>
);
@@ -59,6 +60,7 @@ function Protected() {
<Route path="/calendar" element={<CalendarPage />} />
<Route path="/clients" element={<ClientsPage />} />
<Route path="/clients/:id" element={<ClientDetailPage />} />
<Route path="/me" element={<MyPerformancePage />} />
{user.role === "owner" && <Route path="/employees" element={<EmployeesPage />} />}
{user.role === "owner" && <Route path="/services" element={<ServicesPage />} />}
{user.role === "owner" && <Route path="/tickets" element={<TicketsPage />} />}
+1 -1
View File
@@ -40,7 +40,7 @@ export function AdminShell() {
<ShieldCheck className="h-5 w-5" />
</div>
<div className="leading-tight">
<div className="text-sm font-extrabold tracking-tight text-slate-900">AgendaPro</div>
<div className="text-sm font-extrabold tracking-tight text-slate-900">AgendaMax</div>
<div className="text-[11px] font-medium text-slate-500">Consola Admin</div>
</div>
</div>
+17 -6
View File
@@ -14,6 +14,7 @@ import {
Wallet,
Bell,
Settings,
BarChart3,
} from "lucide-react";
import { useAuth } from "../lib/auth";
import { initials, cn } from "../lib/format";
@@ -24,12 +25,16 @@ interface NavItem {
label: string;
icon: typeof CalendarDays;
ownerOnly?: boolean;
employeeOnly?: boolean;
}
const DEMO = import.meta.env.DEV || import.meta.env.VITE_DEMO_UI === "1";
const NAV: NavItem[] = [
{ to: "/dashboard", label: "Tablero", icon: LayoutDashboard, ownerOnly: true },
{ to: "/calendar", label: "Calendario", icon: CalendarDays },
{ to: "/clients", label: "Clientes", icon: Users },
{ to: "/me", label: "Mi desempeño", icon: BarChart3, employeeOnly: true },
{ to: "/employees", label: "Empleados", icon: UserCircle, ownerOnly: true },
{ to: "/services", label: "Servicios", icon: Scissors, ownerOnly: true },
{ to: "/cash", label: "Caja", icon: Wallet, ownerOnly: true },
@@ -45,7 +50,11 @@ export function AppShell() {
if (!user) return null;
const isOwner = user.role === "owner";
const items = NAV.filter((n) => !n.ownerOnly || isOwner);
const items = NAV.filter((n) => {
if (n.ownerOnly) return isOwner;
if (n.employeeOnly) return user.role === "employee" || (isOwner && !!user.employee_id);
return true;
});
const SidebarContent = (
<>
@@ -54,7 +63,7 @@ export function AppShell() {
<Sparkles className="h-5 w-5" />
</div>
<div className="leading-tight">
<div className="text-sm font-extrabold tracking-tight text-slate-900">AgendaPro</div>
<div className="text-sm font-extrabold tracking-tight text-slate-900">AgendaMax</div>
<div className="text-[11px] font-medium text-slate-500">{business?.name ?? "Tu negocio"}</div>
</div>
</div>
@@ -87,9 +96,11 @@ export function AppShell() {
</nav>
<div className="mt-auto space-y-2 px-2 pb-3">
<div className="rounded-xl border border-slate-200 bg-slate-50/60 p-2.5">
<DemoSwitcher />
</div>
{DEMO && (
<div className="rounded-xl border border-slate-200 bg-slate-50/60 p-2.5">
<DemoSwitcher />
</div>
)}
<div className="flex items-center gap-3 rounded-xl p-2">
<div
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
@@ -151,7 +162,7 @@ export function AppShell() {
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-brand-500 to-brand-700 text-white">
<Sparkles className="h-4 w-4" />
</div>
<span className="text-sm font-extrabold">AgendaPro</span>
<span className="text-sm font-extrabold">AgendaMax</span>
</div>
</header>
<main className="flex-1 overflow-y-auto">
+251 -101
View File
@@ -11,6 +11,10 @@ import {
Clock,
DollarSign,
Stethoscope,
Phone,
Mail,
Tag,
AlertTriangle,
} from "lucide-react";
import { api } from "../lib/api";
import { useAuth } from "../lib/auth";
@@ -32,6 +36,13 @@ const TIME_SUGGESTIONS = [
"16:00","16:30","17:00","17:30","18:00","18:30","19:00","19:30","20:00",
];
// joinAppointment attaches notes + no_show_count directly on the client object,
// but shared/types Appointment.client Pick omits them — augment locally.
type ApptClientMeta = NonNullable<Appointment["client"]> & {
notes?: string | null;
no_show_count?: number;
};
function toLocalInput(iso: string) {
const d = new Date(iso);
if (isNaN(d.getTime())) return { date: "", time: "10:00" };
@@ -51,9 +62,18 @@ export function AppointmentModal({ open, onClose, appointment, draftSlot }: Prop
const [serviceId, setServiceId] = useState<number | null>(null);
const [employeeId, setEmployeeId] = useState<number | null>(user?.employee_id ?? null);
const [clientId, setClientId] = useState<number | null>(null);
const [pickedClientName, setPickedClientName] = useState<string>("");
const [pickedClientPhone, setPickedClientPhone] = useState<string>("");
const [pickedClientEmail, setPickedClientEmail] = useState<string>("");
const [pickedClientTags, setPickedClientTags] = useState<string>("");
const [pickedClientNotes, setPickedClientNotes] = useState<string>("");
const [pickedClientNoShows, setPickedClientNoShows] = useState<number>(0);
const [clientMode, setClientMode] = useState<"existing" | "new">("existing");
const [newClient, setNewClient] = useState({ name: "", phone: "", email: "", notes: "" });
const [search, setSearch] = useState("");
// When editing, hide the picker behind an explicit "Cambiar cliente" action so the
// current client is always visible — never a stray "?" or empty search field.
const [changingClient, setChangingClient] = useState(false);
const [dateInput, setDateInput] = useState("");
const [timeInput, setTimeInput] = useState("10:00");
const [duration, setDuration] = useState(60);
@@ -88,7 +108,15 @@ export function AppointmentModal({ open, onClose, appointment, draftSlot }: Prop
setServiceId(appointment.service_id);
setEmployeeId(appointment.employee_id);
setClientId(appointment.client_id);
setPickedClientName(appointment.client?.name ?? "");
setPickedClientPhone(appointment.client?.phone ?? "");
setPickedClientEmail(appointment.client?.email ?? "");
setPickedClientTags(appointment.client?.tags ?? "");
const ac = appointment.client as ApptClientMeta | undefined;
setPickedClientNotes(ac?.notes ?? "");
setPickedClientNoShows(Number(ac?.no_show_count ?? 0));
setClientMode("existing");
setChangingClient(false);
const d = toLocalInput(appointment.start_at);
setDateInput(d.date);
setTimeInput(d.time);
@@ -107,7 +135,14 @@ export function AppointmentModal({ open, onClose, appointment, draftSlot }: Prop
setServiceId(null);
setEmployeeId(user?.employee_id ?? null);
setClientId(null);
setPickedClientName("");
setPickedClientPhone("");
setPickedClientEmail("");
setPickedClientTags("");
setPickedClientNotes("");
setPickedClientNoShows(0);
setClientMode("existing");
setChangingClient(true);
setNewClient({ name: "", phone: "", email: "", notes: "" });
setSearch("");
const d = toLocalInput(draftSlot.start);
@@ -297,122 +332,237 @@ export function AppointmentModal({ open, onClose, appointment, draftSlot }: Prop
<div>
<div className="mb-2 flex items-center justify-between">
<label className="label mb-0">Cliente</label>
<div className="flex rounded-lg border border-slate-200 p-0.5">
{!isEditing && clientMode === "existing" && !clientId && (
<div className="flex rounded-lg border border-slate-200 p-0.5">
<button
onClick={() => setClientMode("existing")}
className={cn(
"rounded-md px-2.5 py-1 text-xs font-semibold transition-colors",
clientMode === "existing" ? "bg-brand-500 text-white" : "text-slate-600 hover:bg-slate-100"
)}
>
Existente
</button>
<button
onClick={() => setClientMode("new")}
className="rounded-md px-2.5 py-1 text-xs font-semibold text-slate-600 transition-colors hover:bg-slate-100"
>
<UserPlus className="mr-1 inline h-3 w-3" /> Nuevo
</button>
</div>
)}
{!isEditing && clientId && (
<button
onClick={() => setClientMode("existing")}
className={cn(
"rounded-md px-2.5 py-1 text-xs font-semibold transition-colors",
clientMode === "existing" ? "bg-brand-500 text-white" : "text-slate-600 hover:bg-slate-100"
)}
type="button"
onClick={() => {
setClientId(null);
setPickedClientName("");
setPickedClientPhone("");
setPickedClientEmail("");
setPickedClientTags("");
setPickedClientNotes("");
setPickedClientNoShows(0);
setSearch("");
}}
className="rounded-lg border border-slate-200 bg-white px-2.5 py-1 text-xs font-semibold text-slate-600 transition-colors hover:bg-slate-50"
>
Existente
Cambiar cliente
</button>
)}
{isEditing && !changingClient && (
<button
onClick={() => setClientMode("new")}
className={cn(
"rounded-md px-2.5 py-1 text-xs font-semibold transition-colors",
clientMode === "new" ? "bg-brand-500 text-white" : "text-slate-600 hover:bg-slate-100"
)}
type="button"
onClick={() => setChangingClient(true)}
className="rounded-lg border border-slate-200 bg-white px-2.5 py-1 text-xs font-semibold text-slate-600 transition-colors hover:bg-slate-50"
>
<UserPlus className="mr-1 inline h-3 w-3" /> Nuevo
Cambiar cliente
</button>
</div>
)}
{isEditing && changingClient && clientId && (
<button
type="button"
onClick={() => {
setChangingClient(false);
setSearch("");
}}
className="rounded-lg border border-slate-200 bg-white px-2.5 py-1 text-xs font-semibold text-slate-600 transition-colors hover:bg-slate-50"
>
Mantener actual
</button>
)}
</div>
{clientMode === "existing" ? (
<div>
<div className="relative">
<Search 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"
placeholder="Buscar por nombre, teléfono o correo…"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setClientId(null);
}}
/>
{searching && <Spinner className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400" />}
</div>
{clientId && (
<div className="mt-2 flex items-center gap-2 rounded-xl bg-brand-50 px-3 py-2">
<Avatar name={searchResults?.clients.find((c) => c.id === clientId)?.name ?? "?"} size={28} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-bold text-slate-800">
{searchResults?.clients.find((c) => c.id === clientId)?.name}
</div>
{/* Current client card (edit mode, or after picking existing client) */}
{!changingClient && clientId && pickedClientName && (
<div className="rounded-xl border border-brand-200 bg-brand-50/70 px-3 py-2.5">
<div className="flex items-center gap-2.5">
<Avatar name={pickedClientName} size={32} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<div className="truncate text-sm font-bold text-slate-800">{pickedClientName}</div>
<Check className="h-3.5 w-3.5 shrink-0 text-brand-600" />
</div>
<Check className="h-4 w-4 text-brand-600" />
<div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[11px] text-slate-600">
{pickedClientPhone && (
<span className="inline-flex items-center gap-1">
<Phone className="h-3 w-3 text-slate-400" />
{pickedClientPhone}
</span>
)}
{pickedClientEmail && (
<span className="inline-flex items-center gap-1 truncate">
<Mail className="h-3 w-3 shrink-0 text-slate-400" />
<span className="truncate">{pickedClientEmail}</span>
</span>
)}
{!pickedClientPhone && !pickedClientEmail && (
<span className="text-slate-400">Sin datos de contacto</span>
)}
</div>
</div>
</div>
{pickedClientTags && (
<div className="mt-2 flex flex-wrap items-center gap-1 border-t border-brand-200/60 pt-2">
<Tag className="h-3 w-3 text-slate-400" />
{pickedClientTags
.split(",")
.map((t) => t.trim())
.filter(Boolean)
.map((t) => (
<span key={t} className="chip bg-white text-slate-600">
{t}
</span>
))}
</div>
)}
{!clientId && search && searchResults && (
<div className="mt-2 max-h-48 overflow-y-auto rounded-xl border border-slate-100">
{searchResults.clients.length === 0 ? (
<div className="px-3 py-4 text-center text-sm text-slate-500">
Sin resultados.{" "}
<button onClick={() => setClientMode("new")} className="font-semibold text-brand-600">
Crear nuevo cliente
</button>
</div>
) : (
searchResults.clients.slice(0, 12).map((c) => (
<button
key={c.id}
onClick={() => {
setClientId(c.id);
setSearch(c.name);
}}
className="flex w-full items-center gap-2.5 border-b border-slate-50 px-3 py-2 text-left transition-colors last:border-0 hover:bg-slate-50"
>
<Avatar name={c.name} size={28} color="#94a3b8" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-bold text-slate-800">{c.name}</div>
<div className="truncate text-[11px] text-slate-500">
{c.phone ?? c.email ?? "Sin contacto"}
</div>
{pickedClientNoShows >= 1 && (
<div className="mt-2 flex flex-wrap items-center gap-1 border-t border-brand-200/60 pt-2">
<span
title="Ausencias previas sin avisar"
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold",
pickedClientNoShows >= 2
? "bg-amber-100 text-amber-800 ring-1 ring-amber-300"
: "bg-amber-50 text-amber-700"
)}
>
<AlertTriangle className="h-3 w-3" />
Ausencias previas: {pickedClientNoShows}
</span>
</div>
)}
{pickedClientNotes && (
<div className="mt-2 rounded-lg border border-brand-200/60 bg-white/60 px-2.5 py-1.5 text-[11px] text-slate-700">
<div className="font-semibold text-slate-600">Notas del cliente</div>
<div className="mt-0.5 whitespace-pre-wrap break-words">{pickedClientNotes}</div>
</div>
)}
</div>
)}
{/* Picker (existing/new): visible when explicitly changing (edit) OR
when no existing client has been picked yet (create). Once an existing
client is picked in create mode, the card above takes over. */}
{(changingClient || (!isEditing && !clientId)) && (
<>
{clientMode === "existing" ? (
<div>
<div className="relative">
<Search 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"
placeholder="Buscar por nombre, teléfono o correo…"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setClientId(null);
setPickedClientName("");
setPickedClientPhone("");
setPickedClientEmail("");
setPickedClientTags("");
setPickedClientNotes("");
setPickedClientNoShows(0);
}}
/>
{searching && <Spinner className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400" />}
</div>
{!clientId && search && searchResults && (
<div className="mt-2 max-h-48 overflow-y-auto rounded-xl border border-slate-100">
{searchResults.clients.length === 0 ? (
<div className="px-3 py-4 text-center text-sm text-slate-500">
Sin resultados.{" "}
<button onClick={() => setClientMode("new")} className="font-semibold text-brand-600">
Crear nuevo cliente
</button>
</div>
{c.tags && (
<span className="chip bg-slate-100 text-slate-600">{c.tags.split(",")[0]}</span>
)}
</button>
))
) : (
searchResults.clients.slice(0, 12).map((c) => (
<button
key={c.id}
onClick={() => {
setClientId(c.id);
setPickedClientName(c.name);
setPickedClientPhone(c.phone ?? "");
setPickedClientEmail(c.email ?? "");
setPickedClientTags(c.tags ?? "");
setPickedClientNotes(c.notes ?? "");
setPickedClientNoShows(Number(c.stats?.no_show_count ?? 0));
setSearch(c.name);
if (isEditing) setChangingClient(false);
}}
className="flex w-full items-center gap-2.5 border-b border-slate-50 px-3 py-2 text-left transition-colors last:border-0 hover:bg-slate-50"
>
<Avatar name={c.name} size={28} color="#94a3b8" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-bold text-slate-800">{c.name}</div>
<div className="truncate text-[11px] text-slate-500">
{c.phone ?? c.email ?? "Sin contacto"}
</div>
</div>
{c.tags && (
<span className="chip bg-slate-100 text-slate-600">{c.tags.split(",")[0]}</span>
)}
</button>
))
)}
</div>
)}
{!clientId && !search && (
<p className="mt-1 text-xs text-slate-400">Escribe para buscar un cliente existente.</p>
)}
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="label">Nombre completo *</label>
<input
className="input"
value={newClient.name}
onChange={(e) => setNewClient({ ...newClient, name: e.target.value })}
placeholder="Nombre del cliente"
/>
</div>
<div>
<label className="label">Teléfono</label>
<input
className="input"
value={newClient.phone}
onChange={(e) => setNewClient({ ...newClient, phone: e.target.value })}
placeholder="+52 …"
/>
</div>
<div>
<label className="label">Correo</label>
<input
className="input"
value={newClient.email}
onChange={(e) => setNewClient({ ...newClient, email: e.target.value })}
placeholder="[email protected]"
/>
</div>
</div>
)}
{!clientId && !search && (
<p className="mt-1 text-xs text-slate-400">Escribe para buscar un cliente existente.</p>
)}
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="label">Nombre completo *</label>
<input
className="input"
value={newClient.name}
onChange={(e) => setNewClient({ ...newClient, name: e.target.value })}
placeholder="Nombre del cliente"
/>
</div>
<div>
<label className="label">Teléfono</label>
<input
className="input"
value={newClient.phone}
onChange={(e) => setNewClient({ ...newClient, phone: e.target.value })}
placeholder="+52 …"
/>
</div>
<div>
<label className="label">Correo</label>
<input
className="input"
value={newClient.email}
onChange={(e) => setNewClient({ ...newClient, email: e.target.value })}
placeholder="[email protected]"
/>
</div>
</div>
</>
)}
</div>
+3
View File
@@ -3,6 +3,8 @@ import { ChevronDown, UserCog, Check } from "lucide-react";
import { useAuth } from "../lib/auth";
import { api } from "../lib/api";
const DEMO = import.meta.env.DEV || import.meta.env.VITE_DEMO_UI === "1";
export function DemoSwitcher() {
const { user, switchUser } = useAuth();
const [open, setOpen] = useState(false);
@@ -13,6 +15,7 @@ export function DemoSwitcher() {
api.auth.demoUsers().then((r) => setUsers(r.users)).catch(() => {});
}, []);
if (!DEMO) return null;
if (!user) return null;
const pick = async (email: string) => {
+25 -1
View File
@@ -66,6 +66,12 @@ body {
color: #0f172a;
}
.fc .fc-toolbar.fc-header-toolbar {
position: sticky;
top: 0;
z-index: 10;
background: #ffffff;
padding: 0.4rem 0.6rem;
margin: -0.4rem -0.6rem 0;
flex-wrap: wrap;
gap: 0.5rem 0.75rem;
}
@@ -101,11 +107,18 @@ body {
text-transform: capitalize;
box-shadow: none !important;
}
.fc .fc-button:not(:disabled):hover {
.fc .fc-button:not(:disabled):not(.fc-button-active):hover {
background: #f3f4f6;
}
.fc .fc-button-primary:not(:disabled).fc-button-active {
color: #ffffff;
background: var(--fc-button-active-bg-color, #3b66ff);
border-color: var(--fc-button-active-border-color, #3b66ff);
}
.fc .fc-button-primary:not(:disabled).fc-button-active:hover {
background: #2447f5;
border-color: #2447f5;
color: #ffffff;
}
.fc .fc-button:disabled {
opacity: 0.5;
@@ -136,6 +149,7 @@ body {
font-weight: 600 !important;
cursor: pointer;
transition: transform 0.12s, box-shadow 0.12s;
overflow: hidden;
}
.fc-event:hover {
transform: translateY(-1px);
@@ -149,6 +163,16 @@ body {
}
.fc-timegrid-event {
overflow: hidden;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
}
.fc-timegrid-event-harness {
margin-right: 2px !important;
}
/* When slotEventOverlap is false, FullCalendar puts a small gap between
side-by-side events; tighten it a touch so each event gets more readable
width without bleeding into its neighbour. */
.fc .fc-timegrid-col-events {
column-gap: 2px;
}
/* List view (great on mobile) */
+25 -1
View File
@@ -120,7 +120,8 @@ export const api = {
remove: (id: number) => request<{ ok: boolean }>(`/appointments/${id}`, { method: "DELETE" }),
},
dashboard: {
overview: () => request<DashboardOverview>("/dashboard/overview"),
overview: (range?: number) =>
request<DashboardOverview>(`/dashboard/overview${range ? `?range=${range}` : ""}`),
bestEmployees: (range = "30") => request<{ employees: RankedEmployee[] }>(`/dashboard/best-employees?range=${range}`),
bestServices: (range = "30") => request<{ services: RankedService[] }>(`/dashboard/best-services?range=${range}`),
topTickets: (range = "30", limit = 8) =>
@@ -135,6 +136,29 @@ export const api = {
tickets: (limit = 50) => request<{ tickets: Ticket[] }>(`/dashboard/tickets?limit=${limit}`),
commissions: (range = "30") => request<{ rows: any[]; total: number }>(`/dashboard/commissions?range=${range}`),
},
me: {
performance: () =>
request<{
appointments_today: number;
upcoming: number;
revenue_30d: number;
avg_rating: number;
share_pct: number;
next_appointment: { start_at: string; service_name: string; client_name: string } | null;
}>("/me/performance"),
commissions: () =>
request<{
rows: {
appointment_id: number | null;
service_name: string;
client_name: string;
amount: number;
commission: number;
created_at: string;
}[];
total: number;
}>("/me/commissions"),
},
settings: {
get: () => request<{ settings: any }>("/settings"),
update: (data: any) => request<{ settings: any }>("/settings", { method: "PATCH", body: JSON.stringify(data) }),
+3 -14
View File
@@ -1,3 +1,6 @@
import type { BookResponse } from "../../shared/types";
export type { BookResponse };
const BASE = "/api/public";
export interface PublicBusiness {
@@ -64,20 +67,6 @@ export interface BookPayload {
client: BookClient;
}
export interface BookResponse {
appointment: {
id: number;
start_at: string;
price: number;
service_name: string;
employee_id: number;
employee_name: string;
reasons: string[];
};
business: { name: string; currency_symbol: string };
client_created: boolean;
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
...init,
+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>
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />