Compare commits

...

2 Commits

Author SHA1 Message Date
Carlos Narro
27165a41eb 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>
2026-05-30 22:56:30 +02:00
Carlos Narro
83ad82e617 Mejora la navegación móvil del panel y admin
Extrae un AppNav reutilizable con menú hamburguesa desplegable en móvil
y resaltado del enlace activo. Antes la marca y los enlaces se apretaban
en una sola fila en pantallas pequeñas; ahora el nombre de empresa se
oculta en móvil y los enlaces pasan a un desplegable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 22:42:07 +02:00
3 changed files with 189 additions and 28 deletions

View File

@@ -1,30 +1,32 @@
import Link from 'next/link';
import type { Metadata } from 'next';
import { requireAdmin } from '@/lib/auth/current-user';
import AppNav from '@/components/AppNav';
export const metadata: Metadata = { title: 'Admin · Reformix' };
const ADMIN_LINKS = [
{ href: '/admin', label: 'Resumen', icon: 'resumen' },
{ href: '/admin/usuarios', label: 'Usuarios', icon: 'usuarios' },
{ href: '/admin/planes', label: 'Planes', icon: 'planes' },
] as const;
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
await requireAdmin();
return (
<div className="min-h-screen bg-gray-50">
<header className="sticky top-0 z-10 bg-white border-b border-gray-200">
<div className="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
<Link href="/admin" className="flex items-center gap-3">
<span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-black text-white font-black italic text-lg leading-none">R</span>
<header className="sticky top-0 z-20 bg-white border-b border-gray-200">
<div className="relative max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
<Link href="/admin" className="flex items-center gap-2 min-w-0">
<span className="inline-flex shrink-0 items-center justify-center w-8 h-8 rounded-lg bg-black text-white font-black italic text-lg leading-none">R</span>
<span className="font-extrabold tracking-tight text-black">Reformix</span>
<span className="text-gray-300">/</span>
<span className="text-sm font-medium text-gray-600">Admin</span>
<span className="hidden sm:inline text-gray-300">/</span>
<span className="hidden sm:inline text-sm font-medium text-gray-600">Admin</span>
</Link>
<nav className="flex items-center gap-4 text-xs font-medium">
<Link href="/admin" className="text-gray-500 hover:text-black">Resumen</Link>
<Link href="/admin/usuarios" className="text-gray-500 hover:text-black">Usuarios</Link>
<Link href="/admin/planes" className="text-gray-500 hover:text-black">Planes</Link>
<form action="/logout" method="post"><button type="submit" className="text-gray-500 hover:text-black">Salir</button></form>
</nav>
<AppNav links={ADMIN_LINKS} />
</div>
</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>
);
}

View File

@@ -4,6 +4,13 @@ import { requireUser } from '@/lib/auth/current-user';
import { db } from '@/db';
import { tenants } from '@/db/schema';
import { eq } from 'drizzle-orm';
import AppNav from '@/components/AppNav';
const PANEL_LINKS = [
{ href: '/panel', label: 'Leads', icon: 'leads' },
{ href: '/panel/precios', label: 'Precios', icon: 'precios' },
{ href: '/panel/empresa', label: 'Empresa', icon: 'empresa' },
] as const;
export const metadata: Metadata = {
title: 'Panel · Reformix',
@@ -19,27 +26,22 @@ export default async function PanelLayout({ children }: { children: React.ReactN
return (
<div className="min-h-screen bg-gray-50">
<header className="sticky top-0 z-10 bg-white border-b border-gray-200">
<div className="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
<Link href="/panel" className="flex items-center gap-3">
<span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-black text-white font-black italic text-lg leading-none">
<header className="sticky top-0 z-20 bg-white border-b border-gray-200">
<div className="relative max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
<Link href="/panel" className="flex items-center gap-2 min-w-0">
<span className="inline-flex shrink-0 items-center justify-center w-8 h-8 rounded-lg bg-black text-white font-black italic text-lg leading-none">
R
</span>
<span className="font-extrabold tracking-tight text-black">Reformix</span>
<span className="text-gray-300">/</span>
<span className="text-sm font-medium text-gray-600">{nombreEmpresa}</span>
<span className="hidden sm:inline text-gray-300">/</span>
<span className="hidden sm:inline text-sm font-medium text-gray-600 truncate">
{nombreEmpresa}
</span>
</Link>
<nav className="flex items-center gap-4 text-xs font-medium">
<Link href="/panel" className="text-gray-500 hover:text-black">Leads</Link>
<Link href="/panel/precios" className="text-gray-500 hover:text-black">Precios</Link>
<Link href="/panel/empresa" className="text-gray-500 hover:text-black">Empresa</Link>
<form action="/logout" method="post">
<button type="submit" className="text-gray-500 hover:text-black">Salir</button>
</form>
</nav>
<AppNav links={PANEL_LINKS} />
</div>
</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>
);
}

View File

@@ -0,0 +1,157 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
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.
function activeHref(pathname: string, links: readonly AppNavLink[]): string {
let best = '';
for (const l of links) {
if ((pathname === l.href || pathname.startsWith(l.href + '/')) && l.href.length > best.length) {
best = l.href;
}
}
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[] }) {
const pathname = usePathname();
const active = activeHref(pathname, links);
return (
<>
{/* Escritorio: navegación horizontal en la cabecera */}
<nav className="hidden sm:flex items-center gap-4 text-xs font-medium">
{links.map((l) => (
<Link
key={l.href}
href={l.href}
className={active === l.href ? 'text-black font-semibold' : 'text-gray-500 hover:text-black'}
>
{l.label}
</Link>
))}
<form action="/logout" method="post">
<button type="submit" className="text-gray-500 hover:text-black">
Salir
</button>
</form>
</nav>
{/* Móvil: barra de pestañas fija inferior (thumb-friendly) */}
<nav
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"
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
aria-label="Navegación principal"
>
<div className="grid grid-cols-4">
{links.map((l) => {
const isActive = active === l.href;
return (
<Link
key={l.href}
href={l.href}
aria-current={isActive ? 'page' : undefined}
className={
'relative flex flex-col items-center justify-center gap-1 pt-2.5 pb-2 text-[11px] font-medium transition-colors ' +
(isActive ? 'text-black' : 'text-gray-400 hover:text-gray-600')
}
>
{isActive && (
<span className="absolute top-0 h-0.5 w-8 rounded-full bg-black" aria-hidden="true" />
)}
<Icon name={l.icon} />
<span className="leading-none">{l.label}</span>
</Link>
);
})}
<form action="/logout" method="post" className="contents">
<button
type="submit"
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"
>
<Icon name="salir" />
<span className="leading-none">Salir</span>
</button>
</form>
</div>
</nav>
</>
);
}