175 lines
6.9 KiB
TypeScript
175 lines
6.9 KiB
TypeScript
import { Link, useRouterState } from "@tanstack/react-router";
|
|
import { CalendarCheck, Wallet, UserRound, Bell, LogOut, Menu, Sparkles, Ticket } from "lucide-react";
|
|
import { useState, type ReactNode } from "react";
|
|
import { cn } from "@/lib/utils";
|
|
import { useAppState } from "@/lib/store";
|
|
import { notificar, pedirPermisoNotificaciones } from "@/lib/notificaciones";
|
|
import { BrandLogo, BrandMark } from "@/components/BrandLogo";
|
|
import { useInstalarPWA } from "@/lib/pwa-install";
|
|
|
|
const NAV = [
|
|
{ to: "/vuelos", label: "Vuelos", icon: Sparkles },
|
|
{ to: "/reserva", label: "Reservar", icon: Ticket },
|
|
{ to: "/reservaciones", label: "Reservas", icon: CalendarCheck },
|
|
{ to: "/comisiones", label: "Comisiones", icon: Wallet },
|
|
{ to: "/perfil", label: "Perfil", icon: UserRound },
|
|
] as const;
|
|
|
|
export function AppShell({
|
|
title,
|
|
subtitle,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
subtitle?: string;
|
|
children: ReactNode;
|
|
}) {
|
|
const { pathname } = useRouterState({ select: (s) => s.location });
|
|
const { socio } = useAppState();
|
|
const [sidebarAbierto, setSidebarAbierto] = useState(true);
|
|
const { instalada, instalar } = useInstalarPWA();
|
|
|
|
const activarNotificaciones = async () => {
|
|
const estado = await pedirPermisoNotificaciones();
|
|
notificar(
|
|
"Notificaciones activas",
|
|
estado === "granted"
|
|
? "Te avisaremos de reservaciones confirmadas y comisiones acreditadas."
|
|
: "Verás los avisos dentro de la app.",
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-background font-sans">
|
|
{/* Sidebar retráctil — solo desktop/tablet */}
|
|
<aside
|
|
className={cn(
|
|
"fixed inset-y-0 left-0 z-30 hidden flex-col bg-sidebar p-4 text-sidebar-foreground transition-all duration-300 lg:flex",
|
|
sidebarAbierto ? "w-64" : "w-20",
|
|
)}
|
|
>
|
|
<div className="px-2 py-3">
|
|
<BrandLogo soloIcono={!sidebarAbierto} invertido />
|
|
</div>
|
|
|
|
<nav className="mt-6 flex flex-1 flex-col gap-1">
|
|
{NAV.map(({ to, label, icon: Icon }) => {
|
|
const activo = pathname.startsWith(to);
|
|
return (
|
|
<Link
|
|
key={to}
|
|
to={to}
|
|
className={cn(
|
|
"flex items-center gap-3 rounded-2xl px-3 py-3 text-sm font-medium transition-colors",
|
|
activo
|
|
? "bg-brand text-brand-foreground"
|
|
: "text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-foreground",
|
|
)}
|
|
>
|
|
<Icon className="size-5 shrink-0" />
|
|
{sidebarAbierto && label}
|
|
</Link>
|
|
);
|
|
})}
|
|
</nav>
|
|
|
|
<button
|
|
onClick={() => setSidebarAbierto((v) => !v)}
|
|
className="flex items-center gap-3 rounded-2xl px-3 py-3 text-sm text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent"
|
|
>
|
|
<Menu className="size-5" />
|
|
{sidebarAbierto && "Contraer"}
|
|
</button>
|
|
</aside>
|
|
|
|
<div className={cn("transition-all duration-300", sidebarAbierto ? "lg:pl-64" : "lg:pl-20")}>
|
|
{/* Encabezado */}
|
|
<header className="sticky top-0 z-20 border-b border-border/70 bg-background/85 backdrop-blur-xl">
|
|
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-4 sm:px-6">
|
|
<div className="min-w-0">
|
|
<h1 className="truncate text-xl font-bold tracking-tight sm:text-2xl">{title}</h1>
|
|
{subtitle && (
|
|
<p className="truncate text-sm text-muted-foreground">{subtitle}</p>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{!instalada && (
|
|
<button
|
|
onClick={async () => {
|
|
const ok = await instalar();
|
|
if (!ok)
|
|
notificar(
|
|
"Instalar la app",
|
|
"Usa “Agregar a pantalla de inicio” desde el menú de tu navegador.",
|
|
);
|
|
}}
|
|
className="flex items-center gap-2 rounded-2xl border border-border bg-card px-3 py-2.5 text-sm font-semibold shadow-soft transition-all hover:-translate-y-0.5 hover:shadow-lift active:scale-95"
|
|
>
|
|
<span className="grid size-6 place-items-center rounded-lg bg-ink p-0.5">
|
|
<BrandMark className="h-4" />
|
|
</span>
|
|
<span className="hidden sm:inline">Instalar</span>
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={activarNotificaciones}
|
|
aria-label="Activar notificaciones"
|
|
className="grid size-11 place-items-center rounded-2xl border border-border bg-card text-foreground shadow-soft transition-all hover:-translate-y-0.5 hover:bg-muted active:scale-95"
|
|
>
|
|
<Bell className="size-5" />
|
|
</button>
|
|
|
|
<Link
|
|
to="/perfil"
|
|
className="hidden items-center gap-2 rounded-2xl bg-ink px-3 py-2.5 text-sm font-medium text-ink-foreground sm:flex"
|
|
>
|
|
<span className="grid size-6 place-items-center rounded-full bg-brand text-[11px] font-bold text-brand-foreground">
|
|
{(socio?.nombre ?? "S").charAt(0).toUpperCase()}
|
|
</span>
|
|
<span className="max-w-28 truncate">{socio?.nombre ?? "Socio"}</span>
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="mx-auto max-w-6xl px-4 pb-28 pt-5 sm:px-6 lg:pb-12">{children}</main>
|
|
</div>
|
|
|
|
{/* Bottom navigation — móvil */}
|
|
<nav className="fixed inset-x-0 bottom-0 z-30 border-t border-border bg-card/95 pb-[env(safe-area-inset-bottom)] backdrop-blur-xl lg:hidden">
|
|
<ul className="mx-auto grid max-w-md grid-cols-4">
|
|
{NAV.map(({ to, label, icon: Icon }) => {
|
|
const activo = pathname.startsWith(to);
|
|
return (
|
|
<li key={to}>
|
|
<Link
|
|
to={to}
|
|
className="flex flex-col items-center gap-1 px-2 py-3 text-[11px] font-medium"
|
|
>
|
|
<span
|
|
className={cn(
|
|
"grid size-10 place-items-center rounded-2xl transition-colors",
|
|
activo ? "bg-brand text-brand-foreground" : "text-muted-foreground",
|
|
)}
|
|
>
|
|
<Icon className="size-5" />
|
|
</span>
|
|
<span className={activo ? "text-foreground" : "text-muted-foreground"}>{label}</span>
|
|
</Link>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</nav>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function SalirButton() {
|
|
return (
|
|
<span className="inline-flex items-center gap-2 text-sm">
|
|
<LogOut className="size-4" /> Cerrar sesión
|
|
</span>
|
|
);
|
|
}
|