2026-05-09 13:03:36 -05:00
import React , { useEffect , useState } from 'react' ;
2026-05-03 19:51:57 -05:00
import { toast } from 'sonner' ;
import {
User , Mail , KeyRound , ShieldCheck , Loader2 ,
} from 'lucide-react' ;
import { api } from '@/api' ;
import { useAuth } from '@/hooks/useAuth' ;
import { Button } from '@/components/ui/button' ;
import { Input } from '@/components/ui/input' ;
import { Switch } from '@/components/ui/switch' ;
function asProfile ( data ) {
return data ? . profile || data ? . user || data || { } ;
}
function asSettings ( data ) {
return data ? . settings || data ? . notifications || data || { } ;
}
function formatDateTime ( value ) {
if ( ! value ) return 'Not recorded' ;
const d = new Date ( value ) ;
if ( Number . isNaN ( d . getTime ( ) ) ) return String ( value ) ;
return d . toLocaleDateString ( undefined , { month : 'short' , day : 'numeric' , year : 'numeric' } )
+ ' '
+ d . toLocaleTimeString ( [ ] , { hour : '2-digit' , minute : '2-digit' } ) ;
}
function SectionCard ( { title , icon : Icon , subtitle , children } ) {
return (
2026-05-04 23:34:24 -05:00
< section className = "overflow-hidden rounded-xl border border-border/70 bg-card/90 shadow-sm" >
2026-05-03 19:51:57 -05:00
< div className = "px-6 py-4 border-b border-border/50 flex items-center gap-3" >
2026-05-04 23:34:24 -05:00
< div className = "h-9 w-9 rounded-lg border border-primary/15 bg-primary/10 flex items-center justify-center shrink-0" >
2026-05-03 19:51:57 -05:00
< Icon className = "h-4 w-4 text-primary" / >
< / div >
< div className = "min-w-0" >
< h2 className = "text-xs font-bold uppercase tracking-widest text-muted-foreground" > { title } < / h2 >
{ subtitle && < p className = "text-sm text-muted-foreground mt-0.5" > { subtitle } < / p > }
< / div >
< / div >
< div > { children } < / div >
< / section >
) ;
}
function FieldRow ( { label , value } ) {
return (
< div className = "rounded-lg border border-border/60 bg-muted/25 px-4 py-3" >
< p className = "text-[10px] font-bold uppercase tracking-widest text-muted-foreground" > { label } < / p >
< p className = "mt-1 text-sm font-medium text-foreground truncate" > { value || 'Not set' } < / p >
< / div >
) ;
}
function CheckRow ( { id , label , checked , onChange , disabled } ) {
return (
< label htmlFor = { id } className = "flex items-center justify-between gap-4 rounded-lg border border-border/60 bg-muted/20 px-4 py-3" >
< span className = "text-sm font-medium" > { label } < / span >
< Switch id = { id } checked = { ! ! checked } onCheckedChange = { onChange } disabled = { disabled } / >
< / label >
) ;
}
function ProfileSummary ( { profile , loading } ) {
if ( loading ) {
return (
< SectionCard title = "Profile Summary" icon = { User } >
< div className = "px-6 py-6 text-sm text-muted-foreground" > Loading profile … < / div >
< / SectionCard >
) ;
}
return (
< SectionCard title = "Profile Summary" icon = { User } subtitle = "Your signed-in account details." >
2026-05-04 23:34:24 -05:00
< div className = "px-6 py-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-3" >
2026-05-03 19:51:57 -05:00
< FieldRow label = "Username" value = { profile . username } / >
< FieldRow label = "Display Name" value = { profile . display _name || profile . displayName } / >
< FieldRow label = "Role" value = { profile . role } / >
< FieldRow label = "Last Login" value = { formatDateTime ( profile . last _login _at || profile . last _login ) } / >
< FieldRow label = "Password Changed" value = { formatDateTime ( profile . last _password _change _at || profile . password _changed _at ) } / >
< / div >
< / SectionCard >
) ;
}
function EditProfile ( { profile , onSaved } ) {
const [ displayName , setDisplayName ] = useState ( profile . display _name || profile . displayName || '' ) ;
const [ saving , setSaving ] = useState ( false ) ;
useEffect ( ( ) => {
setDisplayName ( profile . display _name || profile . displayName || '' ) ;
} , [ profile . display _name , profile . displayName ] ) ;
const save = async ( ) => {
setSaving ( true ) ;
try {
const data = await api . updateProfile ( { display _name : displayName . trim ( ) || null } ) ;
toast . success ( 'Profile saved.' ) ;
onSaved ( asProfile ( data ) ) ;
} catch ( err ) {
toast . error ( err . message || 'Failed to save profile.' ) ;
} finally {
setSaving ( false ) ;
}
} ;
return (
< SectionCard title = "Edit Profile" icon = { User } subtitle = "Choose how your name appears inside the app." >
< div className = "px-6 py-5 flex flex-col gap-3 sm:flex-row sm:items-end" >
< div className = "space-y-1.5 flex-1 max-w-md" >
< label htmlFor = "display-name" className = "text-xs font-medium text-muted-foreground" > Display name < / label >
< Input id = "display-name" value = { displayName } onChange = { e => setDisplayName ( e . target . value ) } placeholder = "Display name" / >
< / div >
< Button onClick = { save } disabled = { saving } className = "sm:mb-0" >
{ saving ? < > < Loader2 className = "h-4 w-4 mr-2 animate-spin" / > Saving … < / > : 'Save Profile' }
< / Button >
< / div >
< / SectionCard >
) ;
}
function NotificationPreferences ( { settings , onSaved } ) {
const [ form , setForm ] = useState ( settings ) ;
const [ saving , setSaving ] = useState ( false ) ;
useEffect ( ( ) => setForm ( settings ) , [ settings ] ) ;
const set = ( k , v ) => setForm ( prev => ( { ... prev , [ k ] : v } ) ) ;
const payload = {
2026-05-04 23:34:24 -05:00
email : form . email || form . notification _email || '' ,
2026-05-03 19:51:57 -05:00
notifications _enabled : ! ! ( form . notifications _enabled ? ? form . enabled ) ,
notify _3 _day : ! ! ( form . notify _3 _day ? ? form . notify _3d ) ,
notify _1 _day : ! ! ( form . notify _1 _day ? ? form . notify _1d ) ,
notify _due : ! ! ( form . notify _due ? ? form . notify _day _of ) ,
notify _overdue : ! ! ( form . notify _overdue ? ? form . notify _daily _overdue ) ,
} ;
payload . enabled = payload . notifications _enabled ;
payload . notify _3d = payload . notify _3 _day ;
payload . notify _1d = payload . notify _1 _day ;
payload . notify _day _of = payload . notify _due ;
payload . notify _daily _overdue = payload . notify _overdue ;
const save = async ( ) => {
setSaving ( true ) ;
try {
const data = await api . updateProfileSettings ( payload ) ;
toast . success ( 'Notification preferences saved.' ) ;
onSaved ( asSettings ( data ) ) ;
} catch ( err ) {
toast . error ( err . message || 'Failed to save notification preferences.' ) ;
} finally {
setSaving ( false ) ;
}
} ;
return (
< SectionCard title = "Notification Preferences" icon = { Mail } subtitle = "Manage email reminders for your bills." >
< div className = "px-6 py-5 space-y-4" >
< div className = "space-y-1.5 max-w-md" >
< label htmlFor = "profile-email" className = "text-xs font-medium text-muted-foreground" > Email < / label >
< Input id = "profile-email" type = "email" value = { payload . email } onChange = { e => set ( 'email' , e . target . value ) } placeholder = "you@example.com" / >
< / div >
< div className = "grid gap-3 sm:grid-cols-2 xl:grid-cols-3" >
< CheckRow id = "n-enabled" label = "Notifications enabled" checked = { payload . notifications _enabled } onChange = { v => set ( 'notifications_enabled' , v ) } / >
< CheckRow id = "n-3" label = "Notify 3 days before" checked = { payload . notify _3 _day } onChange = { v => set ( 'notify_3_day' , v ) } disabled = { ! payload . notifications _enabled } / >
< CheckRow id = "n-1" label = "Notify 1 day before" checked = { payload . notify _1 _day } onChange = { v => set ( 'notify_1_day' , v ) } disabled = { ! payload . notifications _enabled } / >
< CheckRow id = "n-due" label = "Notify due date" checked = { payload . notify _due } onChange = { v => set ( 'notify_due' , v ) } disabled = { ! payload . notifications _enabled } / >
< CheckRow id = "n-overdue" label = "Notify overdue" checked = { payload . notify _overdue } onChange = { v => set ( 'notify_overdue' , v ) } disabled = { ! payload . notifications _enabled } / >
< / div >
< / div >
< div className = "px-6 py-4 border-t border-border/50 flex justify-end" >
< Button onClick = { save } disabled = { saving } >
{ saving ? < > < Loader2 className = "h-4 w-4 mr-2 animate-spin" / > Saving … < / > : 'Save Preferences' }
< / Button >
< / div >
< / SectionCard >
) ;
}
function ChangePassword ( ) {
const [ currentPassword , setCurrentPassword ] = useState ( '' ) ;
const [ newPassword , setNewPassword ] = useState ( '' ) ;
const [ confirmPassword , setConfirmPassword ] = useState ( '' ) ;
const [ saving , setSaving ] = useState ( false ) ;
const reset = ( ) => {
setCurrentPassword ( '' ) ;
setNewPassword ( '' ) ;
setConfirmPassword ( '' ) ;
} ;
const submit = async ( e ) => {
e . preventDefault ( ) ;
if ( ! currentPassword || ! newPassword || ! confirmPassword ) {
toast . error ( 'All password fields are required.' ) ;
return ;
}
if ( newPassword !== confirmPassword ) {
toast . error ( 'New passwords do not match.' ) ;
return ;
}
setSaving ( true ) ;
try {
await api . changeProfilePassword ( {
current _password : currentPassword ,
new _password : newPassword ,
confirm _new _password : confirmPassword ,
} ) ;
reset ( ) ;
toast . success ( 'Password changed.' ) ;
} catch ( err ) {
toast . error ( err . message || 'Failed to change password.' ) ;
} finally {
setSaving ( false ) ;
}
} ;
return (
< SectionCard title = "Change Password" icon = { KeyRound } subtitle = "Update your password without exposing it in logs or page state beyond this form." >
2026-05-04 23:34:24 -05:00
< form onSubmit = { submit } className = "px-6 py-5 grid gap-4 lg:grid-cols-3" >
2026-05-03 19:51:57 -05:00
< div className = "space-y-1.5" >
< label htmlFor = "current-password" className = "text-xs font-medium text-muted-foreground" > Current password < / label >
< Input id = "current-password" type = "password" autoComplete = "current-password" value = { currentPassword } onChange = { e => setCurrentPassword ( e . target . value ) } / >
< / div >
< div className = "space-y-1.5" >
< label htmlFor = "new-password" className = "text-xs font-medium text-muted-foreground" > New password < / label >
< Input id = "new-password" type = "password" autoComplete = "new-password" value = { newPassword } onChange = { e => setNewPassword ( e . target . value ) } / >
< / div >
< div className = "space-y-1.5" >
< label htmlFor = "confirm-password" className = "text-xs font-medium text-muted-foreground" > Confirm new password < / label >
< Input id = "confirm-password" type = "password" autoComplete = "new-password" value = { confirmPassword } onChange = { e => setConfirmPassword ( e . target . value ) } / >
< / div >
2026-05-04 23:34:24 -05:00
< Button type = "submit" disabled = { saving } className = "lg:col-start-3" >
2026-05-03 19:51:57 -05:00
{ saving ? < > < Loader2 className = "h-4 w-4 mr-2 animate-spin" / > Saving … < / > : 'Change Password' }
< / Button >
< / form >
< / SectionCard >
) ;
}
function ProfileNav ( ) {
const items = [
[ '#account' , 'Account' ] ,
[ '#security' , 'Security' ] ,
[ '#notifications' , 'Notifications' ] ,
] ;
return (
< div className = "mb-6 flex flex-wrap gap-2" >
{ items . map ( ( [ href , label ] ) => (
< a
key = { href }
href = { href }
className = "rounded-md border border-border bg-card px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
>
{ label }
< / a >
) ) }
< / div >
) ;
}
export default function ProfilePage ( ) {
const { setUser , refresh } = useAuth ( ) ;
const [ profile , setProfile ] = useState ( { } ) ;
const [ settings , setSettings ] = useState ( { } ) ;
const [ loading , setLoading ] = useState ( true ) ;
useEffect ( ( ) => {
let mounted = true ;
Promise . all ( [
api . profile ( ) ,
api . profileSettings ( ) ,
] )
. then ( ( [ profileData , settingsData ] ) => {
if ( ! mounted ) return ;
setProfile ( asProfile ( profileData ) ) ;
setSettings ( asSettings ( settingsData ) ) ;
} )
. catch ( err => toast . error ( err . message || 'Failed to load profile.' ) )
. finally ( ( ) => mounted && setLoading ( false ) ) ;
return ( ) => { mounted = false ; } ;
} , [ ] ) ;
const handleProfileSaved = ( nextProfile ) => {
setProfile ( prev => ( { ... prev , ... nextProfile } ) ) ;
setUser ( prev => prev ? { ... prev , ... nextProfile } : prev ) ;
refresh ( ) ;
} ;
return (
2026-05-04 23:34:24 -05:00
< div className = "mx-auto w-full max-w-5xl" >
< div className = "mb-6 flex items-start justify-between gap-4" >
2026-05-03 19:51:57 -05:00
< div >
< h1 className = "text-2xl font-bold tracking-tight" > Profile < / h1 >
2026-05-04 23:34:24 -05:00
< p className = "text-sm text-muted-foreground mt-0.5" > Manage your account , notification preferences , and password . < / p >
2026-05-03 19:51:57 -05:00
< / div >
< div className = "hidden sm:flex items-center gap-2 rounded-full border border-border bg-muted/30 px-3 py-1.5 text-xs text-muted-foreground" >
< ShieldCheck className = "h-3.5 w-3.5 text-emerald-500" / >
User - owned data only
< / div >
< / div >
< ProfileNav / >
2026-05-04 23:34:24 -05:00
< div className = "space-y-5" >
2026-05-03 19:51:57 -05:00
< div id = "account" className = "scroll-mt-6 space-y-6" >
< ProfileSummary profile = { profile } loading = { loading } / >
{ ! loading && < EditProfile profile = { profile } onSaved = { handleProfileSaved } / > }
< / div >
< div id = "security" className = "scroll-mt-6" >
< ChangePassword / >
< / div >
< div id = "notifications" className = "scroll-mt-6" >
{ ! loading && < NotificationPreferences settings = { settings } onSaved = { setSettings } / > }
< / div >
< / div >
< / div >
) ;
}