import { useCallback, useEffect, useState } from "react" import { Check, RefreshCw, Trash2 } from "lucide-react" import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client" import { AlertBanner } from "@crema/feedback-ui" import { AppShell } from "~/components/layout/app-shell" import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar" import { Badge } from "~/components/ui/badge" import { Button } from "~/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "~/components/ui/card" import { Input } from "~/components/ui/input" import { Textarea } from "~/components/ui/textarea" import { pageTitle } from "~/lib/page-meta" import { profileInitials, saveProfile, useProfile, type Profile, } from "~/lib/profile" import { getUser, updateUser, type User } from "~/lib/arcadia/users" import { uploadFile } from "~/lib/arcadia/digital-objects" import { getProfile, updateProfile as updateArcadiaProfile, pickAvatarUrl, type Profile as ArcadiaProfile, } from "~/lib/arcadia/profiles" import { updateSessionUser, useSession } from "~/lib/session" export const meta = () => pageTitle("Profile") interface AccountDraft { first_name: string last_name: string email: string } export default function ProfileRoute() { const session = useSession() const arcadia = useArcadiaClient() const profile = useProfile() // Mirror of the resolved avatar URL — kept in localStorage so the // in the appbar can render before the profile fetch // resolves on next mount. const [prefs, setPrefs] = useState(profile) useEffect(() => { setPrefs(profile) }, [profile]) // Arcadia account. const [account, setAccount] = useState(null) const [accountDraft, setAccountDraft] = useState({ first_name: "", last_name: "", email: "", }) const [accountLoading, setAccountLoading] = useState(true) const [accountSaving, setAccountSaving] = useState(false) const [accountSavedAt, setAccountSavedAt] = useState(null) const [accountError, setAccountError] = useState(null) // Server-side profile (avatar lives here — `prefs.avatarUrl` mirrors // the resolved URL so the existing bindings keep working). const [arcadiaProfile, setArcadiaProfile] = useState(null) const [avatarUploading, setAvatarUploading] = useState(false) const [avatarError, setAvatarError] = useState(null) // Public-profile editable fields, server-backed via PATCH /api/v1/profile. const [profileDraft, setProfileDraft] = useState<{ bio: string phone: string location: string timezone: string }>({ bio: "", phone: "", location: "", timezone: "" }) const [profileSaving, setProfileSaving] = useState(false) const [profileSavedAt, setProfileSavedAt] = useState(null) const [profileError, setProfileError] = useState(null) const profileDirty = !!arcadiaProfile && (profileDraft.bio !== (arcadiaProfile.bio ?? "") || profileDraft.phone !== (arcadiaProfile.phone ?? "") || profileDraft.location !== (arcadiaProfile.location ?? "") || profileDraft.timezone !== (arcadiaProfile.timezone ?? "")) const loadAccount = useCallback(async () => { if (!session) return setAccountLoading(true) setAccountError(null) try { const [u, p] = await Promise.all([ getUser(arcadia, session.userId), getProfile(arcadia).catch(() => null), ]) setAccount(u) setAccountDraft({ first_name: u.first_name ?? "", last_name: u.last_name ?? "", email: u.email, }) if (p) { setArcadiaProfile(p) setProfileDraft({ bio: p.bio ?? "", phone: p.phone ?? "", location: p.location ?? "", timezone: p.timezone ?? "", }) const url = pickAvatarUrl(p) if (url) setPrefs((d) => ({ ...d, avatarUrl: url })) } } catch (err) { setAccountError( err instanceof ArcadiaError ? err.message : "Failed to load account.", ) } finally { setAccountLoading(false) } }, [arcadia, session]) useEffect(() => { loadAccount() }, [loadAccount]) const accountDirty = !!account && (accountDraft.first_name !== (account.first_name ?? "") || accountDraft.last_name !== (account.last_name ?? "") || accountDraft.email !== account.email) const saveAccount = async () => { if (!account) return setAccountSaving(true) setAccountError(null) try { const updated = await updateUser(arcadia, account.id, { first_name: accountDraft.first_name || null, last_name: accountDraft.last_name || null, email: accountDraft.email, }) setAccount(updated) updateSessionUser({ name: updated.full_name, email: updated.email }) setAccountSavedAt(Date.now()) } catch (err) { setAccountError( err instanceof ArcadiaError ? err.message : "Save failed.", ) } finally { setAccountSaving(false) } } const saveArcadiaProfile = async () => { setProfileSaving(true) setProfileError(null) try { const updated = await updateArcadiaProfile(arcadia, { bio: profileDraft.bio || null, phone: profileDraft.phone || null, location: profileDraft.location || null, timezone: profileDraft.timezone || null, }) setArcadiaProfile(updated) setProfileDraft({ bio: updated.bio ?? "", phone: updated.phone ?? "", location: updated.location ?? "", timezone: updated.timezone ?? "", }) setProfileSavedAt(Date.now()) } catch (err) { setProfileError( err instanceof ArcadiaError ? err.message : err instanceof Error ? err.message : "Save failed.", ) } finally { setProfileSaving(false) } } // Local prefs handlers. const initials = profileInitials( [accountDraft.first_name, accountDraft.last_name].filter(Boolean).join(" ") || account?.full_name || session?.name || "", ) const onPickAvatar = async (file: File | null) => { setAvatarError(null) if (!file) { // Clear: detach the digital object on the server, then drop the // local cache. Keep the local cache cleared even if the server call // fails so the UI reflects the user's intent. setPrefs((d) => ({ ...d, avatarUrl: "" })) try { const updated = await updateArcadiaProfile(arcadia, { avatar_digital_object_id: null, }) setArcadiaProfile(updated) savePrefsLocal({ ...prefs, avatarUrl: "" }) } catch (err) { setAvatarError( err instanceof Error ? err.message : "Failed to clear avatar.", ) } return } if (!file.type.startsWith("image/")) { setAvatarError("Avatar must be an image (PNG, JPG, GIF, WebP).") return } // 8MB hard cap client-side; arcadia will enforce its own quota too. if (file.size > 8 * 1024 * 1024) { setAvatarError("Avatar is too large (max 8MB).") return } setAvatarUploading(true) try { const obj = await uploadFile(arcadia, file, { tags: ["avatar"] }) const updated = await updateArcadiaProfile(arcadia, { avatar_digital_object_id: obj.id, }) setArcadiaProfile(updated) const url = pickAvatarUrl(updated) if (url) { const next = { ...prefs, avatarUrl: url } setPrefs(next) savePrefsLocal(next) } } catch (err) { setAvatarError( err instanceof Error ? err.message : "Avatar upload failed.", ) } finally { setAvatarUploading(false) } } // Mirror the avatar URL into localStorage so it survives reloads. const savePrefsLocal = (next: Profile) => { saveProfile(next) } return ( Account {account?.email_verified ? ( Verified ) : account ? ( Unverified ) : null} {account?.status && account.status !== "active" ? ( {account.status} ) : null} Your arcadia identity. Changes are saved to the platform and reflected anywhere your name or email appears. {accountError ? ( setAccountError(null)} > {accountError} ) : null}
{prefs.avatarUrl ? ( ) : null} {initials}
{account?.full_name || accountDraft.email || "—"} {account ? ( <> Tenant {account.tenant_id} · ID {account.id} Last sign-in{" "} {account.last_sign_in_at ? new Date(account.last_sign_in_at).toLocaleString() : "—"} ) : null}
setAccountDraft((d) => ({ ...d, first_name: e.target.value })) } autoComplete="given-name" disabled={accountLoading || accountSaving} /> setAccountDraft((d) => ({ ...d, last_name: e.target.value })) } autoComplete="family-name" disabled={accountLoading || accountSaving} /> setAccountDraft((d) => ({ ...d, email: e.target.value })) } autoComplete="email" disabled={accountLoading || accountSaving} />
{accountSavedAt && !accountDirty && ( Saved. )}
Profile Public profile fields stored on arcadia. Visible to other members of this tenant. {profileError ? ( {profileError} ) : null}