'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 && createPortal(
{/* Identity */} {(userName || userEmail) && (
{userName && (

{userName}

)} {userEmail && (

{userEmail}

)}
)} {/* Company switcher flyout */}
{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 }) => ( ))}
{!sandbox && (
{tSwitcher('add_company')}
)}
)}
{/* Account links */}
{tNav('settings')} {tNav('members_roles')} {tNav('subscription')}
{tNav('help')} {tNav('discord_community')}
, document.body, )} ) }