'use client' import { useState, useRef, useEffect, useCallback } from 'react' import { createPortal } from 'react-dom' import Link from 'next/link' import { useTranslations } from 'next-intl' import { cn } from '@/lib/utils' import { useCompany } from '@/contexts/CompanyContext' import { performCompanySwitch } from '@/lib/company/switch-client' import { useToast } from '@/components/ui/use-toast' import { SupportLink } from '@/components/ui/support-link' import { Building2, Check, ChevronsUpDown, ChevronRight, CreditCard, HelpCircle, Loader2, LogOut, Plus, Search, Settings, Users, } from 'lucide-react' // Community invite (Accounted's Discord). Deliberately a constant, not // branding config: self-hosted rebrands can hide or swap it when someone // actually asks for that. const DISCORD_INVITE_URL = 'https://discord.gg/D9SxtTgvx' // Lucide ships no brand marks, so the Discord logo is inlined (simple-icons // path, CC0). Sized and colored like the surrounding lucide icons. function DiscordLogo({ className }: { className?: string }) { return ( ) } interface UserMenuProps { userName: string | null userEmail: string | null isSandbox: boolean collapsed: boolean onLogout: () => void } // Best single-character initial for the avatar. Prefers the first letter of // the full name; falls back to the email; falls back to "?" so the circle // never renders empty. function accountInitial(name: string | null, email: string | null): string { const trimmedName = name?.trim() if (trimmedName && trimmedName.length > 0) return trimmedName[0]!.toUpperCase() const trimmedEmail = email?.trim() if (trimmedEmail && trimmedEmail.length > 0) return trimmedEmail[0]!.toUpperCase() return '?' } /** * Sticky bottom-of-sidebar user block: avatar initials, name, active company, * chevron. Opens an upward popover aligned with the nav column holding * identity, the company-switcher flyout, account links and logout. * Concept reference: ui_migration_plan.md PR 2. */ export default function UserMenu({ userName, userEmail, isSandbox, collapsed, onLogout, }: UserMenuProps) { const { company, companies, isSandbox: companyCtxSandbox } = useCompany() const tNav = useTranslations('nav') const tCommon = useTranslations('common') const tSwitcher = useTranslations('company_switcher') const { toast } = useToast() const [open, setOpen] = useState(false) const [companiesOpen, setCompaniesOpen] = useState(false) const [query, setQuery] = useState('') const [isPending, setIsPending] = useState(false) const triggerRef = useRef(null) const menuRef = useRef(null) const searchRef = useRef(null) const [menuPos, setMenuPos] = useState({ top: 0, left: 0 }) const sandbox = isSandbox || companyCtxSandbox const updatePosition = useCallback(() => { if (!triggerRef.current || !menuRef.current) return const triggerRect = triggerRef.current.getBoundingClientRect() const menuRect = menuRef.current.getBoundingClientRect() const margin = 8 // Upward popover: bottom edge sits just above the trigger, left-aligned // with the nav column. const top = Math.max(margin, triggerRect.top - menuRect.height - 6) const left = Math.max(margin, triggerRect.left) setMenuPos({ top, left }) }, []) useEffect(() => { if (!open) return const raf = requestAnimationFrame(() => updatePosition()) return () => cancelAnimationFrame(raf) }, [open, companiesOpen, updatePosition]) // Focus the search field when the company flyout opens. useEffect(() => { if (!companiesOpen) return const raf = requestAnimationFrame(() => searchRef.current?.focus()) return () => cancelAnimationFrame(raf) }, [companiesOpen]) const close = useCallback(() => { setOpen(false) setCompaniesOpen(false) setQuery('') }, []) // Outside click. Two carve-outs: elements already removed from the DOM // (isConnected: clicking a row that re-renders must not read as outside, // known concept bug pattern), and portaled dialogs (the support dialog // opens above the menu; interacting with it must not unmount it). useEffect(() => { if (!open) return function handleClick(e: MouseEvent) { const target = e.target as HTMLElement if (!target.isConnected) return if (target.closest('[role="dialog"]')) return if ( (!triggerRef.current || !triggerRef.current.contains(target)) && (!menuRef.current || !menuRef.current.contains(target)) ) { close() } } document.addEventListener('mousedown', handleClick) return () => document.removeEventListener('mousedown', handleClick) }, [open, close]) useEffect(() => { if (!open) return function handleKey(e: KeyboardEvent) { if (e.key !== 'Escape') return // Esc closes the flyout first, then the menu; but never while the // support dialog is open (it handles its own Esc). if (document.querySelector('[role="dialog"]')) return if (companiesOpen) { setCompaniesOpen(false) setQuery('') } else { close() } } document.addEventListener('keydown', handleKey) return () => document.removeEventListener('keydown', handleKey) }, [open, companiesOpen, close]) const handleSwitch = async (companyId: string) => { if (company && companyId === company.id) { close() return } setIsPending(true) const result = await performCompanySwitch(companyId) if (result?.error) { setIsPending(false) toast({ title: tSwitcher( result.error === 'not_member' ? 'error_no_access' : 'error_switch_failed', ), variant: 'destructive', }) } } const filteredCompanies = companies.filter(({ company: c }) => c.name.toLowerCase().includes(query.trim().toLowerCase()), ) const menuRow = 'flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-left text-[13px] ' + 'text-muted-foreground hover:text-foreground hover:bg-secondary/60 ' + 'transition-colors duration-150 cursor-pointer' return ( <> (open ? close() : setOpen(true))} aria-expanded={open} aria-haspopup="menu" className={cn( 'group flex w-full items-center rounded-lg text-left transition-colors duration-150 hover:bg-secondary/60', collapsed ? 'justify-center p-2' : 'gap-2.5 px-3 py-2', )} > {accountInitial(userName, userEmail)} {!collapsed && ( <> {userName?.trim() || userEmail || tNav('mitt_konto')} {company && ( {company.name} )} > )} {open && createPortal( {/* Identity */} {(userName || userEmail) && ( {userName && ( {userName} )} {userEmail && ( {userEmail} )} )} {/* Company switcher flyout */} setCompaniesOpen((v) => !v)} aria-expanded={companiesOpen} className={cn(menuRow, companiesOpen && 'bg-secondary/60 text-foreground')} > {company?.name || tSwitcher('default_company_name')} {companiesOpen && ( // Top-aligned with the company row, growing DOWNWARD // (founder feedback 2026-07-23: never upward over the menu). setQuery(e.target.value)} placeholder={tSwitcher('search_placeholder')} className="w-full bg-transparent text-[13px] text-foreground placeholder:text-muted-foreground/60 focus:outline-none" /> {filteredCompanies.length === 0 && ( {tSwitcher('no_results')} )} {filteredCompanies.map(({ company: c, role }) => ( handleSwitch(c.id)} disabled={isPending} role="option" aria-selected={c.id === company?.id} className={cn( 'flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-[13px] leading-snug transition-colors', c.id === company?.id ? 'bg-secondary/60 text-foreground' : 'text-muted-foreground hover:bg-secondary/60 hover:text-foreground', isPending && 'opacity-50', )} > {c.name} {role !== 'owner' && ( {role} )} {c.id === company?.id && ( )} {isPending && c.id !== company?.id && ( )} ))} {!sandbox && ( {tSwitcher('add_company')} )} )} {/* Account links */} {tNav('settings')} {tNav('members_roles')} {tNav('subscription')} {tNav('help')} {tNav('discord_community')} { close() onLogout() }} className={cn(menuRow, 'text-destructive hover:text-destructive')} > {sandbox ? tNav('logout_sandbox') : tCommon('logout')} , document.body, )} > ) }
{userName}
{userEmail}
{tSwitcher('no_results')}