From b39329d43200d666f8069f8d277d9b6d879c6d11 Mon Sep 17 00:00:00 2001 From: Nicholai Date: Thu, 22 Jan 2026 05:41:11 -0700 Subject: [PATCH] feat(settings): add user profile settings with theme toggle Add user profile management system: - Create user_profiles table with display name, bio, theme prefs - Add profile settings page at /dashboard/settings/profile - Integrate next-themes for light/dark/system theme switching - Update sidebar to display user's profile data and avatar - Wire up Account menu link and Log out button --- drizzle/0003_talented_lockheed.sql | 13 + drizzle/meta/0003_snapshot.json | 378 ++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/app/actions/profile.ts | 190 ++++++++++ src/app/dashboard/page.tsx | 26 +- src/app/dashboard/settings/profile/page.tsx | 67 ++++ src/app/dashboard/wishlist/page.tsx | 22 +- src/app/layout.tsx | 7 +- src/components/app-sidebar.tsx | 13 +- src/components/nav-user.tsx | 28 +- src/components/settings/profile-form.tsx | 263 ++++++++++++++ src/components/theme-provider.tsx | 10 + src/db/schema.ts | 15 + 13 files changed, 1013 insertions(+), 26 deletions(-) create mode 100644 drizzle/0003_talented_lockheed.sql create mode 100644 drizzle/meta/0003_snapshot.json create mode 100644 src/app/actions/profile.ts create mode 100644 src/app/dashboard/settings/profile/page.tsx create mode 100644 src/components/settings/profile-form.tsx create mode 100644 src/components/theme-provider.tsx diff --git a/drizzle/0003_talented_lockheed.sql b/drizzle/0003_talented_lockheed.sql new file mode 100644 index 0000000..887bd0e --- /dev/null +++ b/drizzle/0003_talented_lockheed.sql @@ -0,0 +1,13 @@ +CREATE TABLE `user_profiles` ( + `id` text PRIMARY KEY NOT NULL, + `email` text NOT NULL, + `display_name` text, + `first_name` text, + `last_name` text, + `bio` text, + `avatar_url` text, + `theme` text DEFAULT 'system' NOT NULL, + `email_notifications` text DEFAULT 'true' NOT NULL, + `created_at` text NOT NULL, + `updated_at` text NOT NULL +); diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..bdbd6a4 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,378 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b176d797-36df-41ea-be0c-9e4327ba74f6", + "prevId": "b77d7c90-2258-4123-9d06-632970d59286", + "tables": { + "user_profiles": { + "name": "user_profiles", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "email_notifications": { + "name": "email_notifications", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'true'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "wishlist_comment_votes": { + "name": "wishlist_comment_votes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "comment_id": { + "name": "comment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vote_type": { + "name": "vote_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "wishlist_comment_votes_comment_id_wishlist_comments_id_fk": { + "name": "wishlist_comment_votes_comment_id_wishlist_comments_id_fk", + "tableFrom": "wishlist_comment_votes", + "tableTo": "wishlist_comments", + "columnsFrom": [ + "comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "wishlist_comments": { + "name": "wishlist_comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "wishlist_comments_item_id_wishlist_items_id_fk": { + "name": "wishlist_comments_item_id_wishlist_items_id_fk", + "tableFrom": "wishlist_comments", + "tableTo": "wishlist_items", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "wishlist_items": { + "name": "wishlist_items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "estimated_cost": { + "name": "estimated_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "submitted_by": { + "name": "submitted_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "submitted_by_name": { + "name": "submitted_by_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "wishlist_votes": { + "name": "wishlist_votes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vote_type": { + "name": "vote_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "wishlist_votes_item_id_wishlist_items_id_fk": { + "name": "wishlist_votes_item_id_wishlist_items_id_fk", + "tableFrom": "wishlist_votes", + "tableTo": "wishlist_items", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index b7669e0..524e06d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1769077551375, "tag": "0002_curly_spectrum", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1769083154035, + "tag": "0003_talented_lockheed", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/actions/profile.ts b/src/app/actions/profile.ts new file mode 100644 index 0000000..2678847 --- /dev/null +++ b/src/app/actions/profile.ts @@ -0,0 +1,190 @@ +"use server" + +import { getCloudflareContext } from "@opennextjs/cloudflare" +import { getDb } from "@/db" +import { userProfiles } from "@/db/schema" +import { eq } from "drizzle-orm" +import { revalidatePath } from "next/cache" + +export interface WorkOSUser { + id: string + email: string + firstName?: string | null + lastName?: string | null + profilePictureUrl?: string | null +} + +export interface ProfileData { + id: string + email: string + displayName: string + firstName: string | null + lastName: string | null + bio: string | null + avatarUrl: string | null + theme: string + emailNotifications: boolean +} + +export interface UpdateProfileInput { + displayName?: string + firstName?: string + lastName?: string + bio?: string + theme?: string + emailNotifications?: boolean +} + +function computeDisplayName( + displayName: string | null | undefined, + firstName: string | null | undefined, + lastName: string | null | undefined, + email: string +): string { + if (displayName) return displayName + if (firstName && lastName) return `${firstName} ${lastName}` + if (firstName) return firstName + if (lastName) return lastName + return email.split("@")[0] +} + +export async function ensureProfile(workosUser: WorkOSUser): Promise { + const { env } = await getCloudflareContext() + const db = getDb(env.DB) + + const existing = await db + .select() + .from(userProfiles) + .where(eq(userProfiles.id, workosUser.id)) + .limit(1) + + if (existing.length > 0) { + const profile = existing[0] + return { + id: profile.id, + email: profile.email, + displayName: computeDisplayName( + profile.displayName, + profile.firstName, + profile.lastName, + profile.email + ), + firstName: profile.firstName, + lastName: profile.lastName, + bio: profile.bio, + avatarUrl: profile.avatarUrl ?? workosUser.profilePictureUrl ?? null, + theme: profile.theme, + emailNotifications: profile.emailNotifications === "true", + } + } + + const now = new Date().toISOString() + await db.insert(userProfiles).values({ + id: workosUser.id, + email: workosUser.email, + displayName: null, + firstName: workosUser.firstName ?? null, + lastName: workosUser.lastName ?? null, + bio: null, + avatarUrl: workosUser.profilePictureUrl ?? null, + theme: "system", + emailNotifications: "true", + createdAt: now, + updatedAt: now, + }) + + return { + id: workosUser.id, + email: workosUser.email, + displayName: computeDisplayName( + null, + workosUser.firstName, + workosUser.lastName, + workosUser.email + ), + firstName: workosUser.firstName ?? null, + lastName: workosUser.lastName ?? null, + bio: null, + avatarUrl: workosUser.profilePictureUrl ?? null, + theme: "system", + emailNotifications: true, + } +} + +export async function getProfile(userId: string): Promise { + const { env } = await getCloudflareContext() + const db = getDb(env.DB) + + const result = await db + .select() + .from(userProfiles) + .where(eq(userProfiles.id, userId)) + .limit(1) + + if (result.length === 0) return null + + const profile = result[0] + return { + id: profile.id, + email: profile.email, + displayName: computeDisplayName( + profile.displayName, + profile.firstName, + profile.lastName, + profile.email + ), + firstName: profile.firstName, + lastName: profile.lastName, + bio: profile.bio, + avatarUrl: profile.avatarUrl, + theme: profile.theme, + emailNotifications: profile.emailNotifications === "true", + } +} + +export async function updateProfile( + userId: string, + input: UpdateProfileInput +): Promise<{ success: boolean; error?: string }> { + try { + const { env } = await getCloudflareContext() + const db = getDb(env.DB) + + const updates: Record = { + updatedAt: new Date().toISOString(), + } + + if (input.displayName !== undefined) { + updates.displayName = input.displayName + } + if (input.firstName !== undefined) { + updates.firstName = input.firstName + } + if (input.lastName !== undefined) { + updates.lastName = input.lastName + } + if (input.bio !== undefined) { + updates.bio = input.bio + } + if (input.theme !== undefined) { + updates.theme = input.theme + } + if (input.emailNotifications !== undefined) { + updates.emailNotifications = input.emailNotifications ? "true" : "false" + } + + await db + .update(userProfiles) + .set(updates) + .where(eq(userProfiles.id, userId)) + + revalidatePath("/dashboard/settings/profile") + revalidatePath("/dashboard") + revalidatePath("/dashboard/wishlist") + + return { success: true } + } catch (error) { + console.error("Failed to update profile:", error) + return { success: false, error: "Failed to update profile" } + } +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index de3e703..305b44f 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,5 +1,5 @@ -import { withAuth } from "@workos-inc/authkit-nextjs"; -import { redirect } from "next/navigation"; +import { withAuth } from "@workos-inc/authkit-nextjs" +import { redirect } from "next/navigation" import { AppSidebar } from "@/components/app-sidebar" import { ChartAreaInteractive } from "@/components/chart-area-interactive" import { DataTable } from "@/components/data-table" @@ -9,15 +9,31 @@ import { SidebarInset, SidebarProvider, } from "@/components/ui/sidebar" +import { ensureProfile } from "@/app/actions/profile" import data from "./data.json" export default async function Page() { - const { user } = await withAuth(); + const { user } = await withAuth() if (!user) { - redirect("/"); + redirect("/") } + + const profile = await ensureProfile({ + id: user.id, + email: user.email ?? "", + firstName: user.firstName, + lastName: user.lastName, + profilePictureUrl: user.profilePictureUrl, + }) + + const sidebarUser = { + name: profile.displayName, + email: profile.email, + avatar: profile.avatarUrl ?? "", + } + return ( - +
diff --git a/src/app/dashboard/settings/profile/page.tsx b/src/app/dashboard/settings/profile/page.tsx new file mode 100644 index 0000000..143a2c8 --- /dev/null +++ b/src/app/dashboard/settings/profile/page.tsx @@ -0,0 +1,67 @@ +import { withAuth } from "@workos-inc/authkit-nextjs" +import { redirect } from "next/navigation" + +import { AppSidebar } from "@/components/app-sidebar" +import { SiteHeader } from "@/components/site-header" +import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar" +import { ProfileForm, ProfileFormSubmitButton } from "@/components/settings/profile-form" +import { ensureProfile } from "@/app/actions/profile" + +export default async function ProfilePage() { + const { user } = await withAuth() + + if (!user) { + redirect("/") + } + + const profile = await ensureProfile({ + id: user.id, + email: user.email ?? "", + firstName: user.firstName, + lastName: user.lastName, + profilePictureUrl: user.profilePictureUrl, + }) + + const sidebarUser = { + name: profile.displayName, + email: profile.email, + avatar: profile.avatarUrl ?? "", + } + + return ( + + + + +
+
+
+
+ +
+
+

+ Profile Settings +

+

+ Manage your profile information and preferences +

+
+ +
+
+
+
+
+
+
+
+ ) +} diff --git a/src/app/dashboard/wishlist/page.tsx b/src/app/dashboard/wishlist/page.tsx index 10da16e..43507c1 100644 --- a/src/app/dashboard/wishlist/page.tsx +++ b/src/app/dashboard/wishlist/page.tsx @@ -5,6 +5,7 @@ import { AppSidebar } from "@/components/app-sidebar" import { SiteHeader } from "@/components/site-header" import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar" import { WishlistTable } from "@/components/wishlist/wishlist-table" +import { ensureProfile } from "@/app/actions/profile" export default async function WishlistPage() { const { user } = await withAuth() @@ -13,10 +14,19 @@ export default async function WishlistPage() { redirect("/") } - const userName = - user.firstName && user.lastName - ? `${user.firstName} ${user.lastName}` - : user.email || "Anonymous" + const profile = await ensureProfile({ + id: user.id, + email: user.email ?? "", + firstName: user.firstName, + lastName: user.lastName, + profilePictureUrl: user.profilePictureUrl, + }) + + const sidebarUser = { + name: profile.displayName, + email: profile.email, + avatar: profile.avatarUrl ?? "", + } return ( - +
- +
diff --git a/src/app/layout.tsx b/src/app/layout.tsx index cbc83aa..fcb9b6d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { AuthKitProvider } from "@workos-inc/authkit-nextjs/components"; import { withAuth } from "@workos-inc/authkit-nextjs"; +import { ThemeProvider } from "@/components/theme-provider"; import "./globals.css"; const geistSans = Geist({ @@ -28,12 +29,14 @@ export default async function RootLayout({ const { accessToken: _, ...initialAuth } = auth; return ( - + - {children} + + {children} + ); diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index 6c5170b..632e761 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -31,11 +31,6 @@ import { } from "@/components/ui/sidebar" const data = { - user: { - name: "shadcn", - email: "m@example.com", - avatar: "/avatars/shadcn.jpg", - }, navMain: [ { title: "Dashboard", @@ -132,7 +127,11 @@ const data = { ], } -export function AppSidebar({ ...props }: React.ComponentProps) { +interface AppSidebarProps extends React.ComponentProps { + user: { name: string; email: string; avatar: string } +} + +export function AppSidebar({ user, ...props }: AppSidebarProps) { return ( @@ -156,7 +155,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { - + ) diff --git a/src/components/nav-user.tsx b/src/components/nav-user.tsx index 7c49dc7..3073f1c 100644 --- a/src/components/nav-user.tsx +++ b/src/components/nav-user.tsx @@ -1,5 +1,6 @@ "use client" +import Link from "next/link" import { IconCreditCard, IconDotsVertical, @@ -28,6 +29,15 @@ import { SidebarMenuItem, useSidebar, } from "@/components/ui/sidebar" +import { handleSignOut } from "@/app/actions/auth" + +function getInitials(name: string): string { + const parts = name.split(" ").filter(Boolean) + if (parts.length >= 2) { + return (parts[0][0] + parts[1][0]).toUpperCase() + } + return name.slice(0, 2).toUpperCase() +} export function NavUser({ user, @@ -39,6 +49,7 @@ export function NavUser({ } }) { const { isMobile } = useSidebar() + const initials = getInitials(user.name) return ( @@ -51,7 +62,7 @@ export function NavUser({ > - CN + {initials}
{user.name} @@ -72,7 +83,7 @@ export function NavUser({
- CN + {initials}
{user.name} @@ -84,9 +95,11 @@ export function NavUser({ - - - Account + + + + Account + @@ -98,7 +111,10 @@ export function NavUser({ - + handleSignOut()} + className="cursor-pointer" + > Log out diff --git a/src/components/settings/profile-form.tsx b/src/components/settings/profile-form.tsx new file mode 100644 index 0000000..c0d70ae --- /dev/null +++ b/src/components/settings/profile-form.tsx @@ -0,0 +1,263 @@ +"use client" + +import { createContext, useContext, useTransition } from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { toast } from "sonner" +import { useRouter } from "next/navigation" +import { useTheme } from "next-themes" + +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Switch } from "@/components/ui/switch" +import { Textarea } from "@/components/ui/textarea" +import { updateProfile, type ProfileData } from "@/app/actions/profile" + +const ProfileFormContext = createContext<{ isPending: boolean } | null>(null) + +function useProfileForm() { + const ctx = useContext(ProfileFormContext) + if (!ctx) throw new Error("useProfileForm must be used within ProfileForm") + return ctx +} + +export function ProfileFormSubmitButton() { + const { isPending } = useProfileForm() + return ( + + ) +} + +const formSchema = z.object({ + displayName: z.string().max(50, "Display name too long").optional(), + firstName: z.string().max(50, "First name too long").optional(), + lastName: z.string().max(50, "Last name too long").optional(), + bio: z.string().max(500, "Bio too long").optional(), + theme: z.enum(["system", "light", "dark"]), + emailNotifications: z.boolean(), +}) + +type FormData = z.infer + +function getInitials(name: string): string { + const parts = name.split(" ").filter(Boolean) + if (parts.length >= 2) { + return (parts[0][0] + parts[1][0]).toUpperCase() + } + return name.slice(0, 2).toUpperCase() +} + +interface ProfileFormProps { + profile: ProfileData + formId?: string + children?: React.ReactNode +} + +export function ProfileForm({ profile, formId = "profile-form", children }: ProfileFormProps) { + const [isPending, startTransition] = useTransition() + const router = useRouter() + const { setTheme } = useTheme() + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + displayName: profile.displayName === profile.email.split("@")[0] + ? "" + : profile.displayName, + firstName: profile.firstName ?? "", + lastName: profile.lastName ?? "", + bio: profile.bio ?? "", + theme: profile.theme as "system" | "light" | "dark", + emailNotifications: profile.emailNotifications, + }, + }) + + const onSubmit = (data: FormData) => { + startTransition(async () => { + const result = await updateProfile(profile.id, { + displayName: data.displayName || undefined, + firstName: data.firstName || undefined, + lastName: data.lastName || undefined, + bio: data.bio || undefined, + theme: data.theme, + emailNotifications: data.emailNotifications, + }) + + if (result.success) { + toast.success("Profile updated") + router.refresh() + } else { + toast.error(result.error || "Failed to update profile") + } + }) + } + + return ( + + {children} +
+ + + Avatar + + Your avatar is managed through your authentication provider + + + +
+ + + + {getInitials(profile.displayName)} + + +
+

{profile.displayName}

+

{profile.email}

+
+
+
+
+ +
+ + + Profile Information + + Update your display name and personal details + + + +
+ + + {form.formState.errors.displayName && ( +

+ {form.formState.errors.displayName.message} +

+ )} +
+ +
+
+ + + {form.formState.errors.firstName && ( +

+ {form.formState.errors.firstName.message} +

+ )} +
+
+ + + {form.formState.errors.lastName && ( +

+ {form.formState.errors.lastName.message} +

+ )} +
+
+ +
+ +