Files
jorgecuadros-platform/apps/web/src/components/AppShell.tsx
T
rmancinas 921a47cbaa
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m2s
Build and Push Images / Build jorgecuadros-web (push) Successful in 3m7s
feat(web): use company logo PNG in brand mark
Replace the JC text mark in AppShell and login with the real
company_logo.png. Restyle .brand-mark to host the image (white
rounded bg, object-fit contain). Appbar 38px, login panel 46px.
2026-07-23 22:17:17 -07:00

132 lines
4.0 KiB
TypeScript

"use client";
import { useEffect, useState, type ReactNode } from "react";
import { usePathname, useRouter } from "next/navigation";
import Link from "next/link";
import { logout, me } from "@/lib/api";
import { AuthContext, can } from "@/lib/abilities";
import { ROLE_LABEL } from "@/lib/labels";
import type { AuthUser, Ability } from "@/lib/types";
/**
* Authenticated shell: gates on /auth/me, redirects to /login when the
* session is missing, renders the brand header + logout, and wraps page
* content. Provides the AuthContext so any page can read the user's
* abilities. Used by every authenticated page.
*/
const NAV: { href: string; label: string; ability?: Ability }[] = [
{ href: "/clientes", label: "Clientes" },
{ href: "/servicios", label: "Propiedades" },
{ href: "/polizas", label: "Pólizas" },
{ href: "/estado-cuenta", label: "Estado de cuenta" },
{ href: "/banco", label: "Chequera" },
{ href: "/catalogos", label: "Catálogos", ability: "lookup:manage" },
{ href: "/usuarios", label: "Usuarios", ability: "user:manage" },
{ href: "/operaciones", label: "Operaciones", ability: "db:manage" },
];
export function AppShell({ children }: { children: ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const [user, setUser] = useState<AuthUser | null>(null);
const [checking, setChecking] = useState(true);
const [loggingOut, setLoggingOut] = useState(false);
useEffect(() => {
let alive = true;
me()
.then((u) => {
if (alive) {
setUser(u);
setChecking(false);
}
})
.catch(() => {
router.replace("/login");
});
return () => {
alive = false;
};
}, [router]);
async function handleLogout() {
setLoggingOut(true);
try {
await logout();
} catch {
/* ignore — we redirect regardless */
}
router.replace("/login");
}
if (checking) {
return (
<div
style={{
minHeight: "100vh",
display: "grid",
placeItems: "center",
color: "var(--brand-700)",
}}
>
<span className="spinner" aria-label="Cargando" />
</div>
);
}
return (
<AuthContext.Provider value={user}>
<header className="appbar">
<div className="appbar-inner">
<Link href="/clientes" className="brand">
<img
src="/images/company_logo.png"
alt=""
className="brand-mark"
/>
<span className="brand-text">
<span className="brand-name">Jorge Cuadros</span>
<span className="brand-sub">& Asociados</span>
</span>
</Link>
<nav className="appbar-nav" aria-label="Principal">
{NAV.filter((item) => !item.ability || can(user, item.ability)).map(
(item) => {
const active = pathname?.startsWith(item.href) ?? false;
return (
<Link
key={item.href}
href={item.href}
className={`appbar-link${active ? " active" : ""}`}
aria-current={active ? "page" : undefined}
>
{item.label}
</Link>
);
},
)}
</nav>
<span className="appbar-spacer" />
<div className="appbar-user">
{user && (
<span className="appbar-user-name">
{user.name}
<span className="appbar-user-role">{ROLE_LABEL[user.role]}</span>
</span>
)}
<button
type="button"
className="btn btn-ghost"
onClick={handleLogout}
disabled={loggingOut}
>
{loggingOut ? "Saliendo…" : "Cerrar sesión"}
</button>
</div>
</div>
</header>
<main className="shell-main">{children}</main>
</AuthContext.Provider>
);
}