'use client'; import { apiFetch } from '@/lib/api'; import { ChevronDown, CreditCard, Download, LifeBuoy, LogOut, ShieldAlert, User as UserIcon, } from 'lucide-react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useEffect, useRef, useState } from 'react'; interface MeUser { userId: string; // All three can be null on a fresh phone-only signup: name not collected, // email not entered, phone is the only stable identifier. Anything that // dereferences these MUST handle the null case. email: string | null; name: string | null; phone: string | null; plan?: 'hobby' | 'pro' | 'team' | 'enterprise'; isAdmin?: boolean; } const PLAN_LABEL: Record, string> = { hobby: 'Hobby', pro: 'Pro', team: 'Team', enterprise: 'Enterprise', }; /** Pick a deterministic accent for the avatar based on the email. Keeps * the avatar stable across sessions without needing an uploaded image. */ function avatarShade(seed: string): string { let hash = 0; for (let i = 0; i < seed.length; i += 1) hash = (hash * 31 + seed.charCodeAt(i)) | 0; const palette = [ 'bg-indigo-500/30 text-indigo-200', 'bg-emerald-500/30 text-emerald-200', 'bg-rose-500/30 text-rose-200', 'bg-amber-500/30 text-amber-200', 'bg-sky-500/30 text-sky-200', 'bg-fuchsia-500/30 text-fuchsia-200', ]; return palette[Math.abs(hash) % palette.length] ?? palette[0]!; } export function UserMenu() { const router = useRouter(); const [user, setUser] = useState(null); const [open, setOpen] = useState(false); const wrapRef = useRef(null); useEffect(() => { apiFetch<{ user: MeUser }>('/v1/auth/me') .then((r) => setUser(r.user)) .catch(() => setUser(null)); }, []); // Click outside / Escape to close useEffect(() => { if (!open) return; function onDocClick(e: MouseEvent) { if (!wrapRef.current?.contains(e.target as Node)) setOpen(false); } function onKey(e: KeyboardEvent) { if (e.key === 'Escape') setOpen(false); } document.addEventListener('mousedown', onDocClick); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('mousedown', onDocClick); document.removeEventListener('keydown', onKey); }; }, [open]); async function logout() { await apiFetch('/v1/auth/logout', { method: 'POST' }).catch(() => undefined); router.push('/'); } if (!user) return null; // Pick the best identifier we have. Phone-only signups have null email // AND null name — used to crash with .charAt(null) here. Fallback chain: // name → email → phone → "Account". const identifier = user.name?.trim() || user.email || user.phone || 'Account'; const label = identifier; const initial = identifier.charAt(0).toUpperCase() || '?'; // Avatar colour is derived from a stable identifier so it doesn't shift // across sessions. Fall back to userId so we always have something. const shade = avatarShade(user.email || user.phone || user.userId); return (
{open && (
{initial}
{label}
{user.email || user.phone || user.userId.slice(0, 8)}
{user.plan && (
Plan {PLAN_LABEL[user.plan]}
)}
)}
); } function MenuLink({ href, icon: Icon, label, onClick, }: { href: string; icon: React.ComponentType<{ size?: number }>; label: string; onClick: () => void; }) { return ( {label} ); }