Files
playzer/apps/frontend/src/features/layout/root-layout.tsx

315 lines
12 KiB
TypeScript

import { FootballIcon } from '@/assets/icons/football';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { UserAvatar } from '@/components/user-avatar';
import { apiClient } from '@/lib/api-client';
import { useAuth } from '@/lib/auth';
import { acceptStoredPendingInvite } from '@/lib/invitations';
import { useCurrentComplexStore } from '@/lib/stores/current-complex-store';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, Outlet, useNavigate } from '@tanstack/react-router';
import { LogOut, Menu, UserRound } from 'lucide-react';
import { useEffect, useMemo, useRef } from 'react';
import { ThemeSwitcher } from './theme-switcher';
export function RootLayout() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { user, isAuthenticated, displayName, avatarUrl, signOut } = useAuth();
const { currentComplexSlug, setCurrentComplex } = useCurrentComplexStore();
const processedInviteForUser = useRef<string | null>(null);
const myComplexesQuery = useQuery({
queryKey: ['my-complexes'],
enabled: isAuthenticated,
queryFn: () => apiClient.complexes.listMine(),
});
useEffect(() => {
if (!isAuthenticated) {
processedInviteForUser.current = null;
return;
}
const userKey = user?.email ?? displayName ?? 'authenticated-user';
if (processedInviteForUser.current === userKey) {
return;
}
processedInviteForUser.current = userKey;
void (async () => {
await acceptStoredPendingInvite(apiClient.complexes.acceptPendingInvitations);
await queryClient.invalidateQueries({ queryKey: ['my-complexes'] });
})();
}, [displayName, isAuthenticated, queryClient, user?.email]);
const complexSlug = currentComplexSlug || myComplexesQuery.data?.[0]?.complexSlug;
const currentComplexName = useMemo(() => {
const complexes = myComplexesQuery.data ?? [];
if (complexes.length === 0) return 'Mi complejo';
const selected = complexes.find((complex) => complex.complexSlug === complexSlug);
return selected?.complexName ?? complexes[0]?.complexName ?? 'Mi complejo';
}, [complexSlug, myComplexesQuery.data]);
const handleSignOut = async () => {
await signOut();
await navigate({ to: '/login' });
};
type NavigationItem = {
label: string;
to: '/' | '/profile' | '/complex/$slug/edit';
show: boolean;
params?: { slug: string };
};
const navigationItems: NavigationItem[] = [
{
label: 'Inicio',
to: '/',
show: true,
},
{
label: 'Configuración',
to: '/complex/$slug/edit',
params: currentComplexSlug ? { slug: currentComplexSlug } : undefined,
show: isAuthenticated && Boolean(currentComplexSlug),
},
{
label: 'Perfil',
to: '/profile',
show: isAuthenticated,
},
] as const;
return (
<div className="flex min-h-screen flex-col">
<header className="sticky top-0 z-40 border-b border-border/70 bg-background/80 backdrop-blur-xl">
<div className="mx-auto flex w-full max-w-7xl items-center justify-between gap-4 px-4 py-3 sm:px-6">
<div className="flex min-w-0 items-center gap-4">
<Link to="/" className="flex min-w-0 items-center gap-3">
<span className="relative flex size-10 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-sm ring-1 ring-primary/20">
<FootballIcon className="size-5" />
<span className="absolute -right-0.5 -bottom-0.5 size-2 rounded-full border border-background bg-accent" />
</span>
<span className="min-w-0">
<span className="block text-[10px] font-semibold uppercase tracking-[0.32em] text-muted-foreground">
Playzer
</span>
<span className="block truncate text-sm font-semibold text-foreground">
{currentComplexName}
</span>
</span>
</Link>
<nav className="hidden items-center gap-1 lg:flex">
{navigationItems
.filter((item) => item.show)
.map((item) =>
item.params ? (
<Link
key={item.label}
to={item.to}
params={item.params}
className="rounded-full px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
activeProps={{ className: 'bg-muted text-foreground font-medium' }}
>
{item.label}
</Link>
) : (
<Link
key={item.label}
to={item.to}
className="rounded-full px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
activeProps={{ className: 'bg-muted text-foreground font-medium' }}
>
{item.label}
</Link>
)
)}
</nav>
</div>
<div className="flex items-center gap-2">
<ThemeSwitcher />
{!isAuthenticated && (
<Button asChild size="sm" variant="outline" className="hidden md:inline-flex">
<Link to="/login">Ingresar</Link>
</Button>
)}
{isAuthenticated && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex items-center gap-2 rounded-full border border-border/70 bg-card px-2 py-1.5 transition-colors hover:bg-muted"
>
<UserAvatar
src={avatarUrl}
alt={displayName}
fallbackText={displayName}
size="sm"
/>
<span className="hidden max-w-32 truncate text-sm text-foreground sm:block">
{displayName}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Mi cuenta</DropdownMenuLabel>
<DropdownMenuSeparator />
{myComplexesQuery.data && myComplexesQuery.data.length > 1 && (
<>
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
Cambiar complejo
</DropdownMenuLabel>
{myComplexesQuery.data.map((complex) => (
<DropdownMenuItem
key={complex.id}
onSelect={async () => {
await apiClient.complexes.select({ complexId: complex.id });
setCurrentComplex(complex.complexSlug);
}}
>
{complex.complexSlug === complexSlug && <span className="mr-2"></span>}
{complex.complexName}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
onSelect={() => {
void navigate({ to: '/profile' });
}}
>
<UserRound className="size-4" />
Perfil
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => {
void handleSignOut();
}}
>
<LogOut className="size-4" />
Cerrar sesión
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="icon-sm"
className="lg:hidden"
aria-label="Abrir menú"
>
<Menu className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64 lg:hidden">
<DropdownMenuLabel>{currentComplexName}</DropdownMenuLabel>
<DropdownMenuSeparator />
{navigationItems
.filter((item) => item.show)
.map((item) =>
item.params ? (
<DropdownMenuItem
key={item.label}
onSelect={() => {
void navigate({ to: item.to, params: item.params });
}}
>
{item.label}
</DropdownMenuItem>
) : (
<DropdownMenuItem
key={item.label}
onSelect={() => {
void navigate({ to: item.to });
}}
>
{item.label}
</DropdownMenuItem>
)
)}
<DropdownMenuSeparator />
{myComplexesQuery.data && myComplexesQuery.data.length > 1 && (
<>
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
Cambiar complejo
</DropdownMenuLabel>
{myComplexesQuery.data.map((complex) => (
<DropdownMenuItem
key={complex.id}
onSelect={async () => {
await apiClient.complexes.select({ complexId: complex.id });
setCurrentComplex(complex.complexSlug);
}}
>
{complex.complexSlug === complexSlug && <span className="mr-2"></span>}
{complex.complexName}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</>
)}
{isAuthenticated ? (
<>
<DropdownMenuItem
onSelect={() => {
void navigate({ to: '/profile' });
}}
>
<UserRound className="size-4" />
Perfil
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => {
void handleSignOut();
}}
>
<LogOut className="size-4" />
Cerrar sesión
</DropdownMenuItem>
</>
) : (
<DropdownMenuItem
onSelect={() => {
void navigate({ to: '/login' });
}}
>
Ingresar
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</header>
<div className="flex-1">
<Outlet />
</div>
</div>
);
}