Rediseña la navegación móvil del panel/admin con barra de pestañas inferior
Sustituye el menú hamburguesa por una barra fija inferior con iconos y etiquetas (thumb-friendly, siempre visible), manteniendo la nav horizontal en escritorio. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -6,9 +6,9 @@ import AppNav from '@/components/AppNav';
|
|||||||
export const metadata: Metadata = { title: 'Admin · Reformix' };
|
export const metadata: Metadata = { title: 'Admin · Reformix' };
|
||||||
|
|
||||||
const ADMIN_LINKS = [
|
const ADMIN_LINKS = [
|
||||||
{ href: '/admin', label: 'Resumen' },
|
{ href: '/admin', label: 'Resumen', icon: 'resumen' },
|
||||||
{ href: '/admin/usuarios', label: 'Usuarios' },
|
{ href: '/admin/usuarios', label: 'Usuarios', icon: 'usuarios' },
|
||||||
{ href: '/admin/planes', label: 'Planes' },
|
{ href: '/admin/planes', label: 'Planes', icon: 'planes' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
@@ -26,7 +26,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
|||||||
<AppNav links={ADMIN_LINKS} />
|
<AppNav links={ADMIN_LINKS} />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="max-w-6xl mx-auto px-6 py-8">{children}</main>
|
<main className="max-w-6xl mx-auto px-6 py-8 pb-24 sm:pb-8">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import { eq } from 'drizzle-orm';
|
|||||||
import AppNav from '@/components/AppNav';
|
import AppNav from '@/components/AppNav';
|
||||||
|
|
||||||
const PANEL_LINKS = [
|
const PANEL_LINKS = [
|
||||||
{ href: '/panel', label: 'Leads' },
|
{ href: '/panel', label: 'Leads', icon: 'leads' },
|
||||||
{ href: '/panel/precios', label: 'Precios' },
|
{ href: '/panel/precios', label: 'Precios', icon: 'precios' },
|
||||||
{ href: '/panel/empresa', label: 'Empresa' },
|
{ href: '/panel/empresa', label: 'Empresa', icon: 'empresa' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -41,7 +41,7 @@ export default async function PanelLayout({ children }: { children: React.ReactN
|
|||||||
<AppNav links={PANEL_LINKS} />
|
<AppNav links={PANEL_LINKS} />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="max-w-6xl mx-auto px-6 py-8">{children}</main>
|
<main className="max-w-6xl mx-auto px-6 py-8 pb-24 sm:pb-8">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
export type AppNavLink = { href: string; label: string };
|
export type AppNavIcon =
|
||||||
|
| 'leads'
|
||||||
|
| 'precios'
|
||||||
|
| 'empresa'
|
||||||
|
| 'resumen'
|
||||||
|
| 'usuarios'
|
||||||
|
| 'planes';
|
||||||
|
|
||||||
|
export type AppNavLink = { href: string; label: string; icon: AppNavIcon };
|
||||||
|
|
||||||
// El enlace activo es el href más específico (más largo) que prefija el pathname.
|
// El enlace activo es el href más específico (más largo) que prefija el pathname.
|
||||||
function activeHref(pathname: string, links: readonly AppNavLink[]): string {
|
function activeHref(pathname: string, links: readonly AppNavLink[]): string {
|
||||||
@@ -17,14 +24,79 @@ function activeHref(pathname: string, links: readonly AppNavLink[]): string {
|
|||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ICON_PATHS: Record<AppNavIcon | 'salir', React.ReactNode> = {
|
||||||
|
leads: (
|
||||||
|
<>
|
||||||
|
<path d="M3 4h18v12H6l-3 3V4Z" />
|
||||||
|
<path d="M7 9h10M7 12h6" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
precios: (
|
||||||
|
<>
|
||||||
|
<path d="M20 12 12.5 4.5A2 2 0 0 0 11 4H5a1 1 0 0 0-1 1v6a2 2 0 0 0 .6 1.4L12 20l8-8Z" />
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.2" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
empresa: (
|
||||||
|
<>
|
||||||
|
<path d="M4 21V6l8-3 8 3v15" />
|
||||||
|
<path d="M4 21h16M9 21v-5h6v5M8 9h.01M12 9h.01M16 9h.01M8 13h.01M12 13h.01M16 13h.01" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
resumen: (
|
||||||
|
<>
|
||||||
|
<rect x="3" y="3" width="7" height="9" rx="1" />
|
||||||
|
<rect x="14" y="3" width="7" height="5" rx="1" />
|
||||||
|
<rect x="14" y="12" width="7" height="9" rx="1" />
|
||||||
|
<rect x="3" y="16" width="7" height="5" rx="1" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
usuarios: (
|
||||||
|
<>
|
||||||
|
<circle cx="9" cy="8" r="3" />
|
||||||
|
<path d="M3.5 20a5.5 5.5 0 0 1 11 0" />
|
||||||
|
<path d="M16 5.5a3 3 0 0 1 0 5.5M17 14.5a5.5 5.5 0 0 1 3.5 5" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
planes: (
|
||||||
|
<>
|
||||||
|
<path d="M12 3 3 8l9 5 9-5-9-5Z" />
|
||||||
|
<path d="M3 13l9 5 9-5M3 18l9 5 9-5" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
salir: (
|
||||||
|
<>
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||||
|
<path d="M16 17l5-5-5-5M21 12H9" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
function Icon({ name }: { name: AppNavIcon | 'salir' }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="22"
|
||||||
|
height="22"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.75"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{ICON_PATHS[name]}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function AppNav({ links }: { links: readonly AppNavLink[] }) {
|
export default function AppNav({ links }: { links: readonly AppNavLink[] }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const active = activeHref(pathname, links);
|
const active = activeHref(pathname, links);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Escritorio */}
|
{/* Escritorio: navegación horizontal en la cabecera */}
|
||||||
<nav className="hidden sm:flex items-center gap-4 text-xs font-medium">
|
<nav className="hidden sm:flex items-center gap-4 text-xs font-medium">
|
||||||
{links.map((l) => (
|
{links.map((l) => (
|
||||||
<Link
|
<Link
|
||||||
@@ -42,64 +114,44 @@ export default function AppNav({ links }: { links: readonly AppNavLink[] }) {
|
|||||||
</form>
|
</form>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Móvil: botón hamburguesa */}
|
{/* Móvil: barra de pestañas fija inferior (thumb-friendly) */}
|
||||||
<button
|
<nav
|
||||||
type="button"
|
className="sm:hidden fixed inset-x-0 bottom-0 z-30 border-t border-gray-200 bg-white/95 backdrop-blur supports-[backdrop-filter]:bg-white/80"
|
||||||
onClick={() => setOpen((v) => !v)}
|
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
|
||||||
aria-label={open ? 'Cerrar menú' : 'Abrir menú'}
|
aria-label="Navegación principal"
|
||||||
aria-expanded={open}
|
|
||||||
className="sm:hidden inline-flex items-center justify-center w-10 h-10 -mr-2 rounded-lg text-gray-700 hover:bg-gray-100"
|
|
||||||
>
|
>
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
<div className="grid grid-cols-4">
|
||||||
{open ? (
|
{links.map((l) => {
|
||||||
<>
|
const isActive = active === l.href;
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
return (
|
||||||
<line x1="6" y1="18" x2="18" y2="6" />
|
<Link
|
||||||
</>
|
key={l.href}
|
||||||
) : (
|
href={l.href}
|
||||||
<>
|
aria-current={isActive ? 'page' : undefined}
|
||||||
<line x1="3" y1="6" x2="21" y2="6" />
|
className={
|
||||||
<line x1="3" y1="12" x2="21" y2="12" />
|
'relative flex flex-col items-center justify-center gap-1 pt-2.5 pb-2 text-[11px] font-medium transition-colors ' +
|
||||||
<line x1="3" y1="18" x2="21" y2="18" />
|
(isActive ? 'text-black' : 'text-gray-400 hover:text-gray-600')
|
||||||
</>
|
}
|
||||||
)}
|
>
|
||||||
</svg>
|
{isActive && (
|
||||||
</button>
|
<span className="absolute top-0 h-0.5 w-8 rounded-full bg-black" aria-hidden="true" />
|
||||||
|
)}
|
||||||
{/* Móvil: panel desplegable */}
|
<Icon name={l.icon} />
|
||||||
{open && (
|
<span className="leading-none">{l.label}</span>
|
||||||
<>
|
</Link>
|
||||||
<button
|
);
|
||||||
type="button"
|
})}
|
||||||
aria-hidden="true"
|
<form action="/logout" method="post" className="contents">
|
||||||
tabIndex={-1}
|
<button
|
||||||
onClick={() => setOpen(false)}
|
type="submit"
|
||||||
className="sm:hidden fixed inset-0 top-16 z-10 bg-black/20 cursor-default"
|
className="flex flex-col items-center justify-center gap-1 pt-2.5 pb-2 text-[11px] font-medium text-gray-400 transition-colors hover:text-gray-600"
|
||||||
/>
|
>
|
||||||
<nav className="sm:hidden absolute left-0 right-0 top-16 z-20 bg-white border-b border-gray-200 shadow-lg">
|
<Icon name="salir" />
|
||||||
<div className="px-6 py-2 flex flex-col">
|
<span className="leading-none">Salir</span>
|
||||||
{links.map((l) => (
|
</button>
|
||||||
<Link
|
</form>
|
||||||
key={l.href}
|
</div>
|
||||||
href={l.href}
|
</nav>
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
className={
|
|
||||||
'py-3 text-sm border-b border-gray-100 ' +
|
|
||||||
(active === l.href ? 'text-black font-semibold' : 'text-gray-600')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{l.label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
<form action="/logout" method="post" className="py-3">
|
|
||||||
<button type="submit" className="text-sm text-gray-600">
|
|
||||||
Salir
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user