feat: public landing page with magic login, brand palette and demo build flag

Adds a public funnel landing at `/` and moves the login to `/login`, rebuilt
around the panel's own colors instead of an invented palette.

Landing (`src/components/landing/`, one section per file, no props):
- Seven funnel sections composed by `LandingPage`. The hero's eight swatches are
  literally `PIE_COLORS` from `DashboardPage`, the same hex values the seed hands
  to avatars and services, so "your colors become your numbers" is literal.
- `BrandMark` becomes the single source for the logo, replicating
  `public/favicon.svg`. Blue is the action surface, orange only ever marks.
- `NotebookVisual` is the one deliberate exception to the palette: it is what the
  product replaces.
- Copy drops all system vocabulary; motion comes from `src/lib/motion.ts` with a
  single easing, and reduced-motion resolves `initial` to the final state so a
  never-firing `whileInView` cannot leave a section invisible forever.

Routing and bundle:
- `homePathFor` is the single definition of each role's destination.
- Landing, login, dashboard and calendar load lazily. Eager, the login dragged
  framer-motion (~40 KB gz) into every panel load and the landing downloaded
  recharts + FullCalendar (~187 KB gz) without charting anything. `clsx` is
  pinned to the `react` chunk because Rollup otherwise assigns it to `charts`,
  making the entry import 111 KB gz for a 200-byte utility.

Responsiveness (iPhone/iPad), verified with `npm run audit:responsive`:
- No touch form field below 16px, `dvh` height utilities, safe-area insets, and
  40px touch targets keyed off `pointer: coarse` rather than `sm:`.
- New `.ld-gutter`: `.safe-x` lives outside `@layer` and beats Tailwind's `px-*`,
  so it left the login's side padding at 0 on anything but an iPhone in
  landscape. No overflow check could see it — there was no overflow, just zero
  margin. The audit now guards it with a `gutter` check.
- `shell-height` no longer fires on pages that legitimately scroll; the static
  `raw-viewport-unit` scan covers those instead.

Production build:
- The Dockerfile now sets `VITE_DEMO_UI=1` as a build arg. `DEMO` is a build-time
  constant, so without it Vite eliminated the magic login and the "Ver como…"
  switcher: the deployed landing promised "no registration" and led to an empty
  form. Verified by building both ways and diffing the bundle.
- Service worker cache bumped to v2 so the orphaned pre-landing chunks get purged
  from returning visitors' caches.

Verified: typecheck clean; unit 39/39, e2e 33/33, admin 17/17, booking 12/12,
landing 13/13 (WebKit), PWA passed against the real production build.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
AgendaPro Dev
2026-07-28 14:25:58 -06:00
co-authored by Claude Opus 5
parent 0b466f33f3
commit 4c19244df9
53 changed files with 7310 additions and 525 deletions
+95 -27
View File
@@ -1,10 +1,9 @@
import { lazy, Suspense } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { AuthProvider, useAuth } from "./lib/auth";
import { LoginPage } from "./pages/LoginPage";
import type { User } from "../shared/types";
import { AppShell } from "./components/AppShell";
import { AdminShell } from "./components/AdminShell";
import { DashboardPage } from "./pages/DashboardPage";
import { CalendarPage } from "./pages/CalendarPage";
import { EmployeesPage } from "./pages/EmployeesPage";
import { ServicesPage } from "./pages/ServicesPage";
import { ClientsPage } from "./pages/ClientsPage";
@@ -19,28 +18,90 @@ import { AdminBusinessesPage } from "./pages/admin/AdminBusinessesPage";
import { AdminBusinessDetailPage } from "./pages/admin/AdminBusinessDetailPage";
import BookingPage from "./pages/public/BookingPage";
function Protected() {
const { user, loading } = useAuth();
if (loading) {
return (
<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 AgendaMax</span>
</div>
</div>
);
}
if (!user) return <LoginPage />;
// Las dos páginas públicas van aparte del bundle principal por dos razones que
// apuntan en direcciones opuestas, y por eso importan las dos: quien ya tiene
// sesión nunca las ve y no debe descargarlas, y quien llega a la landing no debe
// descargar FullCalendar ni recharts para leer una página de venta.
//
// El login tiene que ser lazy junto con la landing, no solo la landing: es el único
// consumidor eager de framer-motion, y con él en el chunk de entrada Rollup mete
// `lib/motion` ahí también. Eso obliga al chunk de la landing a importar el de
// entrada, que a su vez arrastra `charts` y `calendar`. Medido: 39 kB gzip de
// motion en cada carga del panel, más recharts y FullCalendar en la landing.
const LandingPage = lazy(() => import("./pages/LandingPage"));
const LoginPage = lazy(() =>
import("./pages/LoginPage").then((m) => ({ default: m.LoginPage }))
);
const isAdmin = user.role === "admin";
// Las dos únicas páginas que importan librerías pesadas: recharts (~111 KB gzip) y
// FullCalendar (~76 KB gzip). Eager, viven en el chunk de entrada, que se descarga
// en TODA visita — incluida la landing, que no grafica ni agenda nada. Su frontera
// de Suspense está en el <Outlet> de AppShell.
const DashboardPage = lazy(() =>
import("./pages/DashboardPage").then((m) => ({ default: m.DashboardPage }))
);
const CalendarPage = lazy(() =>
import("./pages/CalendarPage").then((m) => ({ default: m.CalendarPage }))
);
/** Destino del usuario según su rol. Única definición de la regla. */
export function homePathFor(user: User): string {
if (user.role === "admin") return "/admin";
return user.role === "owner" ? "/dashboard" : "/calendar";
}
function Splash() {
return (
<div className="flex h-screen-safe 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 AgendaMax</span>
</div>
</div>
);
}
function AppRoutes() {
const { user, loading } = useAuth();
if (loading) return <Splash />;
const home = user ? homePathFor(user) : "/login";
const isAdmin = user?.role === "admin";
return (
<Routes>
{/* Admin routes */}
{isAdmin && (
{/* ---- Públicas ---- */}
<Route
path="/"
element={
user ? (
<Navigate to={home} replace />
) : (
<Suspense fallback={<Splash />}>
<LandingPage />
</Suspense>
)
}
/>
<Route
path="/login"
element={
user ? (
<Navigate to={home} replace />
) : (
<Suspense fallback={<Splash />}>
<LoginPage />
</Suspense>
)
}
/>
{/* Sin sesión, cualquier otra ruta manda al login. */}
{!user && <Route path="*" element={<Navigate to="/login" replace />} />}
{/* ---- Admin de plataforma ---- */}
{user && isAdmin && (
<Route element={<AdminShell />}>
<Route path="/" element={<Navigate to="/admin" replace />} />
<Route path="/admin" element={<AdminOverviewPage />} />
<Route path="/admin/businesses" element={<AdminBusinessesPage />} />
<Route path="/admin/businesses/:id" element={<AdminBusinessDetailPage />} />
@@ -48,10 +109,9 @@ function Protected() {
</Route>
)}
{/* Business routes */}
{!isAdmin && (
{/* ---- Negocio ---- */}
{user && !isAdmin && (
<Route element={<AppShell />}>
<Route path="/" element={<Navigate to={user.role === "owner" ? "/dashboard" : "/calendar"} replace />} />
{user.role === "owner" ? (
<Route path="/dashboard" element={<DashboardPage />} />
) : (
@@ -67,9 +127,9 @@ function Protected() {
{user.role === "owner" && <Route path="/cash" element={<CashPage />} />}
{user.role === "owner" && <Route path="/notifications" element={<NotificationsPage />} />}
{user.role === "owner" && <Route path="/settings" element={<SettingsPage />} />}
{/* Block business users from admin */}
<Route path="/admin/*" element={<Navigate to="/" replace />} />
<Route path="*" element={<Navigate to={user.role === "owner" ? "/dashboard" : "/calendar"} replace />} />
{/* La autorización real vive en los middlewares del servidor; esto es UX. */}
<Route path="/admin/*" element={<Navigate to={home} replace />} />
<Route path="*" element={<Navigate to={home} replace />} />
</Route>
)}
</Routes>
@@ -79,8 +139,16 @@ function Protected() {
export default function App() {
return (
<Routes>
{/* Reservas públicas: fuera del AuthProvider a propósito, no asumas usuario. */}
<Route path="/b/:slug" element={<BookingPage />} />
<Route path="*" element={<AuthProvider><Protected /></AuthProvider>} />
<Route
path="*"
element={
<AuthProvider>
<AppRoutes />
</AuthProvider>
}
/>
</Routes>
);
}
+81 -37
View File
@@ -8,11 +8,14 @@ import {
X,
ShieldCheck,
Plus,
PanelLeftClose,
PanelLeftOpen,
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../lib/auth";
import { initials, cn } from "../lib/format";
import { InstallAppPrompt } from "./InstallAppPrompt";
import { useSidebarCollapsed } from "../lib/useSidebarCollapsed";
interface NavItem {
to: string;
@@ -31,19 +34,35 @@ export function AdminShell() {
const location = useLocation();
const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false);
const { collapsed, toggle } = useSidebarCollapsed();
if (!user) return null;
const SidebarContent = (
/** `mini` = solo iconos. El panel móvil siempre se muestra completo. */
const sidebarContent = (mini: boolean) => (
<>
<div className="flex items-center gap-2.5 px-2 py-1">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-slate-800 to-slate-950 text-white shadow-soft">
<div className={cn("flex items-center px-2 py-1", mini ? "flex-col gap-2" : "gap-2.5")}>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-slate-800 to-slate-950 text-white shadow-soft">
<ShieldCheck className="h-5 w-5" />
</div>
<div className="leading-tight">
<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>
{!mini && (
<div className="min-w-0 leading-tight">
<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>
)}
<button
onClick={toggle}
aria-label={mini ? "Expandir menú" : "Colapsar menú"}
title={mini ? "Expandir menú" : "Colapsar menú"}
aria-expanded={!mini}
className={cn(
"icon-btn hidden rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700 lg:block",
mini ? "" : "ml-auto"
)}
>
{mini ? <PanelLeftOpen className="h-[18px] w-[18px]" /> : <PanelLeftClose className="h-[18px] w-[18px]" />}
</button>
</div>
<nav className="mt-5 flex-1 space-y-1 px-2">
@@ -53,15 +72,17 @@ export function AdminShell() {
to={item.to}
end={item.end}
onClick={() => setMobileOpen(false)}
title={mini ? item.label : undefined}
className={({ isActive }) =>
cn(
"group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-semibold transition-colors",
"group flex w-full items-center rounded-xl py-2.5 text-sm font-semibold transition-colors",
mini ? "justify-center px-0" : "gap-3 px-3",
isActive ? "bg-slate-900 text-white" : "text-slate-600 hover:bg-slate-100 hover:text-slate-900"
)
}
>
<item.icon className="h-[18px] w-[18px]" />
{item.label}
<item.icon className="h-[18px] w-[18px] shrink-0" />
{!mini && <span className="truncate">{item.label}</span>}
</NavLink>
))}
<button
@@ -69,29 +90,42 @@ export function AdminShell() {
navigate("/admin/businesses?new=1");
setMobileOpen(false);
}}
className="group mt-2 flex w-full items-center gap-3 rounded-xl border border-dashed border-slate-300 px-3 py-2.5 text-sm font-semibold text-slate-500 transition-colors hover:border-brand-400 hover:bg-brand-50 hover:text-brand-700"
title={mini ? "Nuevo negocio" : undefined}
aria-label={mini ? "Nuevo negocio" : undefined}
className={cn(
"group mt-2 flex w-full items-center rounded-xl border border-dashed border-slate-300 py-2.5 text-sm font-semibold text-slate-500 transition-colors hover:border-brand-400 hover:bg-brand-50 hover:text-brand-700",
mini ? "justify-center px-0" : "gap-3 px-3"
)}
>
<Plus className="h-[18px] w-[18px]" />
Nuevo negocio
<Plus className="h-[18px] w-[18px] shrink-0" />
{!mini && <span className="truncate">Nuevo negocio</span>}
</button>
</nav>
<div className="mt-auto px-2 pb-3">
<div className="rounded-xl bg-amber-50 px-3 py-2 text-[11px] font-medium text-amber-700">
Modo administrador de plataforma. Puedes crear y gestionar todos los negocios.
</div>
<div className="mt-2 flex items-center gap-3 rounded-xl p-2">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-slate-900 text-xs font-bold text-white">
<div className="safe-area-bottom mt-auto px-2">
{!mini && (
<div className="rounded-xl bg-amber-50 px-3 py-2 text-[11px] font-medium text-amber-700">
Modo administrador de plataforma. Puedes crear y gestionar todos los negocios.
</div>
)}
<div className={cn("mt-2 flex items-center rounded-xl p-2", mini ? "flex-col gap-2" : "gap-3")}>
<div
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-slate-900 text-xs font-bold text-white"
title={mini ? user.name : undefined}
>
{initials(user.name)}
</div>
<div className="min-w-0 flex-1 leading-tight">
<div className="truncate text-sm font-bold text-slate-900">{user.name}</div>
<div className="text-[11px] font-medium text-slate-500">Admin · {user.email}</div>
</div>
{!mini && (
<div className="min-w-0 flex-1 leading-tight">
<div className="truncate text-sm font-bold text-slate-900">{user.name}</div>
<div className="truncate text-[11px] font-medium text-slate-500">Admin · {user.email}</div>
</div>
)}
<button
onClick={logout}
title="Cerrar sesión"
className="rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-rose-50 hover:text-rose-600"
aria-label="Cerrar sesión"
className="icon-btn shrink-0 rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-rose-50 hover:text-rose-600"
>
<LogOut className="h-[18px] w-[18px]" />
</button>
@@ -101,42 +135,52 @@ export function AdminShell() {
);
return (
<div className="flex h-screen overflow-hidden bg-[#f6f7fb]">
<aside className="hidden w-64 shrink-0 flex-col border-r border-slate-200 bg-white py-4 lg:flex">
{SidebarContent}
<div data-app-shell className="flex h-screen-safe overflow-hidden bg-[#f6f7fb]">
<aside
className={cn(
"hidden shrink-0 flex-col border-r border-slate-200 bg-white py-4 transition-[width] duration-200 lg:flex",
collapsed ? "w-[68px]" : "w-64"
)}
>
{sidebarContent(collapsed)}
</aside>
{mobileOpen && (
<div className="fixed inset-0 z-40 lg:hidden">
<div className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm animate-fade-in" onClick={() => setMobileOpen(false)} />
<aside className="absolute left-0 top-0 flex h-full w-72 flex-col bg-white py-4 shadow-2xl animate-slide-up">
<aside className="safe-top absolute left-0 top-0 flex h-full w-72 max-w-[85vw] flex-col overflow-y-auto bg-white py-4 pl-[var(--safe-left)] shadow-2xl animate-slide-up">
<button
onClick={() => setMobileOpen(false)}
className="absolute right-3 top-4 rounded-lg p-1.5 text-slate-400 hover:bg-slate-100"
aria-label="Cerrar menú"
className="icon-btn absolute right-3 top-4 rounded-lg p-1.5 text-slate-400 hover:bg-slate-100"
>
<X className="h-5 w-5" />
</button>
{SidebarContent}
{sidebarContent(false)}
</aside>
</div>
)}
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex items-center gap-3 border-b border-slate-200 bg-white/80 px-4 py-3 backdrop-blur lg:hidden">
<button onClick={() => setMobileOpen(true)} className="rounded-lg p-2 text-slate-600 hover:bg-slate-100">
<header className="safe-top safe-x flex shrink-0 items-center gap-3 border-b border-slate-200 bg-white/80 px-4 py-3 backdrop-blur lg:hidden">
<button
onClick={() => setMobileOpen(true)}
aria-label="Abrir menú"
className="icon-btn -ml-1 rounded-lg p-2.5 text-slate-600 hover:bg-slate-100"
>
<Menu className="h-5 w-5" />
</button>
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-slate-900 text-white">
<div className="flex min-w-0 items-center gap-2">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-slate-900 text-white">
<ShieldCheck className="h-4 w-4" />
</div>
<span className="text-sm font-extrabold">Admin</span>
<span className="truncate text-sm font-extrabold">Admin</span>
</div>
<div className="ml-auto w-auto">
<div className="ml-auto w-auto shrink-0">
<InstallAppPrompt compact />
</div>
</header>
<main className="flex-1 overflow-y-auto">
<main className="safe-x flex min-h-0 flex-1 flex-col overflow-y-auto">
<Outlet key={location.pathname} />
</main>
</div>
+77 -35
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { Suspense, useState } from "react";
import { NavLink, Outlet, useLocation } from "react-router-dom";
import {
CalendarDays,
@@ -15,11 +15,15 @@ import {
Bell,
Settings,
BarChart3,
PanelLeftClose,
PanelLeftOpen,
} from "lucide-react";
import { useAuth } from "../lib/auth";
import { initials, cn } from "../lib/format";
import { DemoSwitcher } from "./DemoSwitcher";
import { InstallAppPrompt } from "./InstallAppPrompt";
import { RouteSpinner } from "./ui";
import { useSidebarCollapsed } from "../lib/useSidebarCollapsed";
interface NavItem {
to: string;
@@ -48,6 +52,7 @@ export function AppShell() {
const { user, business, logout } = useAuth();
const location = useLocation();
const [mobileOpen, setMobileOpen] = useState(false);
const { collapsed, toggle } = useSidebarCollapsed();
if (!user) return null;
const isOwner = user.role === "owner";
@@ -57,16 +62,32 @@ export function AppShell() {
return true;
});
const SidebarContent = (
/** `mini` = solo iconos. El panel móvil siempre se muestra completo. */
const sidebarContent = (mini: boolean) => (
<>
<div className="flex items-center gap-2.5 px-2 py-1">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-700 text-white shadow-soft">
<div className={cn("flex items-center px-2 py-1", mini ? "flex-col gap-2" : "gap-2.5")}>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-700 text-white shadow-soft">
<Sparkles className="h-5 w-5" />
</div>
<div className="leading-tight">
<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>
{!mini && (
<div className="min-w-0 leading-tight">
<div className="text-sm font-extrabold tracking-tight text-slate-900">AgendaMax</div>
<div className="truncate text-[11px] font-medium text-slate-500">{business?.name ?? "Tu negocio"}</div>
</div>
)}
{/* Solo en lg+: en móvil se cierra con el backdrop o la X. */}
<button
onClick={toggle}
aria-label={mini ? "Expandir menú" : "Colapsar menú"}
title={mini ? "Expandir menú" : "Colapsar menú"}
aria-expanded={!mini}
className={cn(
"icon-btn hidden rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700 lg:block",
mini ? "" : "ml-auto"
)}
>
{mini ? <PanelLeftOpen className="h-[18px] w-[18px]" /> : <PanelLeftClose className="h-[18px] w-[18px]" />}
</button>
</div>
<nav className="mt-5 flex-1 space-y-1 px-2">
@@ -75,9 +96,12 @@ export function AppShell() {
key={item.to}
to={item.to}
onClick={() => setMobileOpen(false)}
title={mini ? item.label : undefined}
className={({ isActive }) =>
cn(
"group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-semibold transition-colors",
// `flex` explícito y una entrada por fila: es un menú vertical.
"group flex w-full items-center rounded-xl py-2.5 text-sm font-semibold transition-colors",
mini ? "justify-center px-0" : "gap-3 px-3",
isActive
? "bg-brand-50 text-brand-700"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900"
@@ -87,38 +111,45 @@ export function AppShell() {
{({ isActive }) => (
<>
<item.icon
className={cn("h-[18px] w-[18px]", isActive ? "text-brand-600" : "text-slate-400 group-hover:text-slate-600")}
className={cn(
"h-[18px] w-[18px] shrink-0",
isActive ? "text-brand-600" : "text-slate-400 group-hover:text-slate-600"
)}
/>
{item.label}
{!mini && <span className="truncate">{item.label}</span>}
</>
)}
</NavLink>
))}
</nav>
<div className="mt-auto space-y-2 px-2 pb-3">
{DEMO && (
<div className="safe-area-bottom mt-auto space-y-2 px-2">
{DEMO && !mini && (
<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={cn("flex items-center rounded-xl p-2", mini ? "flex-col gap-2" : "gap-3")}>
<div
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
style={{ background: user.avatar_color }}
title={mini ? user.name : undefined}
>
{initials(user.name)}
</div>
<div className="min-w-0 flex-1 leading-tight">
<div className="truncate text-sm font-bold text-slate-900">{user.name}</div>
<div className="text-[11px] font-medium capitalize text-slate-500">
{isOwner ? "Dueño" : "Empleado"}
{!mini && (
<div className="min-w-0 flex-1 leading-tight">
<div className="truncate text-sm font-bold text-slate-900">{user.name}</div>
<div className="text-[11px] font-medium capitalize text-slate-500">
{isOwner ? "Dueño" : "Empleado"}
</div>
</div>
</div>
)}
<button
onClick={logout}
title="Cerrar sesión"
className="rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-rose-50 hover:text-rose-600"
aria-label="Cerrar sesión"
className="icon-btn shrink-0 rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-rose-50 hover:text-rose-600"
>
<LogOut className="h-[18px] w-[18px]" />
</button>
@@ -128,49 +159,60 @@ export function AppShell() {
);
return (
<div className="flex h-screen overflow-hidden bg-[#f6f7fb]">
{/* Desktop sidebar */}
<aside className="hidden w-64 shrink-0 flex-col border-r border-slate-200 bg-white py-4 lg:flex">
{SidebarContent}
<div data-app-shell className="flex h-screen-safe overflow-hidden bg-[#f6f7fb]">
{/* Barra lateral en lg+, colapsable a solo iconos */}
<aside
className={cn(
"hidden shrink-0 flex-col border-r border-slate-200 bg-white py-4 transition-[width] duration-200 lg:flex",
collapsed ? "w-[68px]" : "w-64"
)}
>
{sidebarContent(collapsed)}
</aside>
{/* Mobile sidebar */}
{mobileOpen && (
<div className="fixed inset-0 z-40 lg:hidden">
<div className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm animate-fade-in" onClick={() => setMobileOpen(false)} />
<aside className="absolute left-0 top-0 flex h-full w-72 flex-col bg-white py-4 shadow-2xl animate-slide-up">
<aside className="safe-top absolute left-0 top-0 flex h-full w-72 max-w-[85vw] flex-col overflow-y-auto bg-white py-4 pl-[var(--safe-left)] shadow-2xl animate-slide-up">
<button
onClick={() => setMobileOpen(false)}
className="absolute right-3 top-4 rounded-lg p-1.5 text-slate-400 hover:bg-slate-100"
aria-label="Cerrar menú"
className="icon-btn absolute right-3 top-4 rounded-lg p-1.5 text-slate-400 hover:bg-slate-100"
>
<X className="h-5 w-5" />
</button>
{SidebarContent}
{sidebarContent(false)}
</aside>
</div>
)}
{/* Main */}
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex items-center gap-3 border-b border-slate-200 bg-white/80 px-4 py-3 backdrop-blur lg:hidden">
<header className="safe-top safe-x flex shrink-0 items-center gap-3 border-b border-slate-200 bg-white/80 px-4 py-3 backdrop-blur lg:hidden">
<button
onClick={() => setMobileOpen(true)}
className="rounded-lg p-2 text-slate-600 hover:bg-slate-100"
aria-label="Abrir menú"
className="icon-btn -ml-1 rounded-lg p-2.5 text-slate-600 hover:bg-slate-100"
>
<Menu className="h-5 w-5" />
</button>
<div className="flex items-center gap-2">
<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">
<div className="flex min-w-0 items-center gap-2">
<div className="flex h-7 w-7 shrink-0 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">AgendaMax</span>
<span className="truncate text-sm font-extrabold">AgendaMax</span>
</div>
<div className="ml-auto w-auto">
<div className="ml-auto w-auto shrink-0">
<InstallAppPrompt compact />
</div>
</header>
<main className="flex-1 overflow-y-auto">
<Outlet key={location.pathname} />
<main className="safe-x flex min-h-0 flex-1 flex-col overflow-y-auto">
{/* El calendario (FullCalendar) y el tablero (recharts) se cargan por
demanda, así que el Outlet necesita su propia frontera de Suspense. */}
<Suspense fallback={<RouteSpinner />}>
<Outlet key={location.pathname} />
</Suspense>
</main>
</div>
</div>
+21
View File
@@ -0,0 +1,21 @@
/**
* La marca de AgendaMax, en un solo lugar.
*
* Es el mismo dibujo que `public/favicon.svg` y que los iconos de la PWA: cuadrado
* redondeado en el azul primario `#3b66ff`, tres renglones de agenda en blanco y el
* punto naranja `#f17616` sobre el último. Se replica aquí como SVG en línea para
* que herede el tamaño del texto y no dependa de una petición de red.
*
* Los colores están fijos a propósito: si cambian, tienen que cambiar también en
* `public/favicon.svg` y en los iconos generados por `npm run generate:pwa-icons`,
* o la pestaña del navegador deja de coincidir con la página.
*/
export function BrandMark({ className = "h-8 w-8" }: { className?: string }) {
return (
<svg viewBox="0 0 32 32" className={className} role="img" aria-label="AgendaMax">
<rect width="32" height="32" rx="7" fill="#3b66ff" />
<path d="M9 11h14M9 16h14M9 21h9" stroke="#fff" strokeWidth="2.4" strokeLinecap="round" />
<circle cx="23" cy="21" r="3" fill="#f17616" />
</svg>
);
}
+1 -1
View File
@@ -34,7 +34,7 @@ export function DemoSwitcher() {
<div className="relative">
<button
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-xs font-semibold text-slate-600 transition-colors hover:bg-slate-100"
className="tap-target flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-xs font-semibold text-slate-600 transition-colors hover:bg-slate-100"
disabled={loading}
>
<UserCog className="h-3.5 w-3.5 text-slate-400" />
+11 -4
View File
@@ -40,7 +40,9 @@ export function Modal({ open, onClose, title, subtitle, children, size = "md", f
aria-modal="true"
aria-labelledby={title ? titleId : undefined}
className={cn(
"relative z-10 flex max-h-[92vh] w-full flex-col overflow-hidden rounded-t-2xl bg-white shadow-2xl animate-slide-up sm:rounded-2xl",
// max-h en dvh: con 92vh el sheet queda más alto que el área visible de
// Safari y el footer se esconde bajo la barra de URL.
"relative z-10 flex max-h-[92dvh] w-full flex-col overflow-hidden rounded-t-2xl bg-white shadow-2xl animate-slide-up sm:rounded-2xl",
maxW
)}
>
@@ -53,14 +55,19 @@ export function Modal({ open, onClose, title, subtitle, children, size = "md", f
<button
onClick={onClose}
aria-label="Cerrar"
className="rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
className="icon-btn rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
>
<X className="h-5 w-5" />
</button>
</div>
)}
<div className="flex-1 overflow-y-auto px-5 py-4">{children}</div>
{footer && <div className="border-t border-slate-100 bg-slate-50/60 px-5 py-3">{footer}</div>}
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">{children}</div>
{footer ? (
<div className="safe-area-bottom shrink-0 border-t border-slate-100 bg-slate-50/60 px-5 pt-3">{footer}</div>
) : (
/* Sin footer, el sheet a ras de pantalla queda bajo el home indicator. */
<div className="h-[var(--safe-bottom)] shrink-0 sm:hidden" />
)}
</div>
</div>,
document.body
+109
View File
@@ -0,0 +1,109 @@
import { Link } from "react-router-dom";
import { motion } from "framer-motion";
import { ArrowRight } from "lucide-react";
import { inView, revealStagger, revealUp } from "../../lib/motion";
import { useReducedMotion } from "../../lib/useReducedMotion";
import { BrandMark } from "../BrandMark";
// Las ocho muestras del abanico, ya en fila: `PIE_COLORS` del panel, mismo orden.
const TINTES = [
"#3b66ff",
"#f17616",
"#10b981",
"#a855f7",
"#ec4899",
"#14b8a6",
"#f59e0b",
"#6366f1",
];
export function ClosingSection() {
const reduced = useReducedMotion();
return (
// El degradado va en la sección misma y no en un hijo con `-z-10`: ahí quedaba
// detrás del fondo opaco de `.landing` y no se veía nada.
<section
data-ld-section
className="relative overflow-hidden"
// Lavados del propio panel: orange-50 → brand-50. Nada inventado.
style={{ background: "linear-gradient(170deg, #fff7ed 0%, #eef4ff 60%, #f6f7fb 100%)" }}
>
<motion.div
className="ld-wrap ld-section text-center"
initial="hidden"
whileInView="show"
viewport={inView}
variants={revealStagger(reduced)}
>
{/* Cinta de tintes: las ocho muestras del hero, ya en fila. */}
<motion.div
className="mx-auto mb-10 flex w-fit gap-1.5"
variants={revealUp(reduced, 14)}
aria-hidden="true"
>
{TINTES.map((c, i) => (
<motion.span
key={c}
className="h-11 w-4 rounded-full sm:h-14 sm:w-5"
style={{ background: c }}
animate={reduced ? undefined : { scaleY: [1, 0.78, 1] }}
transition={{
duration: 2.6,
repeat: Infinity,
delay: i * 0.11,
ease: "easeInOut",
}}
/>
))}
</motion.div>
<motion.h2 className="ld-display mx-auto max-w-3xl" variants={revealUp(reduced, 28)}>
Deja de <span className="ld-gradient-text">adivinar</span>.
</motion.h2>
<motion.p
className="ld-lead mx-auto mt-7 max-w-lg"
style={{ color: "var(--ld-muted)" }}
variants={revealUp(reduced)}
>
Ábrelo con una cuenta que ya está lista y mira tu salón como se ve cuando alguien te
lleva la cuenta.
</motion.p>
<motion.div className="mt-11" variants={revealUp(reduced)}>
<Link data-ld-closing-cta to="/login" className="ld-cta">
Entrar y probarlo
<ArrowRight className="h-4 w-4" />
</Link>
</motion.div>
<motion.p
className="mt-6 text-[0.9rem] font-semibold"
style={{ color: "var(--ld-muted)" }}
variants={revealUp(reduced, 8)}
>
No pide tarjeta. No pide registro.
</motion.p>
</motion.div>
<footer
className="ld-wrap flex flex-col items-center justify-between gap-4 border-t px-5 py-8 text-[0.82rem] font-semibold sm:flex-row"
style={{ borderColor: "var(--ld-hairline)", color: "var(--ld-muted)" }}
>
<div className="flex items-center gap-2" style={{ color: "var(--ld-tinta)" }}>
<BrandMark className="h-6 w-6" />
<span className="font-extrabold">AgendaMax</span>
</div>
<span>© {new Date().getFullYear()} AgendaMax · Salón de práctica</span>
{/* `inline-flex` no es decorativo: `.tap-target` solo fija min-height, y en un
enlace inline la altura mínima no aplica. */}
<Link
to="/login"
className="tap-target inline-flex items-center underline-offset-4 hover:underline"
>
Entrar
</Link>
</footer>
</section>
);
}
+149
View File
@@ -0,0 +1,149 @@
import { motion, useInView } from "framer-motion";
import { useEffect, useRef, useState } from "react";
import { inView, revealStagger, revealUp } from "../../lib/motion";
import { useReducedMotion } from "../../lib/useReducedMotion";
/**
* Cuatro escenas de un martes cualquiera, no cuatro estadísticas. Cada una arranca
* con la frase que la dueña diría en voz alta; el número viene después, como
* confirmación de algo que ya sospechaba.
*
* Ninguna menciona el producto todavía: vender antes de haber dolido no funciona.
*/
const ESCENAS = [
{
color: "var(--ld-rosa)",
escena: "«Se me hizo un hueco a las 3»",
cifra: 34,
sufijo: "%",
remate: "de tus horas se quedan solas",
detalle:
"Cuarenta minutos entre una clienta y otra. Nadie los llena porque nadie los ve a tiempo. Y la renta se paga igual.",
},
{
color: "var(--ld-naranja)",
escena: "«¿Y doña Carmen? Ya ni viene»",
cifra: 4,
sufijo: " meses",
remate: "sin aparecer, y sigue en tu libreta",
detalle:
"Antes venía cada tres semanas. Un día dejó de venir y nadie te avisó. Traerla de vuelta cuesta una llamada; conseguir una nueva cuesta mucho más.",
},
{
color: "var(--ld-purpura)",
escena: "«Ese servicio sí deja… ¿o no?»",
cifra: 1,
sufijo: " de 6",
remate: "servicios te cuesta más de lo que cobra",
detalle:
"Entre producto, la hora de silla y la comisión, hay uno que estás regalando. En la libreta se ve igual que los demás.",
},
{
color: "var(--ld-esmeralda)",
escena: "«El domingo cuadro las cuentas»",
cifra: 3,
sufijo: " horas",
remate: "de calculadora, y aun así no sabes",
detalle:
"Tu día de descanso se va sumando tickets. Y al final del rato sigues sin saber si la semana fue mejor o peor que la anterior.",
},
];
function Cifra({ valor, sufijo, color }: { valor: number; sufijo: string; color: string }) {
const reduced = useReducedMotion();
const ref = useRef<HTMLSpanElement>(null);
const visible = useInView(ref, { once: true, margin: "-15% 0px" });
const [n, setN] = useState(reduced ? valor : 0);
useEffect(() => {
if (reduced) {
setN(valor);
return;
}
if (!visible) return;
const DUR = 1100;
let raf = 0;
let inicio = 0;
const paso = (t: number) => {
if (!inicio) inicio = t;
const p = Math.min(1, (t - inicio) / DUR);
setN(Math.round(valor * (1 - Math.pow(1 - p, 3))));
if (p < 1) raf = requestAnimationFrame(paso);
};
raf = requestAnimationFrame(paso);
return () => cancelAnimationFrame(raf);
}, [visible, valor, reduced]);
return (
<span ref={ref} className="ld-num text-5xl font-extrabold sm:text-6xl" style={{ color }}>
{n}
{sufijo}
</span>
);
}
export function CostSection() {
const reduced = useReducedMotion();
return (
// `scroll-mt-24` no es decorativo: el nav es fijo, y sin margen de scroll el
// salto desde «Ver cómo se ve» deja el titular escondido debajo de él.
<section id="dolor" data-ld-section className="landing-nube ld-section scroll-mt-24">
<div className="ld-wrap">
<motion.div
initial="hidden"
whileInView="show"
viewport={inView}
variants={revealStagger(reduced)}
>
<motion.p
className="ld-eyebrow mb-5"
style={{ color: "var(--ld-rosa)" }}
variants={revealUp(reduced, 12)}
>
Esto te va a sonar
</motion.p>
<motion.h2 className="ld-title max-w-3xl" variants={revealUp(reduced, 24)}>
No es que trabajes poco. Es que nadie te está llevando la cuenta.
</motion.h2>
<motion.p
className="ld-lead mt-6 max-w-xl"
style={{ color: "var(--ld-muted)" }}
variants={revealUp(reduced)}
>
Cuatro cosas que pasan en tu salón cada semana. Ninguna aparece en el banco con nombre
y apellido. Todas salen de ahí.
</motion.p>
</motion.div>
<div className="mt-14 grid grid-cols-1 gap-5 sm:grid-cols-2">
{ESCENAS.map((e, i) => (
<motion.div
key={e.escena}
className="grid grid-cols-1 gap-3 rounded-3xl bg-white/85 p-6 ring-1 ring-black/[0.04] sm:p-7"
initial={{ opacity: reduced ? 1 : 0, y: reduced ? 0 : 26 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={inView}
transition={{ duration: 0.7, delay: reduced ? 0 : i * 0.09 }}
whileHover={reduced ? undefined : { y: -6 }}
>
<span
className="ld-body font-extrabold italic"
style={{ color: e.color, fontFamily: "Fraunces, Georgia, serif" }}
>
{e.escena}
</span>
<p className="flex flex-wrap items-baseline gap-x-2.5">
<Cifra valor={e.cifra} sufijo={e.sufijo} color={e.color} />
<span className="text-lg font-extrabold leading-snug">{e.remate}</span>
</p>
<p className="ld-body" style={{ color: "var(--ld-muted)" }}>
{e.detalle}
</p>
</motion.div>
))}
</div>
</div>
</section>
);
}
+119
View File
@@ -0,0 +1,119 @@
import { Link } from "react-router-dom";
import { motion } from "framer-motion";
import { ArrowRight, ChevronDown } from "lucide-react";
import { EASE_EXPO, revealStagger, revealUp } from "../../lib/motion";
import { useReducedMotion } from "../../lib/useReducedMotion";
import { SwatchFanVisual } from "./visuals/SwatchFanVisual";
import { FloatingMomentsLayer, MomentsStack } from "./visuals/FloatingMomentsVisual";
export function HeroSection() {
const reduced = useReducedMotion();
return (
<section
data-ld-section
className="landing-papel relative isolate overflow-hidden pt-24 ld-section sm:pt-28"
>
{/* Lavados de color. Los tres tonos son los que el panel ya usa como fondo suave:
brand-50 (#eef4ff, el "hoy" del calendario), orange-50 (#fff7ed) y
emerald-50 (#ecfdf5). Van detrás de todo y no llevan texto, así que no
compiten con la lectura. */}
<div className="pointer-events-none absolute inset-0 -z-10">
<div
className="absolute -left-24 -top-24 h-[26rem] w-[26rem] rounded-full opacity-90 blur-3xl"
style={{ background: "radial-gradient(circle, #eef4ff 0%, transparent 70%)" }}
/>
<div
className="absolute -right-20 top-24 h-[30rem] w-[30rem] rounded-full opacity-85 blur-3xl"
style={{ background: "radial-gradient(circle, #fff7ed 0%, transparent 70%)" }}
/>
<div
className="absolute bottom-0 left-1/3 h-[24rem] w-[24rem] rounded-full opacity-80 blur-3xl"
style={{ background: "radial-gradient(circle, #ecfdf5 0%, transparent 70%)" }}
/>
</div>
<div className="ld-wrap grid grid-cols-1 items-center gap-14 lg:grid-cols-[1.05fr_1fr] lg:gap-10">
{/* ---- Columna de texto ---- */}
<motion.div initial="hidden" animate="show" variants={revealStagger(reduced, 0.09)}>
<motion.p
className="ld-eyebrow mb-5 inline-flex items-center gap-2 rounded-full bg-white/80 py-2 pl-2 pr-4 ring-1 ring-black/[0.05]"
variants={revealUp(reduced, 12)}
>
{/* Los cuatro primeros de la paleta del panel, en su orden. */}
<span className="flex gap-1" aria-hidden="true">
{["var(--ld-azul)", "var(--ld-naranja)", "var(--ld-esmeralda)", "var(--ld-purpura)"].map(
(c) => (
<span key={c} className="h-3 w-3 rounded-full" style={{ background: c }} />
)
)}
</span>
Salones · Barberías · Spas
</motion.p>
<h1 data-ld-hero-title className="ld-display">
<motion.span className="block" variants={revealUp(reduced, 26)}>
Tu agenda llena.
</motion.span>
<motion.span className="ld-gradient-text block" variants={revealUp(reduced, 26)}>
Tu domingo libre.
</motion.span>
</h1>
<motion.p className="ld-lead mt-7 max-w-lg" variants={revealUp(reduced)}>
Guarda la libreta y la calculadora. AgendaMax te dice quién viene hoy, quién dejó de
venir y cuánto llevas ganando este mes. Todo en una pantalla.
</motion.p>
<motion.div
className="mt-9 grid grid-cols-1 gap-3 sm:flex sm:items-center"
variants={revealUp(reduced)}
>
<Link to="/login" className="ld-cta">
Entrar y probarlo
<ArrowRight className="h-4 w-4" />
</Link>
<a href="#dolor" className="ld-cta-ghost">
Ver cómo se ve
<ChevronDown className="h-4 w-4" />
</a>
</motion.div>
<motion.p
className="mt-5 text-[0.9rem] font-semibold"
style={{ color: "var(--ld-muted)" }}
variants={revealUp(reduced, 8)}
>
No pide tarjeta. No pide registro. Entras y ya está adentro.
</motion.p>
</motion.div>
{/* ---- Columna del muestrario ----
El padding vertical en lg no es decorativo: reserva la banda superior e
inferior donde se posan los avisos flotantes. Sin él se estrellan contra
el abanico, que es la parte más ancha del dibujo. */}
<div className="relative lg:py-16">
<SwatchFanVisual />
{/* Los avisos flotan solo cuando hay espacio real alrededor del abanico.
Por debajo de lg se apilan bajo él: superpuestos en 390px taparían el
muestrario completo. */}
<FloatingMomentsLayer className="hidden lg:block" />
<MomentsStack className="mt-8 lg:hidden" />
</div>
</div>
{/* Indicador de scroll: respira, y se apaga con movimiento reducido. */}
{!reduced && (
<motion.div
className="mx-auto mt-14 flex w-fit items-center gap-2 text-[0.8rem] font-bold uppercase tracking-widest"
style={{ color: "var(--ld-muted)" }}
animate={{ y: [0, 7, 0], opacity: [0.5, 1, 0.5] }}
transition={{ duration: 2.6, repeat: Infinity, ease: EASE_EXPO }}
>
Sigue bajando
<ChevronDown className="h-4 w-4" />
</motion.div>
)}
</section>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { Link } from "react-router-dom";
import { motion, useScroll, useTransform } from "framer-motion";
import { useReducedMotion } from "../../lib/useReducedMotion";
import { BrandMark } from "../BrandMark";
/**
* Nav flotante. Arranca casi transparente sobre el hero y se condensa al bajar:
* fondo y desenfoque suben juntos, que es lo que evita el salto brusco de un
* `position: fixed` con fondo sólido desde el primer píxel.
*/
export function LandingNav() {
const reduced = useReducedMotion();
const { scrollY } = useScroll();
const fondo = useTransform(scrollY, [0, 140], ["rgba(255,251,247,0)", "rgba(255,251,247,0.86)"]);
const desenfoque = useTransform(scrollY, [0, 140], ["blur(0px)", "blur(16px)"]);
const borde = useTransform(scrollY, [0, 140], ["rgba(27,21,51,0)", "rgba(27,21,51,0.1)"]);
return (
<motion.header
className="safe-top fixed inset-x-0 top-0 z-50 border-b"
style={
reduced
? { background: "rgba(255,251,247,0.92)", borderColor: "rgba(27,21,51,0.1)" }
: { background: fondo, backdropFilter: desenfoque, borderColor: borde }
}
>
<nav className="ld-wrap flex items-center justify-between gap-4 px-5 py-3">
<Link
to="/"
className="tap-target flex items-center gap-2.5 text-[1.05rem] font-extrabold tracking-tight"
>
<BrandMark className="h-8 w-8" />
AgendaMax
</Link>
<Link
to="/login"
className="ld-cta"
style={{ minHeight: 46, paddingInline: "1.35rem", fontSize: "0.9rem" }}
>
Entrar
</Link>
</nav>
</motion.header>
);
}
@@ -0,0 +1,80 @@
import { motion } from "framer-motion";
import { inView, revealStagger, revealUp } from "../../lib/motion";
import { useReducedMotion } from "../../lib/useReducedMotion";
/**
* Las tres frases que una dueña dice de verdad cuando le enseñas un sistema. Están
* en su registro, no en el nuestro: «no le sé a la tecnología», no «tengo baja
* alfabetización digital». La respuesta también: corta y sin tecnicismos.
*/
const OBJECIONES = [
{
color: "var(--ld-rosa)",
dice: "No le sé a la tecnología.",
responde:
"Si contestas WhatsApp, ya sabes usarlo. Se toca y se arrastra, nada más. No hay que instalar ni configurar nada, y no hay curso que tomar.",
},
{
color: "var(--ld-naranja)",
dice: "Mi gente no lo va a usar.",
responde:
"Cada quien ve nomás su día y sus propias cuentas. No administran nada. Es menos trabajo que preguntarte a ti a qué hora entran.",
},
{
color: "var(--ld-purpura)",
dice: "Mi libreta me funciona bien.",
responde:
"Tu libreta te dice la hora de la cita, y eso lo hace bien. Lo que no te dice es que doña Carmen lleva cuatro meses sin venir, ni cuál de tus servicios estás regalando.",
},
];
export function ObjectionsSection() {
const reduced = useReducedMotion();
return (
<section data-ld-section className="landing-papel ld-section">
<div className="ld-wrap">
<motion.div
initial="hidden"
whileInView="show"
viewport={inView}
variants={revealStagger(reduced)}
>
<motion.p
className="ld-eyebrow mb-5"
style={{ color: "var(--ld-muted)" }}
variants={revealUp(reduced, 12)}
>
Lo que estás pensando
</motion.p>
<motion.h2 className="ld-title max-w-2xl" variants={revealUp(reduced, 24)}>
Tres motivos para no hacerlo. Y por qué ninguno aguanta.
</motion.h2>
</motion.div>
<div className="mt-14 grid grid-cols-1 gap-4">
{OBJECIONES.map((o, i) => (
<motion.div
key={o.dice}
className="grid grid-cols-1 items-start gap-4 rounded-3xl bg-white p-7 ring-1 ring-black/[0.05] sm:grid-cols-[1fr_1.45fr] sm:gap-10 sm:p-9"
initial={{ opacity: reduced ? 1 : 0, y: reduced ? 0 : 22 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={inView}
transition={{ duration: 0.65, delay: reduced ? 0 : i * 0.1 }}
>
<p
className="text-2xl font-extrabold italic leading-snug sm:text-[1.75rem]"
style={{ fontFamily: "Fraunces, Georgia, serif", color: o.color }}
>
«{o.dice}»
</p>
<p className="ld-body" style={{ color: "var(--ld-muted)" }}>
{o.responde}
</p>
</motion.div>
))}
</div>
</div>
</section>
);
}
+159
View File
@@ -0,0 +1,159 @@
import { useRef, useState } from "react";
import { motion, useMotionValueEvent, useScroll } from "framer-motion";
import { CalendarDays, HandCoins, Users } from "lucide-react";
import { EASE_EXPO } from "../../lib/motion";
import { useReducedMotion } from "../../lib/useReducedMotion";
import { CalendarGridVisual } from "./visuals/CalendarGridVisual";
import { RevenueLineVisual } from "./visuals/RevenueLineVisual";
import { TeamLoadVisual } from "./visuals/TeamLoadVisual";
/**
* Tres actos, escritos como se los explicarías a una amiga que abre su salón. Cero
* palabras de sistema: ni «datos», ni «métricas», ni «panel». Lo que ella gana,
* dicho con sus palabras.
*/
const ACTOS = [
{
icono: CalendarDays,
rotulo: "Tu día",
color: "var(--ld-rosa)",
titulo: "Quién viene, a qué hora y con quién.",
copy: "Se mueve una cita con el dedo, como arrastrar una foto. Y no te deja poner a dos clientas en la misma silla a la misma hora, aunque se lo pidas.",
visual: <CalendarGridVisual density={0.48} />,
},
{
icono: HandCoins,
rotulo: "Tu dinero",
color: "var(--ld-naranja)",
titulo: "Cuánto entró hoy. Ya sumado.",
copy: "Sin calculadora y sin esperar al domingo. Cuánto llevas hoy, qué servicio te deja más y si esta semana va mejor que la pasada.",
visual: <RevenueLineVisual />,
},
{
icono: Users,
rotulo: "Tu gente",
color: "var(--ld-purpura)",
titulo: "Quién está hasta el tope y quién tiene hueco.",
copy: "De un golpe, sin preguntarle a nadie ni revisar tres libretas. Así reparte el trabajo sin que nadie se sienta cargada de más.",
visual: <TeamLoadVisual />,
},
];
function Encabezado({ a }: { a: (typeof ACTOS)[number] }) {
const Icono = a.icono;
return (
<>
<div className="mb-4 inline-flex items-center gap-2.5">
<span
className="flex h-9 w-9 items-center justify-center rounded-xl"
style={{ background: a.color }}
>
<Icono className="h-4 w-4 text-white" strokeWidth={2.6} />
</span>
<span className="ld-eyebrow" style={{ color: a.color }}>
{a.rotulo}
</span>
</div>
<h3 className="ld-title">{a.titulo}</h3>
<p className="ld-lead mt-5 max-w-lg" style={{ color: "var(--ld-muted)" }}>
{a.copy}
</p>
</>
);
}
function Panel({ children }: { children: React.ReactNode }) {
return (
<div className="rounded-3xl bg-white p-5 shadow-[0_30px_70px_-30px_rgba(27,21,51,0.25)] ring-1 ring-black/[0.05] sm:p-7">
{children}
</div>
);
}
export function ProductSection() {
const reduced = useReducedMotion();
const ref = useRef<HTMLDivElement>(null);
const [activo, setActivo] = useState(0);
const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end end"] });
useMotionValueEvent(scrollYProgress, "change", (p) => {
// Tres tramos iguales, acotados a los extremos para que el final del recorrido
// no devuelva un índice fuera de rango.
setActivo(Math.min(ACTOS.length - 1, Math.max(0, Math.floor(p * ACTOS.length))));
});
// Con movimiento reducido no hay panel fijo: los tres actos se apilan enteros.
if (reduced) {
return (
<section data-ld-section className="landing-papel ld-section">
<div className="ld-wrap grid grid-cols-1 gap-20">
{ACTOS.map((a) => (
<div key={a.rotulo} className="grid grid-cols-1 items-center gap-8 lg:grid-cols-2">
<div>
<Encabezado a={a} />
</div>
<Panel>{a.visual}</Panel>
</div>
))}
</div>
</section>
);
}
return (
<section data-ld-section className="landing-papel">
<div ref={ref} className="relative" style={{ height: `${ACTOS.length * 100}vh` }}>
<div className="sticky top-0 flex min-h-screen-safe items-center ld-section">
<div className="ld-wrap grid grid-cols-1 items-center gap-12 lg:grid-cols-2">
{/* Texto: los tres actos ocupan la misma caja y se relevan. */}
<div className="relative min-h-[20rem]">
{ACTOS.map((a, i) => (
<motion.div
key={a.rotulo}
className="absolute inset-0 grid grid-cols-1 content-center"
animate={{ opacity: activo === i ? 1 : 0, y: activo === i ? 0 : 20 }}
transition={{ duration: 0.5, ease: EASE_EXPO }}
style={{ pointerEvents: activo === i ? "auto" : "none" }}
aria-hidden={activo !== i}
>
<Encabezado a={a} />
</motion.div>
))}
</div>
{/* Panel: cambia de visual con el acto. */}
<div className="relative min-h-[21rem] sm:min-h-[25rem]">
{ACTOS.map((a, i) => (
<motion.div
key={a.rotulo}
className="absolute inset-0 grid grid-cols-1 content-center"
animate={{ opacity: activo === i ? 1 : 0, scale: activo === i ? 1 : 0.96 }}
transition={{ duration: 0.5, ease: EASE_EXPO }}
style={{ pointerEvents: activo === i ? "auto" : "none" }}
aria-hidden={activo !== i}
>
<Panel>{a.visual}</Panel>
</motion.div>
))}
</div>
</div>
</div>
</div>
{/* Avance entre actos: la píldora activa toma el color del acto. */}
<div className="ld-wrap flex items-center justify-center gap-2 pb-8">
{ACTOS.map((a, i) => (
<motion.span
key={a.rotulo}
className="h-1.5 rounded-full"
animate={{
width: activo === i ? 34 : 12,
backgroundColor: activo === i ? a.color : "rgba(27,21,51,0.15)",
}}
transition={{ duration: 0.4, ease: EASE_EXPO }}
/>
))}
</div>
</section>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { motion } from "framer-motion";
import { MousePointerClick } from "lucide-react";
import { inView, revealStagger, revealUp } from "../../lib/motion";
import { useReducedMotion } from "../../lib/useReducedMotion";
import { DashboardMockVisual } from "./visuals/DashboardMockVisual";
export function ProofSection() {
const reduced = useReducedMotion();
return (
<section data-ld-section className="landing-nube ld-section">
<div className="ld-wrap">
<motion.div
className="mx-auto max-w-2xl text-center"
initial="hidden"
whileInView="show"
viewport={inView}
variants={revealStagger(reduced)}
>
<motion.p
className="ld-eyebrow mb-5"
style={{ color: "var(--ld-esmeralda)" }}
variants={revealUp(reduced, 12)}
>
Esto es de verdad
</motion.p>
<motion.h2 className="ld-title" variants={revealUp(reduced, 24)}>
No es un dibujito. Es el salón que te presta la demo.
</motion.h2>
<motion.p
className="ld-lead mt-6"
style={{ color: "var(--ld-muted)" }}
variants={revealUp(reduced)}
>
Entra, mueve una cita y mira cómo cambian las cuentas. No vas a romper nada: es un
salón de práctica que se puede volver a llenar cuando quieras.
</motion.p>
</motion.div>
<DashboardMockVisual className="mt-14" />
<motion.p
className="ld-body mx-auto mt-8 flex max-w-xl items-center justify-center gap-2.5 text-center font-semibold"
style={{ color: "var(--ld-muted)" }}
initial={{ opacity: reduced ? 1 : 0 }}
whileInView={{ opacity: 1 }}
viewport={inView}
transition={{ duration: 0.6, delay: reduced ? 0 : 0.3 }}
>
<MousePointerClick className="h-5 w-5 shrink-0" style={{ color: "var(--ld-esmeralda)" }} />
Un clic y estás dentro. No hay formulario que llenar.
</motion.p>
</div>
</section>
);
}
+118
View File
@@ -0,0 +1,118 @@
import { useRef } from "react";
import { motion, useScroll, useTransform } from "framer-motion";
import { ArrowRight } from "lucide-react";
import { revealUp } from "../../lib/motion";
import { useReducedMotion } from "../../lib/useReducedMotion";
import { NotebookVisual } from "./visuals/NotebookVisual";
import { CalendarGridVisual } from "./visuals/CalendarGridVisual";
function Texto() {
return (
<>
<p className="ld-eyebrow mb-5" style={{ color: "var(--ld-naranja)" }}>
El cambio
</p>
<h2 className="ld-title">
Tu libreta de siempre,
<br />
<span className="ld-gradient-text">pero que te contesta.</span>
</h2>
<p className="ld-lead mt-6 max-w-md" style={{ color: "var(--ld-muted)" }}>
El mismo día, las mismas clientas, las mismas muchachas. La diferencia es que ahora puedes
verlo todo de un golpe, y ella te avisa antes de que se te pase.
</p>
<p
className="ld-body mt-6 flex items-center gap-2 font-bold"
style={{ color: "var(--ld-tinta)" }}
>
Libreta
<ArrowRight className="h-4 w-4" style={{ color: "var(--ld-naranja)" }} />
Pantalla
</p>
</>
);
}
/** El tablero al que se transforma la libreta. */
function Tablero() {
return (
<div className="rounded-3xl bg-white p-5 shadow-[0_30px_70px_-30px_rgba(27,21,51,0.28)] ring-1 ring-black/[0.05] sm:p-6">
<div
className="mb-3 text-[0.72rem] font-extrabold uppercase tracking-widest"
style={{ color: "var(--ld-muted)" }}
>
Tu semana, completa
</div>
<CalendarGridVisual />
</div>
);
}
/**
* El cambio, contado con el scroll: la libreta se va y el tablero llega. Es la única
* sección con scrub (el avance ligado a la posición), y por eso es la que hace que
* bajar por la página se sienta como una película y no como una lista.
*
* Con movimiento reducido se colapsa a una pila estática con las dos piezas visibles.
*/
export function ShiftSection() {
const reduced = useReducedMotion();
const ref = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end end"] });
const notaOpacidad = useTransform(scrollYProgress, [0.05, 0.42], [1, 0]);
const notaEscala = useTransform(scrollYProgress, [0.05, 0.42], [1, 0.9]);
const notaGiro = useTransform(scrollYProgress, [0.05, 0.42], [-3, -9]);
const panelOpacidad = useTransform(scrollYProgress, [0.32, 0.62], [0, 1]);
const panelY = useTransform(scrollYProgress, [0.32, 0.62], [44, 0]);
const panelEscala = useTransform(scrollYProgress, [0.32, 0.62], [0.94, 1]);
if (reduced) {
return (
<section data-ld-section className="landing-crema ld-section">
<div className="ld-wrap grid grid-cols-1 gap-12">
<div>
<Texto />
</div>
<NotebookVisual className="max-w-md" />
<Tablero />
</div>
</section>
);
}
return (
<section data-ld-section className="landing-crema">
<div ref={ref} className="relative h-[250vh]">
<div className="sticky top-0 flex min-h-screen-safe items-center ld-section">
<div className="ld-wrap grid grid-cols-1 items-center gap-12 lg:grid-cols-2">
<motion.div
initial="hidden"
whileInView="show"
viewport={{ once: true }}
variants={revealUp(false)}
>
<Texto />
</motion.div>
{/* Escenario: las dos piezas ocupan la misma caja y se relevan. */}
<div className="relative min-h-[23rem] sm:min-h-[27rem]">
<motion.div
className="absolute inset-0"
style={{ opacity: notaOpacidad, scale: notaEscala, rotate: notaGiro }}
>
<NotebookVisual />
</motion.div>
<motion.div
className="absolute inset-0"
style={{ opacity: panelOpacidad, y: panelY, scale: panelEscala }}
>
<Tablero />
</motion.div>
</div>
</div>
</div>
</div>
</section>
);
}
@@ -0,0 +1,84 @@
import { motion } from "framer-motion";
import { EASE_EXPO } from "../../../lib/motion";
import { useReducedMotion } from "../../../lib/useReducedMotion";
const COLS = 7;
const ROWS = 9;
const DIAS = ["L", "M", "M", "J", "V", "S", "D"];
/**
* Los colores de las citas son los del muestrario del hero, que a su vez son
* `PIE_COLORS` del panel. No es una coincidencia estética: es el remate del hilo de la
* página — los colores con los que trabaja son los colores con los que lee su semana,
* y son los mismos que verá dentro del producto.
*/
const TINTES = ["#3b66ff", "#f17616", "#10b981", "#a855f7", "#ec4899"];
/**
* Retícula de agenda que se puebla sola. No es una captura: es la promesa del
* producto en movimiento — el día pasando de vacío a lleno.
*
* `density` es la fracción de celdas que se convierten en cita (0..1).
*/
export function CalendarGridVisual({
className = "",
density = 0.42,
}: {
className?: string;
density?: number;
}) {
const reduced = useReducedMotion();
const total = COLS * ROWS;
// Patrón determinista: un pseudo-aleatorio con semilla fija da la misma agenda
// en cada render y en cada corrida del audit visual, que si no capturaría
// pantallas distintas cada vez y reportaría diferencias falsas.
const celdas = Array.from({ length: total }, (_, i) => {
const h = ((i * 2654435761) % 1000) / 1000;
const ocupada = h < density;
return { ocupada, tinte: TINTES[i % TINTES.length] };
});
return (
<div className={`select-none ${className}`} aria-hidden="true">
<div className="mb-2 grid grid-cols-7 gap-1.5">
{DIAS.map((d, i) => (
<div
key={`${d}-${i}`}
className="text-center text-[0.6rem] font-bold uppercase tracking-widest opacity-40"
>
{d}
</div>
))}
</div>
<motion.div
className="grid grid-cols-7 gap-1.5"
initial="hidden"
whileInView="show"
viewport={{ once: true }}
variants={{
hidden: {},
show: { transition: { staggerChildren: reduced ? 0 : 0.012 } },
}}
>
{celdas.map((c, i) => (
<motion.div
key={i}
className="h-5 rounded-[0.3rem] sm:h-6"
style={{ background: c.ocupada ? c.tinte : "currentColor" }}
variants={{
hidden: reduced
? { scaleY: 1, opacity: c.ocupada ? 1 : 0.07 }
: { scaleY: 0.25, opacity: 0 },
show: {
scaleY: 1,
opacity: c.ocupada ? 1 : 0.07,
transition: { duration: 0.45, ease: EASE_EXPO },
},
}}
/>
))}
</motion.div>
</div>
);
}
@@ -0,0 +1,142 @@
import { motion, useInView } from "framer-motion";
import { useEffect, useRef, useState } from "react";
import { EASE_EXPO, inView } from "../../../lib/motion";
import { useReducedMotion } from "../../../lib/useReducedMotion";
import { RevenueLineVisual } from "./RevenueLineVisual";
// Etiquetas en el idioma del salón: «lo que entró», no «ingresos brutos». Los colores
// son los que el panel ya asigna a estas mismas tarjetas de resumen.
const KPIS = [
{ etiqueta: "Lo que entró este mes", valor: 184300, prefijo: "$", sufijo: "", color: "#10b981" },
{ etiqueta: "Clientas atendidas", valor: 412, prefijo: "", sufijo: "", color: "#3b66ff" },
{ etiqueta: "Sillas ocupadas", valor: 78, prefijo: "", sufijo: "%", color: "#a855f7" },
];
// Los tres primeros de PIE_COLORS, en el mismo orden en que el panel colorea la dona
// de servicios.
const TOP = [
{ nombre: "Tinte completo", monto: 42800, color: "#3b66ff" },
{ nombre: "Corte y peinado", monto: 38150, color: "#f17616" },
{ nombre: "Uñas de gel", monto: 24600, color: "#10b981" },
];
/** Contador que interpola de 0 al valor. Se salta la animación si hay reduced motion. */
function Contador({ valor, prefijo, sufijo }: { valor: number; prefijo: string; sufijo: string }) {
const reduced = useReducedMotion();
const ref = useRef<HTMLSpanElement>(null);
const visible = useInView(ref, { once: true, margin: "-10% 0px" });
const [n, setN] = useState(reduced ? valor : 0);
useEffect(() => {
if (reduced) {
setN(valor);
return;
}
if (!visible) return;
const DUR = 1400;
let raf = 0;
let inicio = 0;
const paso = (t: number) => {
if (!inicio) inicio = t;
const p = Math.min(1, (t - inicio) / DUR);
// ease-out cúbico: llega rápido y frena, igual que EASE_EXPO en CSS.
setN(Math.round(valor * (1 - Math.pow(1 - p, 3))));
if (p < 1) raf = requestAnimationFrame(paso);
};
raf = requestAnimationFrame(paso);
return () => cancelAnimationFrame(raf);
}, [visible, valor, reduced]);
return (
<span ref={ref} className="ld-num">
{prefijo}
{n.toLocaleString("es-MX")}
{sufijo}
</span>
);
}
/** El panel real, a escala. Es la prueba: datos de la cuenta demo. */
export function DashboardMockVisual({ className = "" }: { className?: string }) {
const reduced = useReducedMotion();
return (
<motion.div
className={`grid grid-cols-1 gap-4 rounded-[1.75rem] bg-white p-5 shadow-[0_44px_100px_-40px_rgba(27,21,51,0.32)] ring-1 ring-black/[0.05] sm:p-7 ${className}`}
initial={{ opacity: reduced ? 1 : 0, y: reduced ? 0 : 32 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={inView}
transition={{ duration: 0.8, ease: EASE_EXPO }}
>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{KPIS.map((k) => (
<div
key={k.etiqueta}
className="rounded-2xl p-4"
style={{ background: "var(--ld-nube)" }}
>
<div
className="text-[0.72rem] font-extrabold uppercase tracking-wider"
style={{ color: "var(--ld-muted)" }}
>
{k.etiqueta}
</div>
<div
className="mt-1.5 text-2xl font-extrabold tracking-tight sm:text-[1.8rem]"
style={{ color: k.color }}
>
<Contador valor={k.valor} prefijo={k.prefijo} sufijo={k.sufijo} />
</div>
</div>
))}
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[1.6fr_1fr]">
<div className="rounded-2xl p-4" style={{ background: "var(--ld-crema)" }}>
<div
className="mb-2 text-[0.72rem] font-extrabold uppercase tracking-wider"
style={{ color: "var(--ld-muted)" }}
>
Lo que entró · últimos 12 meses
</div>
<RevenueLineVisual />
</div>
<div className="rounded-2xl p-4" style={{ background: "var(--ld-crema)" }}>
<div
className="mb-3 text-[0.72rem] font-extrabold uppercase tracking-wider"
style={{ color: "var(--ld-muted)" }}
>
Lo que más te deja
</div>
<div className="grid grid-cols-1 gap-2.5">
{TOP.map((s, i) => (
<motion.div
key={s.nombre}
className="flex items-baseline justify-between gap-3"
initial={{ opacity: reduced ? 1 : 0, x: reduced ? 0 : -12 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={inView}
transition={{
duration: 0.5,
delay: reduced ? 0 : 0.4 + i * 0.1,
ease: EASE_EXPO,
}}
>
<span className="flex min-w-0 items-center gap-2">
<span
className="h-4 w-1.5 shrink-0 rounded-full"
style={{ background: s.color }}
/>
<span className="truncate text-[0.92rem] font-bold">{s.nombre}</span>
</span>
<span className="ld-num shrink-0 text-[0.92rem] font-extrabold">
${s.monto.toLocaleString("es-MX")}
</span>
</motion.div>
))}
</div>
</div>
</div>
</motion.div>
);
}
@@ -0,0 +1,122 @@
import { motion } from "framer-motion";
import { BellRing, Check, HandCoins } from "lucide-react";
import { EASE_EXPO } from "../../../lib/motion";
import { useReducedMotion } from "../../../lib/useReducedMotion";
/**
* Los tres momentos que la dueña recibe de vuelta. No son features: son avisos
* concretos, escritos como los leería en su teléfono. Flotan sobre el muestrario
* porque eso es lo que el producto pone encima de su día de trabajo.
*
* `pos` los coloca en las tres zonas libres alrededor del abanico: el abanico es un
* semicírculo que abre hacia arriba, así que el hueco está en las dos esquinas
* superiores y en la inferior izquierda, junto al pivote. Ponerlos a media altura
* los estrella contra la parte más ancha del abanico.
*
* El detalle se mantiene corto a propósito: la tarjeta es `truncate`, y una frase
* larga se corta con puntos suspensivos, que se lee como un error.
*
* En móvil no se usa nada de esto: los avisos se apilan (`MomentsStack`).
*/
const MOMENTOS = [
{
icono: Check,
color: "var(--ld-esmeralda)",
titulo: "Valentina confirmó",
detalle: "11:30 · Tinte y corte",
pos: "left-0 top-0",
fase: 0,
},
{
icono: HandCoins,
color: "var(--ld-naranja)",
titulo: "Llevas $4,280 hoy",
detalle: "9 servicios cobrados",
pos: "right-0 top-[13%]",
fase: 1.4,
},
{
icono: BellRing,
color: "var(--ld-rosa)",
titulo: "Carmen no ha venido",
detalle: "4 meses sin aparecer",
pos: "left-0 bottom-0",
fase: 2.6,
},
];
/** Tarjeta de aviso. La posición la decide quien la monta, no la tarjeta. */
function Momento({ m, indice }: { m: (typeof MOMENTOS)[number]; indice: number }) {
const reduced = useReducedMotion();
const Icono = m.icono;
return (
<motion.div
className="flex w-full items-center gap-3 rounded-2xl bg-white/95 px-3.5 py-3 shadow-[0_16px_38px_-16px_rgba(27,21,51,0.3)] ring-1 ring-black/[0.04] backdrop-blur-sm"
initial={reduced ? { opacity: 1, scale: 1 } : { opacity: 0, scale: 0.94 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
duration: reduced ? 0 : 0.75,
delay: reduced ? 0 : 0.75 + indice * 0.22,
ease: EASE_EXPO,
}}
>
<motion.span
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl"
style={{ background: m.color }}
animate={reduced ? undefined : { rotate: [0, -6, 0, 6, 0] }}
transition={{ duration: 6, repeat: Infinity, delay: m.fase, ease: "easeInOut" }}
>
<Icono className="h-4 w-4 text-white" strokeWidth={2.6} />
</motion.span>
<span className="min-w-0">
<span className="block truncate text-[0.9rem] font-extrabold leading-tight">
{m.titulo}
</span>
<span
className="block truncate text-[0.78rem] leading-tight"
style={{ color: "var(--ld-muted)" }}
>
{m.detalle}
</span>
</span>
</motion.div>
);
}
/**
* Capa flotante para pantallas grandes: se superpone al muestrario. La deriva vive
* en este envoltorio y no en la tarjeta, porque dos `animate` sobre la misma `y` se
* sobrescriben y la entrada se perdería.
*/
export function FloatingMomentsLayer({ className = "" }: { className?: string }) {
const reduced = useReducedMotion();
return (
<div className={`pointer-events-none absolute inset-0 ${className}`}>
{MOMENTOS.map((m, i) => (
<motion.div
key={m.titulo}
className={`absolute w-[14.5rem] ${m.pos}`}
// Fases y duraciones distintas por tarjeta: si suben y bajan en bloque, se
// nota que es un bucle y deja de leerse como avisos que llegan solos.
animate={reduced ? undefined : { y: [0, -13, 0] }}
transition={{ duration: 5.5 + i, repeat: Infinity, delay: m.fase, ease: "easeInOut" }}
>
<Momento m={m} indice={i} />
</motion.div>
))}
</div>
);
}
/** Pila para móvil: los mismos avisos, en columna y sin flotar. */
export function MomentsStack({ className = "" }: { className?: string }) {
return (
<div className={`grid grid-cols-1 gap-2.5 ${className}`}>
{MOMENTOS.map((m, i) => (
<Momento key={m.titulo} m={m} indice={i} />
))}
</div>
);
}
@@ -0,0 +1,69 @@
import { motion } from "framer-motion";
import { EASE_EXPO, inView } from "../../../lib/motion";
import { useReducedMotion } from "../../../lib/useReducedMotion";
const APUNTES = [
{ texto: "10:00 Sra. Ortiz — tinte", tachado: true },
{ texto: "11:30 ¿Camila o Mateo?", tachado: false },
{ texto: "12:00 confirmar x WhatsApp", tachado: true },
{ texto: "1:00 —— cancelado", tachado: true },
{ texto: "4:00 pendiente $$$", tachado: false },
{ texto: "cobrar a la Sra. del jueves", tachado: false },
];
/**
* El "antes": la libreta. Se dibuja con tipografía manuscrita del sistema y tachones
* que se trazan, porque un icono de libreta no transmite el desorden.
*
* Es el ÚNICO componente de la landing que sale a propósito de la paleta de la
* plataforma: papel crema, renglones, margen rojo y grafito. Precisamente porque es
* lo que el producto viene a reemplazar — si se pareciera al producto, la sección
* dejaría de contar un cambio. No unifiques estos colores con los tokens `--ld-*`.
*/
export function NotebookVisual({ className = "" }: { className?: string }) {
const reduced = useReducedMotion();
return (
<div
className={`relative overflow-hidden rounded-2xl bg-[#faf7ef] p-6 text-[#3b3a35] shadow-[0_24px_60px_-24px_rgba(0,0,0,0.55)] ${className}`}
aria-hidden="true"
>
{/* Renglones del cuaderno */}
<div
className="pointer-events-none absolute inset-0"
style={{
backgroundImage: "repeating-linear-gradient(#faf7ef 0 30px, #ddd6c4 30px 31px)",
}}
/>
{/* Margen rojo */}
<div className="pointer-events-none absolute inset-y-0 left-10 w-px bg-[#d98b8b]" />
<div className="relative grid grid-cols-1 gap-[0.72rem] pl-8">
{APUNTES.map((a, i) => (
<div key={a.texto} className="relative w-fit max-w-full">
<span
className="text-[0.92rem] leading-[1.6]"
style={{ fontFamily: "'Bradley Hand', 'Segoe Script', cursive" }}
>
{a.texto}
</span>
{a.tachado && (
<motion.span
className="absolute left-0 top-1/2 h-[1.5px] w-full bg-[#3b3a35]"
style={{ originX: 0 }}
initial={{ scaleX: reduced ? 1 : 0 }}
whileInView={{ scaleX: 1 }}
viewport={inView}
transition={{
duration: reduced ? 0 : 0.4,
delay: reduced ? 0 : 0.3 + i * 0.14,
ease: EASE_EXPO,
}}
/>
)}
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,93 @@
import { motion } from "framer-motion";
import { EASE_EXPO, inView } from "../../../lib/motion";
import { useReducedMotion } from "../../../lib/useReducedMotion";
// Serie fija: doce meses con tendencia al alza y ruido creíble. Determinista a
// propósito, para que el audit visual capture siempre la misma gráfica.
const SERIE = [38, 44, 41, 52, 58, 54, 66, 71, 68, 79, 86, 94];
const W = 520;
const H = 200;
const PAD = 12;
const MAX = Math.max(...SERIE);
const MIN = Math.min(...SERIE);
function yDe(v: number): number {
return H - PAD - ((v - MIN) / (MAX - MIN)) * (H - PAD * 2);
}
function pathFrom(serie: number[]): string {
const dx = (W - PAD * 2) / (serie.length - 1);
return serie
.map((v, i) => `${i === 0 ? "M" : "L"}${(PAD + i * dx).toFixed(1)} ${yDe(v).toFixed(1)}`)
.join(" ");
}
/** Línea de ingresos que se traza sola. El "antes/después" no se explica: se ve. */
export function RevenueLineVisual({ className = "" }: { className?: string }) {
const reduced = useReducedMotion();
const d = pathFrom(SERIE);
const area = `${d} L${W - PAD} ${H - PAD} L${PAD} ${H - PAD} Z`;
return (
<svg
viewBox={`0 0 ${W} ${H}`}
className={`w-full ${className}`}
role="img"
aria-label="Ingresos mensuales en tendencia ascendente"
>
<defs>
<linearGradient id="ld-rev-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3b66ff" stopOpacity="0.35" />
<stop offset="100%" stopColor="#3b66ff" stopOpacity="0" />
</linearGradient>
</defs>
{/* Rejilla base: tres guías en #eef0f4, el mismo `CartesianGrid` que usa la
gráfica de ingresos del panel. Dan escala sin competir. */}
{[0.25, 0.5, 0.75].map((f) => (
<line
key={f}
x1={PAD}
x2={W - PAD}
y1={PAD + f * (H - PAD * 2)}
y2={PAD + f * (H - PAD * 2)}
stroke="#eef0f4"
strokeWidth="1"
/>
))}
<motion.path
d={area}
fill="url(#ld-rev-fill)"
initial={{ opacity: reduced ? 1 : 0 }}
whileInView={{ opacity: 1 }}
viewport={inView}
transition={{ duration: 0.9, delay: reduced ? 0 : 0.5, ease: EASE_EXPO }}
/>
<motion.path
d={d}
fill="none"
stroke="#3b66ff"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: reduced ? 1 : 0 }}
whileInView={{ pathLength: 1 }}
viewport={inView}
transition={{ duration: reduced ? 0 : 1.4, ease: EASE_EXPO }}
/>
{/* Punto final: la marca del "hoy". Aterriza cuando la línea termina. */}
<motion.circle
cx={W - PAD}
cy={yDe(SERIE[SERIE.length - 1])}
r="5"
fill="#3b66ff"
initial={{ scale: reduced ? 1 : 0 }}
whileInView={{ scale: 1 }}
viewport={inView}
transition={{ duration: 0.5, delay: reduced ? 0 : 1.3, ease: EASE_EXPO }}
/>
</svg>
);
}
@@ -0,0 +1,120 @@
import { useEffect, useRef } from "react";
import { motion, useMotionValue, useSpring, useTransform } from "framer-motion";
import { EASE_EXPO } from "../../../lib/motion";
import { useReducedMotion } from "../../../lib/useReducedMotion";
/**
* Muestrario: el abanico de coloración que hay sobre el mostrador de cualquier salón.
* Es el objeto más característico de este mundo y ya es vívido de por sí, así que hace
* de firma de la página sin necesidad de inventar un gradiente.
*
* Los ocho colores NO son decorativos ni inventados: son `PIE_COLORS` de
* [DashboardPage.tsx](../../../pages/DashboardPage.tsx), la paleta categórica que la
* app ya usa para los servicios en la gráfica de dona, y los mismos que el seed
* reparte entre los avatares de los empleados. Son ocho, igual que las muestras.
*
* De ahí que el hilo de la página sea literal y no una metáfora: el abanico del hero
* trae los mismos hex que la dueña va a ver en sus fichas cuando entre.
*
* Va sin etiquetas: las muestras se solapan como en un abanico real, así que solo la
* última quedaría a la vista, y una única etiqueta girada se lee como un error en vez
* de como un detalle. La forma y el color dicen lo que hay que decir.
*/
const TINTES = [
"#3b66ff",
"#f17616",
"#10b981",
"#a855f7",
"#ec4899",
"#14b8a6",
"#f59e0b",
"#6366f1",
];
// El abanico abre 92° repartidos entre las nueve muestras. Más apertura y las puntas
// exteriores se salen de la columna y chocan con los avisos flotantes.
const SPREAD = 92;
const anguloDe = (i: number) => -SPREAD / 2 + (SPREAD / (TINTES.length - 1)) * i;
export function SwatchFanVisual({ className = "" }: { className?: string }) {
const reduced = useReducedMotion();
const ref = useRef<HTMLDivElement>(null);
// Puntero normalizado a 1..1. Con `useSpring` el abanico persigue al cursor con
// inercia en lugar de pegarse a él, que es lo que lo hace sentir como un objeto.
const px = useMotionValue(0);
const py = useMotionValue(0);
const sx = useSpring(px, { stiffness: 60, damping: 18, mass: 0.6 });
const sy = useSpring(py, { stiffness: 60, damping: 18, mass: 0.6 });
const rotateZ = useTransform(sx, [-1, 1], [7, -7]);
const rotateY = useTransform(sx, [-1, 1], [-11, 11]);
const rotateX = useTransform(sy, [-1, 1], [7, -7]);
useEffect(() => {
if (reduced) return;
// `pointerFine` decide si hay cursor que perseguir. En táctil no hay puntero,
// así que el abanico se queda con su vaivén propio (animate del contenedor).
const fino = window.matchMedia("(pointer: fine)").matches;
if (!fino) return;
const onMove = (e: PointerEvent) => {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
px.set(((e.clientX - (r.left + r.width / 2)) / (r.width / 2)) * 0.8);
py.set(((e.clientY - (r.top + r.height / 2)) / (r.height / 2)) * 0.8);
};
window.addEventListener("pointermove", onMove, { passive: true });
return () => window.removeEventListener("pointermove", onMove);
}, [px, py, reduced]);
return (
<div
ref={ref}
className={`relative select-none ${className}`}
style={{ perspective: 1100 }}
aria-hidden="true"
>
<motion.div
className="relative mx-auto h-[16.5rem] w-full max-w-[27rem] sm:h-[20rem]"
style={
reduced
? undefined
: { rotateZ, rotateY, rotateX, transformStyle: "preserve-3d" }
}
// Vaivén propio: sostiene la vida del abanico en táctil, donde no hay cursor.
animate={reduced ? undefined : { y: [0, -10, 0] }}
transition={{ duration: 7, repeat: Infinity, ease: "easeInOut" }}
>
{TINTES.map((color, i) => {
const ang = anguloDe(i);
return (
<motion.div
key={color}
// Se centra con `inset-x-0 mx-auto` y NO con `left-1/2` + margen
// negativo: el ancho cambia en `sm:` y un margen fijo solo puede
// centrar uno de los dos. `translateX(-50%)` tampoco sirve aquí,
// porque framer-motion es dueña del `transform` de este nodo.
className="absolute inset-x-0 bottom-0 mx-auto h-[12rem] w-[4.15rem] origin-[50%_112%] rounded-[1rem] sm:h-[14.5rem] sm:w-[4.85rem]"
style={{
background: color,
boxShadow: "0 18px 40px -18px rgba(15,23,42,0.42)",
}}
// Abre desde el abanico cerrado: todas apiladas en 0° y se despliegan.
// Es el momento orquestado de la carga, no un fade genérico.
initial={reduced ? { rotate: ang, opacity: 1 } : { rotate: 0, opacity: 0 }}
animate={{ rotate: ang, opacity: 1 }}
transition={{
duration: reduced ? 0 : 1.15,
delay: reduced ? 0 : 0.15 + i * 0.055,
ease: EASE_EXPO,
}}
whileHover={reduced ? undefined : { y: -16, scale: 1.05 }}
/>
);
})}
</motion.div>
</div>
);
}
@@ -0,0 +1,54 @@
import { motion } from "framer-motion";
import { EASE_EXPO, inView } from "../../../lib/motion";
import { useReducedMotion } from "../../../lib/useReducedMotion";
// Nombres del negocio demo sembrado (plantilla estetica-spa) y los colores que el
// seed reparte a los avatares: cada persona llega con el suyo, igual que dentro
// de la app.
const EQUIPO = [
{ nombre: "Valentina", carga: 0.92, color: "#3b66ff", citas: 34, nota: "hasta el tope" },
{ nombre: "Mateo", carga: 0.74, color: "#f17616", citas: 27, nota: "bien" },
{ nombre: "Camila", carga: 0.61, color: "#10b981", citas: 22, nota: "bien" },
{ nombre: "Diego", carga: 0.38, color: "#a855f7", citas: 14, nota: "le caben más" },
{ nombre: "Renata", carga: 0.22, color: "#ec4899", citas: 8, nota: "casi libre" },
];
/** Carga real por especialista. El punto: la diferencia se ve de un golpe. */
export function TeamLoadVisual({ className = "" }: { className?: string }) {
const reduced = useReducedMotion();
return (
<div className={`grid grid-cols-1 gap-3.5 ${className}`}>
{EQUIPO.map((p, i) => (
<div key={p.nombre} className="grid grid-cols-1 gap-1.5">
<div className="flex items-baseline justify-between gap-3 text-sm">
<span className="font-extrabold">{p.nombre}</span>
<span className="shrink-0 text-xs" style={{ color: "var(--ld-muted)" }}>
<span className="ld-num font-bold">{p.citas}</span> citas · {p.nota}
</span>
</div>
{/* La pista es un div propio con opacidad en línea: el modificador de
opacidad de Tailwind no aplica sobre currentColor. */}
<div className="relative h-2.5 overflow-hidden rounded-full">
<div
className="absolute inset-0 rounded-full"
style={{ background: "currentColor", opacity: 0.12 }}
/>
<motion.div
className="absolute inset-y-0 left-0 w-full rounded-full"
style={{ background: p.color, originX: 0 }}
initial={{ scaleX: reduced ? p.carga : 0 }}
whileInView={{ scaleX: p.carga }}
viewport={inView}
transition={{
duration: reduced ? 0 : 0.9,
delay: reduced ? 0 : i * 0.09,
ease: EASE_EXPO,
}}
/>
</div>
</div>
))}
</div>
);
}
+19 -2
View File
@@ -4,6 +4,19 @@ export function Spinner({ className = "" }: { className?: string }) {
return <Loader2 className={`h-4 w-4 animate-spin ${className}`} />;
}
/**
* Fallback de Suspense para las rutas que se cargan por demanda (calendario y
* tablero, que arrastran FullCalendar y recharts). Ocupa el alto disponible para
* que el shell no dé un salto de layout al resolverse el chunk.
*/
export function RouteSpinner() {
return (
<div className="flex flex-1 items-center justify-center py-16 text-slate-400">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
);
}
export function EmptyState({
icon: Icon,
title,
@@ -41,10 +54,14 @@ export function PageHeader({
actions?: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-3 px-5 pt-5 sm:px-7 sm:pt-7 md:flex-row md:items-end md:justify-between">
<div className="flex shrink-0 flex-col gap-3 px-5 pt-5 sm:px-7 sm:pt-7 md:flex-row md:items-end md:justify-between">
<div>
<h1 className="text-xl font-extrabold tracking-tight text-slate-900 sm:text-2xl">{title}</h1>
{subtitle && <p className="mt-1 text-sm text-slate-500">{subtitle}</p>}
{/* El subtítulo se oculta en teléfono: son 2 líneas de texto descriptivo
(~70px) que en una pantalla de 390px le hacen falta al contenido, y
en el calendario además describe gestos de ratón ("arrastra",
"redimensiona") que no aplican al tacto. */}
{subtitle && <p className="mt-1 hidden text-sm text-slate-500 sm:block">{subtitle}</p>}
</div>
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
</div>
+397 -26
View File
@@ -4,18 +4,39 @@
:root {
color-scheme: light;
/* Insets del sistema (Dynamic Island, notch, home indicator). Con
viewport-fit=cover el contenido se dibuja bajo esas zonas, así que el
chrome de la app tiene que descontarlas explícitamente. */
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-left: env(safe-area-inset-left, 0px);
--safe-right: env(safe-area-inset-right, 0px);
}
* {
-webkit-tap-highlight-color: transparent;
}
html {
/* iOS reescala el texto al rotar a landscape si no se fija. */
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
html,
body,
#root {
height: 100%;
}
/* El shell del panel vive en un #root de altura fija; la landing, no. Se relaja
solo cuando la landing está montada, así el panel conserva su altura fija.
:has() está soportado en Safari 15.4+, dentro del rango de iOS objetivo. */
body:has(.landing) #root {
height: auto;
min-height: 100%;
}
body {
margin: 0;
font-family: Inter, system-ui, sans-serif;
@@ -23,25 +44,41 @@ body {
color: #0f172a;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
/* Red de seguridad: ningún hijo debe poder desplazar la página en horizontal. */
overflow-x: hidden;
overscroll-behavior-y: none;
}
/* Custom scrollbars */
::-webkit-scrollbar {
width: 10px;
height: 10px;
/* Evita el zoom por doble toque y el retardo de 300 ms en controles. */
button,
a,
[role="button"],
label,
summary {
touch-action: manipulation;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 9999px;
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
background-clip: padding-box;
/* Scrollbars visibles solo con puntero fino. En táctil deben ser overlay
(como en iOS nativo): un scrollbar de 10 px roba ancho al layout y
descuadra el cálculo de 100% en pantallas estrechas. */
@media (pointer: fine) {
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 9999px;
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
background-clip: padding-box;
}
}
/* FullCalendar overrides */
@@ -161,6 +198,24 @@ body {
.fc .fc-timegrid-slot {
height: 2.4rem;
}
/* En táctil los slots se ajustan por tamaño de pantalla: la grilla de 08:00 a
21:00 son 26 slots, así que una altura fija de 2.4rem produce ~1000 px y
desborda cualquier teléfono. El calendario scrollea internamente (como
Google Calendar) en lugar de estirar la página. */
@media (max-width: 640px) {
.fc .fc-timegrid-slot {
height: 2.1rem;
}
}
@media (min-width: 641px) and (max-width: 1024px) {
.fc .fc-timegrid-slot {
height: 2.25rem;
}
}
/* El scroller interno del timegrid necesita arrastre fluido en iOS. */
.fc .fc-scroller {
-webkit-overflow-scrolling: touch;
}
.fc-timegrid-event {
overflow: hidden;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
@@ -204,24 +259,67 @@ body {
font-weight: 500;
}
/* Mobile: prevent squished week/month columns by giving them a min-width.
The card wrapper has overflow-x-auto, so wider grids scroll horizontally. */
/* Móvil: la vista Semana necesita un ancho mínimo o las 7 columnas quedan
ilegibles; el card la envuelve en overflow-auto, así que scrollea dentro de
la tarjeta sin arrastrar la página. La vista Mes sí cabe en un teléfono
(7 columnas de ~50 px), así que no se le fuerza ancho mínimo. */
@media (max-width: 640px) {
.fc-timeGridWeek-view .fc-scrollgrid-section > table,
.fc-timeGridWeek-view .fc-scrollgrid {
min-width: 640px;
}
.fc-dayGridMonth-view .fc-scrollgrid-section > table,
.fc-dayGridMonth-view .fc-scrollgrid {
min-width: 560px;
}
/* bigger touch targets for events */
/* Objetivos táctiles más grandes para los eventos */
.fc-event {
min-height: 28px;
min-height: 30px;
padding: 3px 6px !important;
}
.fc .fc-button {
padding: 0.4rem 0.6rem;
padding: 0.45rem 0.6rem;
}
/* Etiquetas de hora más estrechas: en 390 px cada píxel del eje cuenta. */
.fc .fc-timegrid-axis,
.fc .fc-timegrid-slot-label {
font-size: 0.65rem;
}
.fc .fc-timegrid-axis-frame,
.fc .fc-timegrid-slot-label-frame {
padding-right: 2px;
}
.fc .fc-col-header-cell-cushion {
padding: 0.45rem 0;
font-size: 0.65rem;
}
/* La vista Lista es la más cómoda en teléfono: más aire al tocar. */
.fc .fc-list-event {
font-size: 0.85rem;
}
.fc .fc-list-event td {
padding: 0.7rem 0.6rem;
}
}
/* Tablet (iPad portrait y mini): la semana completa cabe sin scroll lateral,
solo hay que evitar que herede el ancho mínimo del teléfono. */
@media (min-width: 641px) and (max-width: 1024px) {
.fc-timeGridWeek-view .fc-scrollgrid,
.fc-dayGridMonth-view .fc-scrollgrid {
min-width: 0;
}
.fc .fc-toolbar-title {
font-size: 1.05rem;
}
.fc .fc-button {
font-size: 0.75rem;
}
}
/* Toolbar del calendario tocable en cualquier pantalla táctil. Va por tipo de
puntero y no por ancho: un iPad en landscape mide 1180px (tier desktop) pero
se sigue tocando con el dedo. No se hereda el min-height de .btn (44px) para
no disparar el alto de la barra de navegación del calendario. */
@media (pointer: coarse) {
.fc .fc-button {
min-height: 40px;
}
}
@@ -281,8 +379,45 @@ body {
.btn-danger:hover:not(:disabled) {
background: #fecaca;
}
/* ---- Safe areas y alturas de viewport ---- */
.safe-area-bottom {
padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
padding-bottom: max(0.75rem, var(--safe-bottom));
}
/* Chrome superior (headers fijos): descuenta la Dynamic Island / notch. */
.safe-top {
padding-top: var(--safe-top);
}
/* Landscape con notch: el contenido no debe quedar bajo la esquina redondeada. */
.safe-x {
padding-left: var(--safe-left);
padding-right: var(--safe-right);
}
/* Altura de viewport estable en iOS. 100vh NO descuenta la barra de URL
dinámica de Safari, así que el shell queda más alto que el área visible y
la página se puede desplazar. dvh sí la descuenta; vh queda como fallback. */
.h-screen-safe {
height: 100vh;
height: 100dvh;
}
.max-h-screen-safe {
max-height: 100vh;
max-height: 100dvh;
}
.min-h-screen-safe {
min-height: 100vh;
min-height: 100dvh;
}
/* Franjas con scroll lateral (chips de filtro) sin barra visible: en móvil la
barra roba alto y el gesto de arrastre ya comunica que hay más contenido. */
.scrollbar-none {
scrollbar-width: none;
-ms-overflow-style: none;
}
.scrollbar-none::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
@layer components {
@@ -305,6 +440,242 @@ body {
border-color: #3b66ff;
box-shadow: 0 0 0 3px rgba(59, 102, 255, 0.15);
}
/* ---- Landing pública ----
Scope propio porque el panel vive en #f6f7fb y la landing tiene su propia
identidad. Va DENTRO de la capa (el default del repo) para que cualquier
utilidad de Tailwind aplicada en el JSX gane por especificidad de capa.
La paleta NO se inventa: son los colores que la plataforma ya usa. `--ld-azul` y
`--ld-naranja` son exactamente el cuadrado y el punto del logo
(`public/favicon.svg`), la tinta y los fondos son los del panel, y los seis tonos
de acento son los que el seed asigna a los avatares de empleados y a los
servicios (`server/lib/templates.ts`).
Por eso el hilo de la página —«sus colores se vuelven sus números»— es literal:
el abanico del hero trae los mismos hex que la dueña verá en sus fichas. */
.landing {
/* Marca */
--ld-azul: #3b66ff; /* brand-500: primario del logo y de .btn-primary */
--ld-azul-hondo: #1c36dc; /* brand-700: el hover que ya usa el panel */
--ld-naranja: #f17616; /* accent-500: el punto del logo */
/* Acentos: los colores de avatares y servicios del panel, sin cambiar un dígito */
--ld-indigo: #6366f1;
--ld-purpura: #a855f7;
--ld-rosa: #ec4899;
--ld-ambar: #f59e0b;
--ld-esmeralda: #10b981;
--ld-turquesa: #14b8a6;
/* Estructura: idéntica a la del panel */
--ld-tinta: #0f172a;
--ld-papel: #ffffff;
--ld-lienzo: #f6f7fb; /* el fondo del body de la app */
--ld-nube: #eef4ff; /* brand-50: el mismo lavado del "hoy" en el calendario */
--ld-crema: #fff7ed; /* orange-50, ya presente en el panel */
--ld-muted: #64748b;
--ld-hairline: #eef0f4;
min-height: 100vh;
min-height: 100dvh;
overflow-x: clip;
font-family: Inter, system-ui, sans-serif;
color: var(--ld-tinta);
background: var(--ld-papel);
}
/* Lavados de sección. Los tres salen del panel, así que pasar de la landing al
producto no se siente como cambiar de sitio. */
.landing-papel {
background: var(--ld-papel);
}
.landing-nube {
background: var(--ld-nube);
}
.landing-crema {
background: var(--ld-lienzo);
}
/* Display: Fraunces con el eje WONK abierto. Es un serif suave y con carácter,
no una Didone de alto contraste: a 56px+ se lee cálido en vez de editorial-frío,
que es lo que este público necesita. */
.ld-display {
font-family: Fraunces, Georgia, "Times New Roman", serif;
font-variation-settings: "SOFT" 40, "WONK" 1;
font-size: clamp(2.5rem, 7.2vw, 5.5rem);
line-height: 1;
letter-spacing: -0.022em;
font-weight: 800;
}
.ld-title {
font-family: Fraunces, Georgia, "Times New Roman", serif;
font-variation-settings: "SOFT" 40, "WONK" 1;
font-size: clamp(1.875rem, 4.4vw, 3.15rem);
line-height: 1.08;
letter-spacing: -0.015em;
font-weight: 800;
}
/* Cuerpo en Inter, la misma tipografía del panel, a 17px mínimo y 1.6 de
interlínea: el público incluye personas de 50+ y la legibilidad pesa más que la
densidad. */
.ld-lead {
font-size: clamp(1.0625rem, 1.9vw, 1.3125rem);
line-height: 1.6;
font-weight: 500;
}
.ld-body {
font-size: 1.0625rem;
line-height: 1.65;
}
.ld-eyebrow {
font-size: 0.8125rem;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.ld-section {
padding-block: clamp(4.5rem, 10vh, 9rem);
padding-inline: max(1.25rem, var(--safe-left)) max(1.25rem, var(--safe-right));
}
.ld-wrap {
width: 100%;
max-width: 72rem;
margin-inline: auto;
}
/* Canaleta lateral que además descuenta el notch en landscape.
NO combines `.safe-x` con `px-*` en el mismo elemento: `.safe-x` vive fuera de
`@layer` y por tanto gana a las utilidades de Tailwind, así que deja el padding
en 0 siempre que el inset del sistema sea 0 — es decir, en todo lo que no sea un
iPhone en landscape. El resultado es contenido pegado al borde en móvil. Esta
clase resuelve las dos cosas en una sola declaración, como ya hace `.ld-section`. */
.ld-gutter {
padding-inline: max(1.25rem, var(--safe-left)) max(1.25rem, var(--safe-right));
}
/* Números que se interpolan frame a frame. Sin tabular-nums cada dígito cambia
de ancho y la línea entera relayoutea en cada frame. Inter porque Fraunces no
tiene cifras tabulares. */
.ld-num {
font-family: Inter, system-ui, sans-serif;
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum";
}
/* Botón principal: el mismo azul de `.btn-primary` del panel, con el degradado
brand-500 → brand-700 (los dos tonos que la app ya usa en normal y hover). El
naranja del logo no se usa como fondo de acción: en el favicon es el punto que
marca, no la superficie que trabaja, y en la app tampoco es un color de botón. */
.ld-cta {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
min-height: 56px;
padding-inline: 2rem;
border-radius: 9999px;
font-weight: 800;
font-size: 1rem;
color: #fff;
background: linear-gradient(100deg, var(--ld-azul), var(--ld-azul-hondo));
box-shadow: 0 12px 30px -10px rgba(59, 102, 255, 0.5);
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.2s;
}
.ld-cta:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 18px 40px -12px rgba(59, 102, 255, 0.58);
}
.ld-cta:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.ld-cta-ghost {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
min-height: 56px;
padding-inline: 1.75rem;
border-radius: 9999px;
font-weight: 700;
font-size: 1rem;
color: var(--ld-tinta);
background: rgba(255, 255, 255, 0.8);
border: 1.5px solid #e5e7eb;
transition: background 0.2s, border-color 0.2s;
}
.ld-cta-ghost:hover {
background: #fff;
border-color: #d1d5db;
}
/* Foco visible propio: el degradado del CTA tapa el outline por defecto. */
.landing a:focus-visible,
.landing button:focus-visible {
outline: 3px solid var(--ld-azul);
outline-offset: 3px;
}
/* Titular con degradado: naranja→ámbar. Es el papel que el naranja tiene en el
logo —el punto que marca el renglón importante— trasladado a la frase que carga
la promesa. El color sólido va primero como fallback: `color: transparent` sin
`background-clip: text` deja el texto invisible, y ese fallo se llevaría media
frase del hero. */
.ld-gradient-text {
color: var(--ld-naranja);
}
@supports ((-webkit-background-clip: text) or (background-clip: text)) {
.ld-gradient-text {
background-image: linear-gradient(100deg, var(--ld-naranja), var(--ld-ambar));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
}
}
/* ---- Anti auto-zoom de iOS (fuera de @layer para ganar a las utilidades) ----
Mobile Safari hace zoom al enfocar cualquier campo con font-size < 16px y no
revierte al desenfocar: la vista queda escalada y corrida. Ese es el origen
del "arranca con zoom y desplazada" al tocar el campo de correo en el login.
Se fuerzan 16px en todo puntero grueso (iPhone y iPad) en lugar de bloquear
el zoom con maximum-scale=1, que rompería el pinch-zoom de accesibilidad.
`select` incluido: iOS también zoomea al abrir un desplegable pequeño. */
@media (pointer: coarse) {
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="color"]),
select,
textarea,
.input,
.select,
.textarea {
font-size: 16px;
}
/* Objetivos táctiles cómodos (Apple recomienda 44 pt). */
.btn,
.input,
.select,
.textarea {
min-height: 44px;
}
/* Para controles compactos (chips de filtro, grupos segmentados) donde 44px
rompería la densidad de la barra. Se decide por tipo de puntero y no por
ancho de pantalla: un iPad se toca con el dedo igual que un iPhone, así que
un breakpoint `sm:` dejaría fuera justamente a las tablets. */
.tap-target {
min-height: 40px;
}
/* Botones de solo icono (editar, borrar, cerrar, menú): con p-1.5 miden 28px,
por debajo de lo tocable con el pulgar. Se marcan con `.icon-btn` en lugar de
detectarlos por selector: se intentó `button:has(> svg:only-child)` y es
engañoso, porque `:only-child` solo mira hijos ELEMENTO y un botón con
etiqueta ("<Plus/> Nueva cita") también encaja — el texto es un nodo de
texto. Esa regla, al fijar `display`, convirtió los NavLink del menú lateral
en inline-flex y los repartió en dos columnas. */
.icon-btn {
min-height: 40px;
min-width: 40px;
}
/* Se centra el icono, no se cambia el `display` del botón. El preflight de
Tailwind pone `svg { display: block }`, así que el icono ignora `text-align`
y `margin-inline: auto` es lo que lo centra en la caja ampliada. Tocar el
display aquí sería contraproducente: esta regla está fuera de @layer y
ganaría a las utilidades, de modo que un `hidden`/`lg:block` dejaría de
funcionar y el botón de colapsar aparecería en el móvil. */
.icon-btn > svg {
margin-inline: auto;
}
}
.label {
display: block;
+38
View File
@@ -0,0 +1,38 @@
import type { Transition, Variants } from "framer-motion";
/**
* ease-out-expo. Un solo easing en toda la landing: es lo que produce la
* sensación de que los elementos "pesan algo y frenan solos", y mezclar curvas
* es exactamente lo que hace que una landing se sienta improvisada.
*/
export const EASE_EXPO = [0.16, 1, 0.3, 1] as const;
export const baseTransition: Transition = { duration: 0.7, ease: EASE_EXPO };
/**
* Reveal vertical. `reduced` colapsa el estado inicial al final: NO acorta la
* transición, la elimina. Si `hidden` dejara `opacity: 0` cuando el usuario pide
* menos movimiento, el contenido quedaría invisible para siempre porque la
* animación que lo revela nunca se dispara.
*/
export function revealUp(reduced: boolean, distance = 24): Variants {
return {
hidden: reduced ? { opacity: 1, y: 0 } : { opacity: 0, y: distance },
show: { opacity: 1, y: 0, transition: baseTransition },
};
}
/** Contenedor que escalona a sus hijos. Con movimiento reducido, stagger 0. */
export function revealStagger(reduced: boolean, stagger = 0.08): Variants {
return {
hidden: {},
show: { transition: { staggerChildren: reduced ? 0 : stagger } },
};
}
/**
* Props de viewport compartidas para `whileInView`. Una sola pasada (`once`), con
* margen negativo para que el reveal arranque cuando la sección ya entró de
* verdad y no en el instante en que su borde roza la pantalla.
*/
export const inView = { once: true, margin: "-12% 0px -12% 0px" } as const;
+45
View File
@@ -0,0 +1,45 @@
import { useEffect, useState } from "react";
/**
* Tres tamaños, alineados con los breakpoints del CSS del calendario:
* phone <= 640px (iPhone 12: 390, iPhone 16 Pro Max: 440)
* tablet 641-1024px (iPad mini: 744, iPad 10.9": 820, iPad Pro portrait: 1024)
* desktop >= 1025px (iPad landscape: 1180, iPad Pro landscape: 1366)
*
* El límite de tablet incluye 1024 a propósito: un iPad Pro en portrait mide justo
* 1024px y, descontando la barra lateral, deja ~712px para el calendario. Con 7
* columnas son ~100px por día, y con dos citas solapadas los títulos quedan en
* "A...". No coincide con el `lg:` de Tailwind (también 1024), así que a 1024px hay
* barra lateral fija Y vista de 3 días: es justo lo que se busca en ese equipo.
*/
export type Breakpoint = "phone" | "tablet" | "desktop";
const PHONE = "(max-width: 640px)";
const TABLET = "(min-width: 641px) and (max-width: 1024px)";
function currentBreakpoint(): Breakpoint {
if (typeof window === "undefined") return "desktop";
if (window.matchMedia(PHONE).matches) return "phone";
if (window.matchMedia(TABLET).matches) return "tablet";
return "desktop";
}
export function useBreakpoint(): Breakpoint {
const [bp, setBp] = useState<Breakpoint>(currentBreakpoint);
useEffect(() => {
const lists = [window.matchMedia(PHONE), window.matchMedia(TABLET)];
const onChange = () => setBp(currentBreakpoint());
lists.forEach((l) => l.addEventListener("change", onChange));
// Al rotar un iPad se cruza de 820 a 1180 y los media queries ya lo cubren,
// pero en iOS el evento de orientación llega antes de que se reasienten las
// medidas; re-evaluamos para no quedar con el layout de la orientación previa.
window.addEventListener("orientationchange", onChange);
return () => {
lists.forEach((l) => l.removeEventListener("change", onChange));
window.removeEventListener("orientationchange", onChange);
};
}, []);
return bp;
}
+23
View File
@@ -0,0 +1,23 @@
import { useEffect, useState } from "react";
const QUERY = "(prefers-reduced-motion: reduce)";
/**
* `true` cuando el sistema pide menos movimiento. Se suscribe a los cambios: en
* iOS la preferencia se activa desde Ajustes sin recargar la pestaña, así que
* leerla una sola vez al montar dejaría la landing animando de todas formas.
*/
export function useReducedMotion(): boolean {
const [reduced, setReduced] = useState(() => window.matchMedia(QUERY).matches);
useEffect(() => {
const mq = window.matchMedia(QUERY);
const onChange = (e: MediaQueryListEvent) => setReduced(e.matches);
mq.addEventListener("change", onChange);
// Resincroniza: la preferencia pudo cambiar entre el render inicial y el efecto.
setReduced(mq.matches);
return () => mq.removeEventListener("change", onChange);
}, []);
return reduced;
}
+43
View File
@@ -0,0 +1,43 @@
import { useCallback, useEffect, useState } from "react";
const KEY = "ap_sidebar_collapsed";
function read(): boolean {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(KEY) === "1";
} catch {
return false; // Safari en modo privado puede lanzar al leer localStorage.
}
}
/**
* Colapso de la barra lateral en pantallas grandes (lg+), recordado entre
* sesiones. En iPad Pro portrait (1024px) la barra expandida se lleva 256px de
* los 1024 disponibles, así que poder colapsarla a solo iconos es lo que más
* ancho devuelve al contenido.
*/
export function useSidebarCollapsed() {
const [collapsed, setCollapsed] = useState(read);
const toggle = useCallback(() => setCollapsed((c) => !c), []);
useEffect(() => {
try {
window.localStorage.setItem(KEY, collapsed ? "1" : "0");
} catch {
/* sin persistencia si el almacenamiento está bloqueado */
}
}, [collapsed]);
// Mantiene ambos shells (app y admin) en sincronía si se abren en dos pestañas.
useEffect(() => {
const onStorage = (e: StorageEvent) => {
if (e.key === KEY && e.newValue !== null) setCollapsed(e.newValue === "1");
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, []);
return { collapsed, toggle };
}
+109 -36
View File
@@ -12,13 +12,19 @@ import type {
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, AlertTriangle } from "lucide-react";
import { Plus, Filter, RotateCcw, AlertTriangle } from "lucide-react";
import { api } from "../lib/api";
import { useAuth } from "../lib/auth";
import type { Appointment } from "../../shared/types";
import { PageHeader, Spinner, EmptyState } from "../components/ui";
import { PageHeader, Spinner } from "../components/ui";
import { AppointmentModal } from "../components/AppointmentModal";
import { statusColor } from "../lib/format";
import { useBreakpoint } from "../lib/useBreakpoint";
/** Aire bajo el calendario (padding inferior del contenedor + respiro visual). */
const CAL_BOTTOM_GAP = 20;
/** Por debajo de esto la grilla es inservible: mejor que la página scrollee. */
const CAL_MIN_HEIGHT = 360;
const STATUS_FILTERS = [
{ value: "all", label: "Todas" },
@@ -38,15 +44,13 @@ export function CalendarPage() {
const [draftSlot, setDraftSlot] = useState<{ start: string; end: string } | null>(null);
const [employeeFilter, setEmployeeFilter] = useState<number | "all">("all");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [isMobile, setIsMobile] = useState(
typeof window !== "undefined" ? window.matchMedia("(max-width: 640px)").matches : false
);
useEffect(() => {
const mq = window.matchMedia("(max-width: 640px)");
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
const bp = useBreakpoint();
const isMobile = bp === "phone";
const isTablet = bp === "tablet";
const calWrapRef = useRef<HTMLDivElement>(null);
const chromeRef = useRef<HTMLDivElement>(null);
const [calHeight, setCalHeight] = useState<number | null>(null);
const isOwner = user?.role === "owner";
// Employees see only their own by default
@@ -75,6 +79,39 @@ export function CalendarPage() {
},
});
// La altura del calendario se le pasa como número en lugar de height="100%":
// FullCalendar mide su contenedor una sola vez al montar y solo re-mide en
// resize de ventana, así que se quedaba con una altura vieja cuando el layout
// cambiaba después (en un iPhone 12 daba 360px dentro de un contenedor de
// 525px). Se mide contra el viewport —posición real del contenedor e
// innerHeight— y no contra el contenedor flex, cuya altura se resuelve más
// tarde que el primer ciclo de medición.
useEffect(() => {
const compute = () => {
const el = calWrapRef.current;
if (!el) return;
const top = el.getBoundingClientRect().top;
const available = Math.round(window.innerHeight - top - CAL_BOTTOM_GAP);
setCalHeight(Math.max(CAL_MIN_HEIGHT, available));
};
compute();
// Se observa la barra de filtros y no el body: el shell es overflow-hidden con
// altura fija, así que el body nunca cambia de tamaño y un observador sobre él
// no volvería a dispararse. Lo que sí reflowea es ese bloque (el filtro de
// empleados pasa a dos filas al llegar la lista), y eso mueve el `top` del
// calendario, que es justo lo que hay que recalcular.
const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(compute) : null;
if (chromeRef.current) ro?.observe(chromeRef.current);
window.addEventListener("resize", compute);
window.addEventListener("orientationchange", compute);
return () => {
ro?.disconnect();
window.removeEventListener("resize", compute);
window.removeEventListener("orientationchange", compute);
};
// employeesData y moveError cambian el alto del chrome; bp cambia la toolbar.
}, [employeesData, moveError, bp]);
const events = useMemo(() => {
return (data?.appointments ?? []).map((a) => {
const empColor = a.employee?.color ?? "#3b66ff";
@@ -148,6 +185,14 @@ export function CalendarPage() {
const onToday = () => calRef.current?.getApi().today();
// La grilla empieza a las 08:00, así que en pantallas cortas la hora actual
// puede quedar fuera de vista al abrir. Arrancamos una hora antes de "ahora",
// acotado al rango visible de la grilla.
const scrollTime = useMemo(() => {
const h = Math.min(20, Math.max(8, new Date().getHours() - 1));
return `${String(h).padStart(2, "0")}:00:00`;
}, []);
return (
<div className="flex h-full flex-col">
<PageHeader
@@ -193,7 +238,7 @@ export function CalendarPage() {
</div>
)}
<div className="flex flex-wrap items-center gap-3 px-5 pb-3 pt-4 sm:px-7">
<div ref={chromeRef} className="flex shrink-0 flex-wrap items-center gap-2 px-5 pb-3 pt-3 sm:gap-3 sm:px-7 sm:pt-4">
{isOwner && (
<div className="flex min-w-0 flex-1 items-center gap-2 sm:flex-none">
<Filter className="h-4 w-4 shrink-0 text-slate-400" />
@@ -211,12 +256,14 @@ export function CalendarPage() {
</select>
</div>
)}
<div className="flex flex-wrap items-center gap-1 rounded-xl border border-slate-200 bg-white p-1">
{/* Una sola fila con scroll lateral en móvil: envueltos en dos filas los
4 chips se llevaban ~150px de alto que necesita la grilla. */}
<div className="scrollbar-none flex max-w-full items-center gap-1 overflow-x-auto rounded-xl border border-slate-200 bg-white p-1 sm:flex-wrap sm:overflow-visible">
{STATUS_FILTERS.map((s) => (
<button
key={s.value}
onClick={() => setStatusFilter(s.value)}
className={`rounded-lg px-2.5 py-1.5 text-xs font-semibold transition-colors ${
className={`tap-target shrink-0 whitespace-nowrap rounded-lg px-3 py-1.5 text-xs font-semibold transition-colors ${
statusFilter === s.value ? "bg-brand-500 text-white" : "text-slate-600 hover:bg-slate-100"
}`}
>
@@ -226,24 +273,42 @@ export function CalendarPage() {
</div>
</div>
<div className="flex-1 overflow-y-auto px-5 pb-6 sm:px-7">
<div className="card flex h-full min-h-0 flex-col overflow-auto p-3 sm:p-4">
{/* min-h-0 permite que el calendario se encoja dentro del flex; min-h en el
card evita que se aplaste en landscape de teléfono (440px de alto), donde
es mejor que la página scrollee que comprimir la grilla. */}
<div className="min-h-0 flex-1 overflow-y-auto px-5 pb-4 sm:px-7 sm:pb-6">
{/* p-1.5 en móvil: el padding interno del card es ancho que le robábamos a
la grilla (antes p-3), y aquí no cuesta consistencia visual. */}
<div className="card flex h-full min-h-[420px] flex-col overflow-y-auto p-1.5 sm:p-4">
{isLoading && !data && (
<div className="flex h-64 items-center justify-center">
<Spinner className="h-6 w-6 text-brand-500" />
</div>
)}
{/* min-h aquí y no solo en el card: si aparece el aviso de "sin citas",
no debe robarle altura a la grilla hasta dejarla inservible. */}
<div ref={calWrapRef} className="min-h-[360px] flex-1 sm:min-h-[420px]">
<FullCalendar
ref={calRef}
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin, listPlugin]}
locale={esLocale}
initialView={isMobile ? "timeGridDay" : "timeGridWeek"}
/* En tablet, 3 días en vez de 7: en un iPad portrait (820px) la semana
completa con varios especialistas a la misma hora parte cada evento
en columnas de ~30px y el texto queda en "A..", "Fe...". Con 3 días
cada columna ronda los 250px y se lee. Semana sigue a un toque. */
initialView={isMobile ? "timeGridDay" : isTablet ? "timeGridThreeDay" : "timeGridWeek"}
headerToolbar={
isMobile
? { left: "prev,next", center: "title", right: "timeGridDay,listWeek" }
? // "today" va en la barra superior en lugar de un footerToolbar
// propio: esa fila extra costaba ~90px de grilla. El CSS móvil
// pone el título en su propia línea, así que los tres grupos
// caben en una sola fila por debajo.
{ left: "prev,next today", center: "title", right: "timeGridDay,listWeek" }
: isTablet
? { left: "prev,next today", center: "title", right: "timeGridThreeDay,timeGridWeek,timeGridDay,listWeek" }
: { left: "prev,next today", center: "title", right: "dayGridMonth,timeGridWeek,timeGridDay,listWeek" }
}
footerToolbar={isMobile ? { center: "today" } : false}
footerToolbar={false}
buttonText={{
today: "Hoy",
month: "Mes",
@@ -259,20 +324,32 @@ export function CalendarPage() {
noEventsContent: "Sin citas esta semana",
},
timeGridDay: { buttonText: "Día" },
timeGridThreeDay: {
type: "timeGrid",
duration: { days: 3 },
buttonText: "3 días",
},
}}
height={isMobile ? "auto" : "auto"}
contentHeight={isMobile ? 560 : 680}
/* Antes: contentHeight fijo (560/680px). Con .fc-timegrid-slot a 2.4rem
la grilla de 08:00-21:00 mide ~1000px, así que el calendario crecía
a 1.4× la pantalla del iPhone 12 y arrastraba la página. Ahora ocupa
exactamente su contenedor y scrollea dentro. */
height={calHeight ?? "100%"}
expandRows
stickyHeaderDates
firstDay={1}
slotMinTime="08:00:00"
slotMaxTime="21:00:00"
slotDuration="00:30:00"
slotLabelInterval="01:00:00"
slotLabelFormat={{
hour: "2-digit",
minute: "2-digit",
hour12: true,
}}
/* En teléfono el eje horario se lleva ancho que necesitan las
columnas: "8 a.m." en vez de "08:00 a. m.". */
slotLabelFormat={
isMobile
? { hour: "numeric", hour12: true }
: { hour: "2-digit", minute: "2-digit", hour12: true }
}
scrollTime={scrollTime}
nowIndicator
allDaySlot={false}
selectable
@@ -283,7 +360,7 @@ export function CalendarPage() {
eventStartEditable
slotEventOverlap={false}
eventMinHeight={28}
dayMaxEvents={isMobile ? 2 : 3}
dayMaxEvents={isMobile ? 2 : isTablet ? 3 : 4}
eventTimeFormat={{
hour: "2-digit",
minute: "2-digit",
@@ -297,15 +374,11 @@ export function CalendarPage() {
datesSet={() => refetch()}
eventContent={(arg) => <EventContent arg={arg} />}
/>
{!isLoading && events.length === 0 && (
<div className="mt-2">
<EmptyState
icon={CalendarDays}
title="Sin citas en este rango"
description="Selecciona un horario en el calendario o crea una nueva cita."
/>
</div>
)}
</div>
{/* Sin EmptyState debajo de la grilla: con la altura del calendario ya
fijada, ese bloque no cabía en el card y se solapaba con las horas.
El vacío ya se comunica por la propia grilla y, en la vista Lista,
por noEventsContent — más el botón "Nueva cita" de la cabecera. */}
</div>
<button onClick={onToday} className="sr-only">Hoy</button>
</div>
+1 -1
View File
@@ -106,7 +106,7 @@ export function DashboardPage() {
key={r.value}
onClick={() => setRange(r.value)}
className={cn(
"rounded-lg px-3 py-1.5 text-xs font-semibold transition-colors",
"tap-target rounded-lg px-3 py-1.5 text-xs font-semibold transition-colors",
range === r.value ? "bg-brand-500 text-white" : "text-slate-600 hover:bg-slate-100"
)}
>
+2 -2
View File
@@ -88,7 +88,7 @@ export function EmployeesPage() {
setEditing(e);
setCreating(false);
}}
className="rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 hover:text-brand-600"
className="icon-btn rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 hover:text-brand-600"
>
<Pencil className="h-4 w-4" />
</button>
@@ -96,7 +96,7 @@ export function EmployeesPage() {
onClick={() => {
if (confirm(`¿Eliminar a ${e.name}?`)) removeMut.mutate(e.id);
}}
className="rounded-lg p-1.5 text-slate-400 hover:bg-rose-50 hover:text-rose-600"
className="icon-btn rounded-lg p-1.5 text-slate-400 hover:bg-rose-50 hover:text-rose-600"
>
<Trash2 className="h-4 w-4" />
</button>
+31
View File
@@ -0,0 +1,31 @@
import { LandingNav } from "../components/landing/LandingNav";
import { HeroSection } from "../components/landing/HeroSection";
import { CostSection } from "../components/landing/CostSection";
import { ShiftSection } from "../components/landing/ShiftSection";
import { ProductSection } from "../components/landing/ProductSection";
import { ProofSection } from "../components/landing/ProofSection";
import { ObjectionsSection } from "../components/landing/ObjectionsSection";
import { ClosingSection } from "../components/landing/ClosingSection";
/**
* Orquestador de la landing. Solo compone: toda la lógica y el copy viven en las
* secciones, que no reciben props ni comparten estado. Export default porque
* `React.lazy` en App.tsx lo exige.
*
* Orden del funnel: problema → costo → mecanismo → prueba → objeción → cierre.
* `LandingNav` es chrome fijo, no cuenta como sección.
*/
export default function LandingPage() {
return (
<div className="landing">
<LandingNav />
<HeroSection />
<CostSection />
<ShiftSection />
<ProductSection />
<ProofSection />
<ObjectionsSection />
<ClosingSection />
</div>
);
}
+203 -102
View File
@@ -1,21 +1,63 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Sparkles, ArrowRight, Mail, Lock, AlertCircle, Wand2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { AlertCircle, ArrowLeft, ArrowRight, Lock, Mail, Wand2 } from "lucide-react";
import { motion } from "framer-motion";
import { useAuth } from "../lib/auth";
import { api } from "../lib/api";
import { Spinner } from "../components/ui";
import { InstallAppPrompt } from "../components/InstallAppPrompt";
import { revealStagger, revealUp } from "../lib/motion";
import { useReducedMotion } from "../lib/useReducedMotion";
import { BrandMark } from "../components/BrandMark";
const DEMO = import.meta.env.DEV || import.meta.env.VITE_DEMO_UI === "1";
type DemoUser = { email: string; name: string; role: string; avatar_color: string };
/**
* Qué ve cada quien. Está escrito en el idioma del salón y no en el del sistema:
* quien entra a probar no sabe qué es un rol ni un tenant, pero sí sabe
* perfectamente quién hace qué en su negocio.
*
* Los rótulos no asumen género. No es un tecnicismo de estilo: la cuenta demo de
* empleado incluye a Carolina y a Diego, así que «tus muchachas» dejaba fuera a una
* de las dos tarjetas que la propia pantalla muestra debajo.
*/
const GRUPOS = [
{
role: "owner",
titulo: "Como quien manda",
detalle: "Ves todo: la agenda, las cuentas del día, tu gente y tus clientas.",
// Azul primario: es el camino principal, y comparte color con el CTA y la marca.
color: "var(--ld-azul)",
limite: 1,
},
{
role: "employee",
titulo: "Como alguien de tu equipo",
detalle: "Ve nomás su día y sus propias cuentas. Nada que configurar.",
color: "var(--ld-naranja)",
limite: 2,
},
{
role: "admin",
titulo: "Como quien lleva varios salones",
detalle: "Da de alta negocios y los ve todos juntos.",
color: "var(--ld-purpura)",
limite: 1,
},
] as const;
export function LoginPage() {
const { login, user } = useAuth();
const navigate = useNavigate();
const reduced = useReducedMotion();
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 }[]>([]);
const [pendiente, setPendiente] = useState<string | null>(null);
const [demoUsers, setDemoUsers] = useState<DemoUser[]>([]);
useEffect(() => {
if (user) navigate("/", { replace: true });
@@ -23,9 +65,21 @@ export function LoginPage() {
useEffect(() => {
if (!DEMO) return;
api.auth.demoUsers().then((r) => setDemoUsers(r.users)).catch(() => {});
api.auth
.demoUsers()
.then((r) => setDemoUsers(r.users))
.catch(() => {});
}, []);
const grupos = useMemo(
() =>
GRUPOS.map((g) => ({
...g,
cuentas: demoUsers.filter((u) => u.role === g.role).slice(0, g.limite),
})).filter((g) => g.cuentas.length > 0),
[demoUsers]
);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
@@ -40,80 +94,150 @@ export function LoginPage() {
}
};
// 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.
// Acceso de un clic, solo demo. `DEMO` es una constante de build, así que esta rama
// entera —y con ella la contraseña— se elimina del bundle de producción por
// dead-code elimination. Por eso la contraseña vive aquí dentro y no arriba.
const quickLogin = DEMO
? async (mail: string) => {
setEmail(mail);
setPassword("demo1234");
setError(null);
setPendiente(mail);
setLoading(true);
try {
await login(mail, "demo1234");
navigate("/", { replace: true });
} catch (err: any) {
setError(err.message);
setError(err.message || "No pudimos iniciar sesión");
} finally {
setLoading(false);
setPendiente(null);
}
}
: undefined;
return (
<div className="grid min-h-screen lg:grid-cols-2">
{/* Left: brand panel */}
<div className="relative hidden flex-col justify-between overflow-hidden bg-gradient-to-br from-brand-700 via-brand-600 to-brand-800 p-10 text-white lg:flex">
<div className="absolute -right-24 -top-24 h-96 w-96 rounded-full bg-white/10 blur-3xl" />
<div className="absolute -bottom-32 -left-20 h-96 w-96 rounded-full bg-accent-500/30 blur-3xl" />
<div className="relative z-10 flex items-center gap-2.5">
<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">AgendaMax</span>
</div>
<div className="landing relative min-h-screen-safe overflow-hidden">
<div
className="pointer-events-none absolute inset-0"
// Los mismos lavados del panel: brand-50 → orange-50 → el lienzo de la app.
// Entrar al producto desde aquí no debe sentirse como cambiar de sitio.
style={{ background: "linear-gradient(160deg, #eef4ff 0%, #fff7ed 55%, #f6f7fb 100%)" }}
/>
<div className="relative z-10 max-w-md">
<h1 className="text-4xl font-extrabold leading-tight tracking-tight">
La gestión visual de tu negocio, en un solo lugar.
</h1>
<p className="mt-4 text-brand-100">
Calendario de citas con arrastrar y soltar, control de empleados, tickets e ingresos en tiempo real.
Diseñado para dueños y equipos.
</p>
<ul className="mt-8 space-y-3 text-sm">
{[
"Agenda citas en segundos y arrástralas cuando quieras",
"Tablero con tus ingresos, mejores empleados y clientes top",
"Gestiona servicios, empleados y clientes desde un panel",
].map((t) => (
<li key={t} className="flex items-start gap-3">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-accent-300" />
<span className="text-brand-50">{t}</span>
</li>
))}
</ul>
</div>
{/* `ld-gutter` en lugar de `safe-x px-5`: ver el comentario de la clase en
index.css — combinar las dos dejaba el padding lateral en 0. */}
<div className="safe-top ld-gutter relative mx-auto flex w-full max-w-5xl flex-col pb-12 pt-8 sm:pt-10">
<Link
to="/"
className="tap-target inline-flex w-fit items-center gap-2 text-[0.92rem] font-bold"
style={{ color: "var(--ld-muted)" }}
>
<ArrowLeft className="h-4 w-4" />
Volver
</Link>
<div className="relative z-10 text-xs text-brand-200">
© {new Date().getFullYear()} AgendaMax · Demo
</div>
</div>
{/* Right: form */}
<div className="flex items-center justify-center bg-[#f6f7fb] px-5 py-10">
<div className="w-full max-w-sm">
<div className="mb-7 text-center lg:hidden">
<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" />
<motion.div
className="mt-9 grid grid-cols-1 items-start gap-10 lg:mt-12 lg:grid-cols-[1.05fr_1fr] lg:gap-14"
initial="hidden"
animate="show"
variants={revealStagger(reduced)}
>
{/* ---- Columna izquierda: entrar de un clic ---- */}
<motion.div variants={revealUp(reduced)}>
<div className="flex items-center gap-2.5 text-[1.05rem] font-extrabold tracking-tight">
<BrandMark className="h-9 w-9" />
AgendaMax
</div>
<h1 className="text-xl font-extrabold">AgendaMax</h1>
</div>
<div className="card p-6 shadow-card">
<h2 className="text-lg font-extrabold text-slate-900">Bienvenido de nuevo</h2>
<p className="mt-1 text-sm text-slate-500">Inicia sesión para acceder a tu panel.</p>
<h1 className="ld-title mt-8 text-[clamp(1.75rem,4vw,2.6rem)]">
Entra sin llenar nada.
</h1>
<p className="ld-lead mt-4 max-w-md" style={{ color: "var(--ld-muted)" }}>
Escoge con qué ojos quieres verlo. Cada opción abre el mismo salón, pero desde el
lugar de una persona distinta.
</p>
<form onSubmit={submit} className="mt-5 space-y-4">
{DEMO && (
<div className="mt-8 grid grid-cols-1 gap-6">
{grupos.map((g) => (
<div key={g.role} className="grid grid-cols-1 gap-2.5">
<div className="flex items-start gap-2.5">
<span
className="mt-1 h-5 w-1.5 shrink-0 rounded-full"
style={{ background: g.color }}
aria-hidden="true"
/>
<div>
<div className="text-[0.95rem] font-extrabold">{g.titulo}</div>
<div className="text-[0.85rem]" style={{ color: "var(--ld-muted)" }}>
{g.detalle}
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-2 pl-4">
{g.cuentas.map((u) => (
<button
key={u.email}
type="button"
data-magic-login
data-role={u.role}
onClick={() => quickLogin?.(u.email)}
disabled={loading}
className="group flex min-h-[58px] w-full items-center gap-3 rounded-2xl bg-white/85 px-4 py-2.5 text-left ring-1 ring-black/[0.05] transition-all hover:bg-white hover:shadow-[0_14px_32px_-14px_rgba(27,21,51,0.28)] disabled:opacity-60"
>
<span
className="flex h-9 w-9 shrink-0 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("")}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[0.95rem] font-extrabold">
{u.name}
</span>
<span
className="block truncate text-[0.78rem]"
style={{ color: "var(--ld-muted)" }}
>
{u.email}
</span>
</span>
{pendiente === u.email ? (
<Spinner />
) : (
<Wand2
className="h-4 w-4 shrink-0 opacity-30 transition-opacity group-hover:opacity-100"
style={{ color: g.color }}
/>
)}
</button>
))}
</div>
</div>
))}
</div>
)}
</motion.div>
{/* ---- Columna derecha: formulario manual ----
Visible sin interacción a propósito: visual-audit, responsive-audit y
pwa-e2e rellenan estos campos justo después del goto. Colapsarlo tras
un toggle rompería los tres. */}
<motion.div
className="w-full rounded-3xl bg-white/85 p-6 ring-1 ring-black/[0.05] backdrop-blur-sm sm:p-7"
variants={revealUp(reduced)}
>
<h2 className="text-lg font-extrabold">Ya tengo mi cuenta</h2>
<p className="mt-1 text-[0.9rem]" style={{ color: "var(--ld-muted)" }}>
Entra con tu correo y tu contraseña.
</p>
<form onSubmit={submit} className="mt-6 grid grid-cols-1 gap-4">
<div>
<label className="label">Correo</label>
<div className="relative">
@@ -123,7 +247,7 @@ export function LoginPage() {
className="input pl-9"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="tucorreo@negocio.com"
placeholder="tucorreo@salon.com"
autoComplete="email"
required
/>
@@ -146,63 +270,40 @@ export function LoginPage() {
</div>
{error && (
<div className="flex items-center gap-2 rounded-lg bg-rose-50 px-3 py-2 text-sm font-medium text-rose-700">
<div className="flex items-center gap-2 rounded-xl bg-rose-50 px-3 py-2.5 text-sm font-semibold text-rose-700">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
<button type="submit" className="btn btn-primary w-full" disabled={loading}>
{loading ? <Spinner /> : <>Entrar <ArrowRight className="h-4 w-4" /></>}
<button type="submit" className="ld-cta w-full" disabled={loading}>
{loading && !pendiente ? (
<Spinner />
) : (
<>
Entrar <ArrowRight className="h-4 w-4" />
</>
)}
</button>
</form>
<div className="mx-auto mt-4 max-w-sm">
<div className="mt-5">
<InstallAppPrompt />
</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>
</>
<p
className="mt-5 text-center text-[0.82rem] font-semibold"
style={{ color: "var(--ld-muted)" }}
>
Salón de práctica · todas las cuentas usan{" "}
<code className="font-mono font-bold" style={{ color: "var(--ld-tinta)" }}>
demo1234
</code>
</p>
)}
</div>
{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>
</motion.div>
</motion.div>
</div>
</div>
);
+2 -2
View File
@@ -110,10 +110,10 @@ export function NotificationsPage() {
</div>
{n.status === "pending" && (
<div className="flex shrink-0 gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button onClick={() => act(n.id, "send")} className="rounded-lg p-1.5 text-slate-400 hover:bg-emerald-50 hover:text-emerald-600" title="Enviar ahora">
<button onClick={() => act(n.id, "send")} className="icon-btn rounded-lg p-1.5 text-slate-400 hover:bg-emerald-50 hover:text-emerald-600" title="Enviar ahora">
<Send className="h-3.5 w-3.5" />
</button>
<button onClick={() => act(n.id, "cancel")} className="rounded-lg p-1.5 text-slate-400 hover:bg-rose-50 hover:text-rose-600" title="Cancelar">
<button onClick={() => act(n.id, "cancel")} className="icon-btn rounded-lg p-1.5 text-slate-400 hover:bg-rose-50 hover:text-rose-600" title="Cancelar">
<XCircle className="h-3.5 w-3.5" />
</button>
</div>
+2 -2
View File
@@ -92,7 +92,7 @@ export function ServicesPage() {
setEditing(s);
setCreating(false);
}}
className="rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 hover:text-brand-600"
className="icon-btn rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 hover:text-brand-600"
>
<Pencil className="h-4 w-4" />
</button>
@@ -100,7 +100,7 @@ export function ServicesPage() {
onClick={() => {
if (confirm(`¿Eliminar "${s.name}"?`)) removeMut.mutate(s.id);
}}
className="rounded-lg p-1.5 text-slate-400 hover:bg-rose-50 hover:text-rose-600"
className="icon-btn rounded-lg p-1.5 text-slate-400 hover:bg-rose-50 hover:text-rose-600"
>
<Trash2 className="h-4 w-4" />
</button>