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:
co-authored by
Claude Opus 5
parent
0b466f33f3
commit
4c19244df9
+95
-27
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 sí 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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user