TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1'use client';23import Link from 'next/link';4import { useEffect, useState } from 'react';5import { Bell } from 'lucide-react';67interface SessionInfo {8 user: { id: string; email: string; name: string | null; handle: string | null; avatarUrl: string | null; displayCurrency: string; role: string } | null;9 unread?: number;10}1112/**13 * Header user menu (drop-in for the site header): shows "Sign in" when anonymous, otherwise the14 * avatar, unread badge and a compact menu. Fetches /api/auth/session client-side so the header15 * stays static/cacheable.16 */17export function UserMenu() {18 const [info, setInfo] = useState<SessionInfo | null>(null);19 useEffect(() => {20 let alive = true;21 fetch('/api/auth/session', { credentials: 'same-origin' })22 .then((r) => (r.ok ? r.json() : { user: null }))23 .then((j: SessionInfo) => {24 if (alive) setInfo(j);25 })26 .catch(() => {27 if (alive) setInfo({ user: null });28 });29 return () => {30 alive = false;31 };32 }, []);33 if (!info?.user) {34 return (35 <Link href="/login" className="hidden rounded-md border border-border px-2.5 py-1.5 text-[13px] font-medium text-fg hover:bg-inset sm:inline-flex">36 Sign in37 </Link>38 );39 }40 const u = info.user;41 const initial = (u.name ?? u.email).slice(0, 1).toUpperCase();42 return (43 <div className="flex items-center gap-1">44 <Link href="/notifications" aria-label="Inbox" className="relative inline-flex h-8 w-8 items-center justify-center rounded-md text-muted hover:bg-inset hover:text-fg">45 <Bell className="h-4 w-4" />46 {info.unread ? <span className="absolute -right-0.5 -top-0.5 min-w-[16px] rounded-full bg-alert px-1 text-center text-[10px] font-semibold leading-4 text-white">{info.unread > 99 ? '99+' : info.unread}</span> : null}47 </Link>48 <details className="relative">49 <summary className="flex cursor-pointer list-none items-center gap-2 rounded-md px-1.5 py-1 hover:bg-inset [&::-webkit-details-marker]:hidden">50 {u.avatarUrl ? (51 // eslint-disable-next-line @next/next/no-img-element52 <img src={u.avatarUrl} alt="" className="h-7 w-7 rounded-full border border-border object-cover" />53 ) : (54 <span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-accent text-[11px] font-semibold text-accent-fg">{initial}</span>55 )}56 <span className="hidden max-w-[120px] truncate text-[13px] font-medium md:inline">{u.name ?? u.email.split('@')[0]}</span>57 </summary>58 <div className="absolute right-0 top-full z-50 mt-1 w-56 rounded-lg border border-border bg-elevated p-1.5 shadow-pop">59 <div className="px-2 py-1.5">60 <p className="truncate text-[13px] font-medium">{u.name ?? 'Collector'}</p>61 <p className="truncate text-[11px] text-subtle">{u.email}</p>62 </div>63 <div className="my-1 border-t border-border" />64 {[65 ['/collections', 'Collections'],66 ['/watchlist', 'Watchlist'],67 ['/alerts', 'Alerts'],68 ['/deals', 'Deal Radar'],69 ['/account/settings', 'Settings'],70 ...(u.handle ? [[`/u/${u.handle}`, 'Public profile']] : []),71 ].map(([href, label]) => (72 <Link key={href} href={href!} className="block rounded-sm px-2 py-1.5 text-[13px] text-muted hover:bg-inset hover:text-fg">73 {label}74 </Link>75 ))}76 <div className="my-1 border-t border-border" />77 <form action="/api/auth/logout" method="post">78 <button type="submit" className="block w-full rounded-sm px-2 py-1.5 text-left text-[13px] text-muted hover:bg-inset hover:text-fg">79 Sign out80 </button>81 </form>82 </div>83 </details>84 </div>85 );86}87