Files
starr-philproperties/src/components/generic/UserMenu.jsx
T
2026-07-07 13:40:07 +08:00

263 lines
11 KiB
React

import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '@/contexts/AuthContext'
import { useTheme } from '@/contexts/ThemeContext'
import { useProfile } from '@/contexts/ProfileProvider'
import { useDateTimePreference } from '@/contexts/DateTimePreferenceContext'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { AlertDialog, AlertDialogContent, } from '@/components/ui/alert-dialog'
import { User, Settings, LogOut, Sun, Moon, Monitor, Loader2, Check, Clock } from 'lucide-react'
import { AVATAR_COLORS } from '@/data/profile.data'
// ─── Theme Option ─────────────────────────────────────────────────────────────
function ThemeOption({ value, label, icon: Icon, active, onClick }) {
return (
<button
onClick={() => onClick(value)}
className={`relative flex flex-col items-center gap-2 p-3 rounded-lg border-2 transition-all cursor-pointer w-full
${active
? 'border-primary bg-primary/5'
: 'border-border hover:border-muted-foreground/40 hover:bg-muted/40'
}`}
>
<Icon size={18} className={active ? 'text-primary' : 'text-muted-foreground'} />
<span className={`text-xs font-medium ${active ? 'text-primary' : 'text-muted-foreground'}`}>
{label}
</span>
{active && (
<span className="absolute top-2 right-2">
<Check size={12} className="text-primary" />
</span>
)}
</button>
)
}
// ─── Timezone Option ──────────────────────────────────────────────────────────
function TimezoneOption({ value, label, description, active, onClick }) {
return (
<button
onClick={() => onClick(value)}
className={`flex flex-col gap-1 p-3 rounded-lg border-2 transition-all cursor-pointer w-full text-left
${active
? 'border-primary bg-primary/5'
: 'border-border hover:border-muted-foreground/40 hover:bg-muted/40'
}`}
>
<span className={`text-sm font-semibold ${active ? 'text-primary' : 'text-foreground'}`}>
{label}
</span>
<span className="text-xs text-muted-foreground">{description}</span>
{active && <Check size={12} className="text-primary mt-0.5" />}
</button>
)
}
// ─── Settings Dialog ──────────────────────────────────────────────────────────
function SettingsDialog({ open, onClose }) {
const { theme, setTheme } = useTheme()
const { timezone, setTimezone } = useDateTimePreference()
const [tab, setTab] = useState('appearance')
const TABS = [
{ id: 'appearance', label: 'Appearance', icon: Sun },
{ id: 'datetime', label: 'Date & Time', icon: Clock },
]
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="p-0 overflow-hidden sm:max-w-2xl gap-0 rounded-2xl">
<div className="flex h-[480px]">
{/* ── Sidebar ── */}
<div className="w-52 shrink-0 border-r bg-muted/30 flex flex-col">
<div className="flex items-center px-4 py-3 border-b">
<span className="text-sm font-semibold">Settings</span>
</div>
<nav className="flex-1 p-2 space-y-0.5">
{TABS.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`w-full flex items-center gap-2.5 px-3 py-2 rounded-md text-sm font-medium transition-colors text-left
${tab === t.id
? 'bg-background text-foreground shadow-sm border border-border/50'
: 'text-muted-foreground hover:text-foreground hover:bg-background/60'
}`}
>
<t.icon size={14} />
{t.label}
</button>
))}
</nav>
</div>
{/* ── Content ── */}
<div className="flex-1 overflow-y-auto p-6">
{/* Appearance */}
{tab === 'appearance' && (
<div className="space-y-5">
<div>
<h2 className="text-base font-semibold">Appearance</h2>
<p className="text-xs text-muted-foreground mt-0.5">
Choose how the interface looks for you.
</p>
</div>
<Separator />
<div>
<Label className="text-sm font-medium mb-3 block">Theme</Label>
<div className="grid grid-cols-3 gap-3 relative">
<ThemeOption value="light" label="Light" icon={Sun} active={theme === 'light'} onClick={setTheme} />
<ThemeOption value="dark" label="Dark" icon={Moon} active={theme === 'dark'} onClick={setTheme} />
<ThemeOption value="system" label="System" icon={Monitor} active={theme === 'system'} onClick={setTheme} />
</div>
</div>
</div>
)}
{/* Date & Time */}
{tab === 'datetime' && (
<div className="space-y-5">
<div>
<h2 className="text-base font-semibold">Date &amp; Time</h2>
<p className="text-xs text-muted-foreground mt-0.5">
Choose how dates and times are displayed across the platform.
Your locale format is taken from your browser&apos;s language setting.
</p>
</div>
<Separator />
<div>
<Label className="text-sm font-medium mb-3 block">Timezone Display</Label>
<div className="grid grid-cols-2 gap-3">
<TimezoneOption
value="local"
label="Local"
description="Dates shown in your device's local timezone."
active={timezone === 'local'}
onClick={setTimezone}
/>
<TimezoneOption
value="UTC"
label="UTC"
description="Dates shown in Coordinated Universal Time (UTC+0)."
active={timezone === 'UTC'}
onClick={setTimezone}
/>
</div>
</div>
<div className="rounded-lg border bg-muted/40 p-3 text-xs text-muted-foreground space-y-1">
<p className="font-medium text-foreground">Preview</p>
<p>Date: <span className="text-foreground">{new Date().toLocaleDateString(navigator.language, { month: 'short', day: 'numeric', year: 'numeric', ...(timezone === 'UTC' ? { timeZone: 'UTC' } : {}) })}</span></p>
<p>Date &amp; Time: <span className="text-foreground">{new Date().toLocaleString(navigator.language, { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', ...(timezone === 'UTC' ? { timeZone: 'UTC' } : {}) })}</span></p>
{timezone === 'UTC' && <p className="text-amber-600 dark:text-amber-400 font-medium">All times shown in UTC</p>}
</div>
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
)
}
// ─── Sign Out Overlay ─────────────────────────────────────────────────────────
function SignOutOverlay({ open }) {
return (
<AlertDialog open={open}>
<AlertDialogContent className="max-w-xs">
<div className="flex flex-col items-center justify-center gap-4 py-6">
<Loader2 className="size-8 animate-spin text-muted-foreground" />
<p className="text-sm font-medium">Signing out...</p>
</div>
</AlertDialogContent>
</AlertDialog>
)
}
// ─── Main UserMenu ────────────────────────────────────────────────────────────
export default function UserMenu() {
const { user, logout } = useAuth()
const { avatarUrl } = useProfile()
const navigate = useNavigate()
const [settingsOpen, setSettingsOpen] = useState(false)
const [signingOut, setSigningOut] = useState(false)
// Build initials from personal_info or fallback to acc_type first char
const given = user?.personal_info?.name?.given_name ?? ''
const last = user?.personal_info?.name?.last_name ?? ''
const initials = given && last
? (given[0] + last[0]).toUpperCase()
: (user?.email?.[0] ?? 'U').toUpperCase()
const fullName = given && last ? `${given} ${last}` : user?.email ?? 'User'
const shortName = (given || user?.email) ?? 'User'
const avatarColor = AVATAR_COLORS[user?.acc_type] ?? 'bg-muted text-muted-foreground'
const handleLogout = async () => {
setSigningOut(true)
await logout()
navigate('/login', { replace: true })
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Avatar className="rounded-lg cursor-pointer hover:opacity-80 transition-opacity size-9">
<AvatarImage src={avatarUrl ?? ''} alt={fullName} />
<AvatarFallback className={`text-sm font-semibold ${avatarColor}`}>
{initials}
</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
{/* User info */}
<div className="px-2 py-2">
<p className="text-sm font-semibold truncate">{shortName}</p>
<p className="text-xs text-muted-foreground truncate">{user?.email ?? '—'}</p>
</div>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => navigate('my-profile')}>
<User className="size-4" />
<span className="font-medium">Profile</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSettingsOpen(true)}>
<Settings className="size-4" />
<span className="font-medium">Settings</span>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem variant="destructive" onClick={handleLogout}>
<LogOut className="size-4" />
<span className="font-medium">Sign out</span>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
{/* Settings dialog */}
<SettingsDialog open={settingsOpen} onClose={() => setSettingsOpen(false)} />
{/* Sign out overlay */}
<SignOutOverlay open={signingOut} />
</>
)
}