489 lines
18 KiB
TypeScript
489 lines
18 KiB
TypeScript
import {
|
|
CalendarDays,
|
|
Check,
|
|
Laptop,
|
|
LogOut,
|
|
Menu,
|
|
Moon,
|
|
Settings,
|
|
Sun,
|
|
User,
|
|
X,
|
|
} from 'lucide-react';
|
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
import PlayzerIcon from '@/assets/playzer-favicon-512-transparent.png';
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { Link, useLocation, useNavigate } from '@tanstack/react-router';
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuRadioGroup,
|
|
DropdownMenuRadioItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
|
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 { useTheme } from '@/lib/theme';
|
|
|
|
interface NavItemProps {
|
|
to: '/' | '/about';
|
|
label: string;
|
|
isMobile?: boolean;
|
|
onClick?: () => void;
|
|
}
|
|
|
|
function NavItem({ to, label, isMobile, onClick }: NavItemProps) {
|
|
const location = useLocation();
|
|
const currentPath = location.pathname;
|
|
const isActive = to === '/' ? currentPath === '/' : currentPath.startsWith(to);
|
|
|
|
return (
|
|
<Link to={to} onClick={onClick}>
|
|
<Button
|
|
variant="ghost"
|
|
size={isMobile ? 'default' : 'sm'}
|
|
className={[
|
|
'rounded-none transition-all duration-200 hover:bg-transparent',
|
|
'focus-visible:ring-2 focus-visible:ring-primary/50',
|
|
isMobile && 'w-full justify-start rounded-2xl',
|
|
isActive
|
|
? 'text-primary relative after:absolute after:bottom-0 after:left-0 after:right-0 after:h-0.5 after:bg-primary'
|
|
: 'text-muted-foreground hover:text-foreground relative hover:after:absolute hover:after:bottom-0 hover:after:left-0 hover:after:right-0 hover:after:h-0.5 hover:after:bg-primary/50',
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')}
|
|
>
|
|
{label}
|
|
</Button>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
export function Header() {
|
|
const [open, setOpen] = useState(false);
|
|
const navigate = useNavigate();
|
|
const queryClient = useQueryClient();
|
|
const { user, isAuthenticated, displayName, avatarUrl, signOut } = useAuth();
|
|
const { theme, setTheme } = useTheme();
|
|
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 menuItems = [
|
|
{ label: 'Inicio', to: '/' },
|
|
{ label: 'About', to: '/about' },
|
|
] as const;
|
|
|
|
const handleSelectComplex = async (complexId: string, nextSlug: string) => {
|
|
await apiClient.complexes.select({ complexId });
|
|
setCurrentComplex(nextSlug);
|
|
await queryClient.invalidateQueries({ queryKey: ['current-complex'] });
|
|
};
|
|
|
|
const handleSignOut = async () => {
|
|
await signOut();
|
|
await navigate({ to: '/login' });
|
|
};
|
|
|
|
const handleOpenBookingCreate = async () => {
|
|
if (location.pathname !== '/') {
|
|
await navigate({ to: '/' });
|
|
}
|
|
|
|
window.setTimeout(() => {
|
|
window.dispatchEvent(new Event('playzer:open-booking-create'));
|
|
}, 0);
|
|
};
|
|
|
|
return (
|
|
<header className="sticky top-0 z-50 w-full border-b border-border/60 bg-background/60 dark:border-primary/20 dark:bg-[#030a10]/90 backdrop-blur-xl">
|
|
<div className="absolute inset-0 -z-10 bg-gradient-to-b from-background/85 to-background/45 dark:from-[#030a10]/95 dark:to-[#030a10]/70" />
|
|
|
|
<div className="mx-auto flex h-16 w-full max-w-7xl items-center gap-4 px-3 sm:px-6 lg:px-8">
|
|
<Link to="/" className="group flex items-center gap-3">
|
|
<div className="flex size-10 items-center justify-center shadow-primary/10 ">
|
|
<img src={PlayzerIcon} alt="Playzer" className="size-7" />
|
|
</div>
|
|
|
|
<span className="bg-gradient-to-r from-primary via-primary to-reserved bg-clip-text text-xl font-bold tracking-tight text-transparent">
|
|
Playzer
|
|
</span>
|
|
</Link>
|
|
|
|
<nav className="hidden absolute left-1/2 -translate-x-1/2 items-center gap-1 md:flex">
|
|
{menuItems.map((item) => (
|
|
<NavItem key={item.label} to={item.to} label={item.label} />
|
|
))}
|
|
</nav>
|
|
|
|
<div className="flex-1" />
|
|
|
|
<div className="hidden items-center gap-2 md:flex">
|
|
<Button
|
|
className="rounded-2xl px-5 shadow-lg shadow-primary/20"
|
|
onClick={() => {
|
|
void handleOpenBookingCreate();
|
|
}}
|
|
>
|
|
<CalendarDays className="mr-2 size-4" />
|
|
Reservar ahora
|
|
</Button>
|
|
</div>
|
|
|
|
{isAuthenticated ? (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="hidden size-10 rounded-full ring-2 ring-transparent transition-all duration-200 hover:ring-primary/30 focus:outline-none md:flex"
|
|
>
|
|
<UserAvatar
|
|
src={avatarUrl}
|
|
alt={displayName}
|
|
fallbackText={displayName}
|
|
className="size-10 border border-border/60 shadow-lg shadow-black/10"
|
|
/>
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
|
|
<DropdownMenuContent
|
|
align="end"
|
|
className="w-64 rounded-2xl border-border/60 bg-popover/95 p-2 shadow-xl shadow-black/20 backdrop-blur-xl"
|
|
>
|
|
<DropdownMenuLabel className="px-2 py-2">
|
|
<div className="flex items-center gap-3">
|
|
<UserAvatar
|
|
src={avatarUrl}
|
|
alt={displayName}
|
|
fallbackText={displayName}
|
|
className="size-9"
|
|
/>
|
|
|
|
<div className="flex min-w-0 flex-col">
|
|
<span className="truncate text-sm font-medium">{displayName}</span>
|
|
<span className="truncate text-xs text-muted-foreground">{user?.email}</span>
|
|
<span className="truncate text-xs text-muted-foreground">
|
|
{currentComplexName}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</DropdownMenuLabel>
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
{myComplexesQuery.data && myComplexesQuery.data.length > 1 && (
|
|
<>
|
|
<DropdownMenuLabel className="px-2 text-xs font-normal text-muted-foreground">
|
|
Cambiar complejo
|
|
</DropdownMenuLabel>
|
|
{myComplexesQuery.data.map((complex) => (
|
|
<DropdownMenuItem
|
|
key={complex.id}
|
|
className="cursor-pointer rounded-xl"
|
|
onSelect={() => {
|
|
void handleSelectComplex(complex.id, complex.complexSlug);
|
|
}}
|
|
>
|
|
<span className="mr-2 flex size-4 items-center justify-center">
|
|
{complex.complexSlug === complexSlug && <Check className="size-4" />}
|
|
</span>
|
|
{complex.complexName}
|
|
</DropdownMenuItem>
|
|
))}
|
|
<DropdownMenuSeparator />
|
|
</>
|
|
)}
|
|
|
|
<DropdownMenuLabel className="px-2 text-xs font-normal text-muted-foreground">
|
|
Apariencia
|
|
</DropdownMenuLabel>
|
|
<DropdownMenuRadioGroup
|
|
value={theme}
|
|
onValueChange={(value) => setTheme(value as 'light' | 'dark' | 'system')}
|
|
>
|
|
<DropdownMenuRadioItem value="light" className="cursor-pointer rounded-xl">
|
|
<Sun className="mr-2 size-4" />
|
|
Light
|
|
</DropdownMenuRadioItem>
|
|
<DropdownMenuRadioItem value="dark" className="cursor-pointer rounded-xl">
|
|
<Moon className="mr-2 size-4" />
|
|
Dark
|
|
</DropdownMenuRadioItem>
|
|
<DropdownMenuRadioItem value="system" className="cursor-pointer rounded-xl">
|
|
<Laptop className="mr-2 size-4" />
|
|
System
|
|
</DropdownMenuRadioItem>
|
|
</DropdownMenuRadioGroup>
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
<DropdownMenuItem
|
|
className="cursor-pointer rounded-xl"
|
|
onSelect={() => {
|
|
void navigate({ to: '/profile' });
|
|
}}
|
|
>
|
|
<User className="mr-2 size-4" />
|
|
Mi Perfil
|
|
</DropdownMenuItem>
|
|
|
|
{currentComplexSlug && (
|
|
<DropdownMenuItem
|
|
className="cursor-pointer rounded-xl"
|
|
onSelect={() => {
|
|
void navigate({
|
|
to: '/complex/$slug/edit',
|
|
params: { slug: currentComplexSlug },
|
|
});
|
|
}}
|
|
>
|
|
<Settings className="mr-2 size-4" />
|
|
Configuración
|
|
</DropdownMenuItem>
|
|
)}
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
<DropdownMenuItem
|
|
className="cursor-pointer rounded-xl text-destructive focus:bg-destructive/10 focus:text-destructive"
|
|
onSelect={() => {
|
|
void handleSignOut();
|
|
}}
|
|
>
|
|
<LogOut className="mr-2 size-4" />
|
|
Cerrar sesión
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
) : (
|
|
<Button asChild size="sm" variant="outline" className="hidden rounded-2xl md:inline-flex">
|
|
<Link to="/login">Login</Link>
|
|
</Button>
|
|
)}
|
|
|
|
<Sheet open={open} onOpenChange={setOpen}>
|
|
<SheetTrigger asChild>
|
|
<Button variant="outline" size="icon" className="rounded-2xl md:hidden">
|
|
{open ? <X className="size-5" /> : <Menu className="size-5" />}
|
|
</Button>
|
|
</SheetTrigger>
|
|
|
|
<SheetContent
|
|
side="right"
|
|
className="w-[88vw] border-border/60 bg-background/95 p-5 backdrop-blur-xl sm:max-w-sm"
|
|
>
|
|
<div className="mt-6 flex flex-col gap-6">
|
|
<div className="rounded-3xl border border-border/60 bg-card/70 p-4 shadow-xl shadow-black/10">
|
|
<div className="flex items-center gap-3">
|
|
<UserAvatar
|
|
src={avatarUrl}
|
|
alt={displayName}
|
|
fallbackText={displayName}
|
|
className="size-11"
|
|
/>
|
|
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm font-medium">{displayName}</p>
|
|
<p className="truncate text-xs text-muted-foreground">{user?.email}</p>
|
|
{isAuthenticated && (
|
|
<p className="truncate text-xs text-muted-foreground">{currentComplexName}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<nav className="flex flex-col gap-1">
|
|
{menuItems.map((item) => (
|
|
<NavItem
|
|
key={item.label}
|
|
to={item.to}
|
|
label={item.label}
|
|
isMobile
|
|
onClick={() => setOpen(false)}
|
|
/>
|
|
))}
|
|
</nav>
|
|
|
|
<Button
|
|
className="rounded-2xl shadow-lg shadow-primary/20"
|
|
onClick={() => {
|
|
setOpen(false);
|
|
void handleOpenBookingCreate();
|
|
}}
|
|
>
|
|
<CalendarDays className="mr-2 size-4" />
|
|
Reservar ahora
|
|
</Button>
|
|
|
|
<div className="border-t border-border/60 pt-4">
|
|
<div className="mb-2 flex items-center justify-between px-2">
|
|
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
|
Cuenta
|
|
</p>
|
|
</div>
|
|
|
|
<nav className="flex flex-col gap-1">
|
|
{!isAuthenticated && (
|
|
<Button
|
|
variant="ghost"
|
|
className="justify-start rounded-2xl"
|
|
onClick={() => {
|
|
setOpen(false);
|
|
void navigate({ to: '/login' });
|
|
}}
|
|
>
|
|
Login
|
|
</Button>
|
|
)}
|
|
|
|
<div className="py-1">
|
|
<p className="px-3 py-1 text-xs text-muted-foreground">Apariencia</p>
|
|
{[
|
|
{ value: 'light', label: 'Light', icon: Sun },
|
|
{ value: 'dark', label: 'Dark', icon: Moon },
|
|
{ value: 'system', label: 'System', icon: Laptop },
|
|
].map((item) => {
|
|
const Icon = item.icon;
|
|
|
|
return (
|
|
<Button
|
|
key={item.value}
|
|
variant="ghost"
|
|
className="w-full justify-start rounded-2xl"
|
|
onClick={() => setTheme(item.value as 'light' | 'dark' | 'system')}
|
|
>
|
|
<span className="mr-2 flex size-4 items-center justify-center">
|
|
{theme === item.value ? (
|
|
<Check className="size-4" />
|
|
) : (
|
|
<Icon className="size-4" />
|
|
)}
|
|
</span>
|
|
{item.label}
|
|
</Button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{myComplexesQuery.data && myComplexesQuery.data.length > 1 && (
|
|
<div className="py-1">
|
|
<p className="px-3 py-1 text-xs text-muted-foreground">Cambiar complejo</p>
|
|
{myComplexesQuery.data.map((complex) => (
|
|
<Button
|
|
key={complex.id}
|
|
variant="ghost"
|
|
className="w-full justify-start rounded-2xl"
|
|
onClick={() => {
|
|
setOpen(false);
|
|
void handleSelectComplex(complex.id, complex.complexSlug);
|
|
}}
|
|
>
|
|
<span className="mr-2 flex size-4 items-center justify-center">
|
|
{complex.complexSlug === complexSlug && <Check className="size-4" />}
|
|
</span>
|
|
{complex.complexName}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{isAuthenticated && (
|
|
<Button
|
|
variant="ghost"
|
|
className="justify-start rounded-2xl"
|
|
onClick={() => {
|
|
setOpen(false);
|
|
void navigate({ to: '/profile' });
|
|
}}
|
|
>
|
|
<User className="mr-2 size-4" />
|
|
Mi Perfil
|
|
</Button>
|
|
)}
|
|
|
|
{currentComplexSlug && (
|
|
<Button
|
|
variant="ghost"
|
|
className="justify-start rounded-2xl"
|
|
onClick={() => {
|
|
setOpen(false);
|
|
void navigate({
|
|
to: '/complex/$slug/edit',
|
|
params: { slug: currentComplexSlug },
|
|
});
|
|
}}
|
|
>
|
|
<Settings className="mr-2 size-4" />
|
|
Configuración
|
|
</Button>
|
|
)}
|
|
|
|
{isAuthenticated && (
|
|
<Button
|
|
variant="ghost"
|
|
className="justify-start rounded-2xl text-destructive hover:bg-destructive/10 hover:text-destructive"
|
|
onClick={() => {
|
|
setOpen(false);
|
|
void handleSignOut();
|
|
}}
|
|
>
|
|
<LogOut className="mr-2 size-4" />
|
|
Cerrar sesión
|
|
</Button>
|
|
)}
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
</div>
|
|
|
|
<div className="h-px bg-gradient-to-r from-transparent via-primary/40 to-transparent" />
|
|
</header>
|
|
);
|
|
}
|