Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions app/profile/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
'use client'

import { useQuery } from '@tanstack/react-query'
import { useSession } from 'next-auth/react'
import { redirect } from 'next/navigation'
import type { UserProfile } from '@/lib/api-schema'
import ProfileForm from '@/components/profile/ProfileForm'

const fetchProfile = async (): Promise<UserProfile> => {
const res = await fetch('/api/profile')
if (!res.ok) throw new Error('Failed to fetch profile')
return res.json()
}

export default function ProfilePage() {
// Protect the route
const { status } = useSession({
required: true,
onUnauthenticated() {
redirect('/')
},
})

const { data: profile, isLoading } = useQuery({
queryKey: ['profile'],
queryFn: fetchProfile,
})

if (status === 'loading' || isLoading) {
return (
<div className='flex h-screen items-center justify-center'>
Loading Profile...
</div>
)
}

return (
<main className='container mx-auto max-w-2xl py-12'>
<h1 className='text-3xl font-bold mb-8'>Edit Your Profile</h1>
{profile && <ProfileForm profile={profile} />}
</main>
)
}
109 changes: 109 additions & 0 deletions components/profile/ProfileForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
'use client'

import type { UserProfile } from '@/lib/api-schema'
import { profileFormSchema, type ProfileFormValues } from '@/lib/validators'
import { zodResolver } from '@hookform/resolvers/zod'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'

// This is our API function for the mutation
const updateProfile = async (data: ProfileFormValues): Promise<UserProfile> => {
const res = await fetch('/api/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) throw new Error('Server error: Failed to update profile.')
return res.json()
}

interface ProfileFormProps {
profile: UserProfile
}

export default function ProfileForm({ profile }: ProfileFormProps) {
const queryClient = useQueryClient()

const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ProfileFormValues>({
resolver: zodResolver(profileFormSchema),
defaultValues: {
bio: profile.bio || '',
},
})

const mutation = useMutation({
mutationFn: updateProfile,
// --- The Optimistic Update Logic ---
onMutate: async (newProfileData) => {
// 1. Cancel any outgoing refetches (so they don't overwrite our optimistic update)
await queryClient.cancelQueries({ queryKey: ['profile'] })

// 2. Snapshot the previous value
const previousProfile = queryClient.getQueryData<UserProfile>(['profile'])

// 3. Optimistically update to the new value
queryClient.setQueryData<UserProfile>(['profile'], (old) =>
old ? { ...old, ...newProfileData } : undefined
)

// 4. Return a context object with the snapshotted value
return { previousProfile }
},
// If the mutation fails, use the context returned from onMutate to roll back
onError: (err, newProfile, context) => {
if (context?.previousProfile) {
queryClient.setQueryData(['profile'], context.previousProfile)
// You can also show a toast notification here
alert('Update failed! Your changes have been reverted.')
}
},
// Always refetch after error or success to ensure server state
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['profile'] })
},
})

const onSubmit = (data: ProfileFormValues) => {
mutation.mutate(data)
}

// We get the latest profile data directly from the query cache
const currentProfile = queryClient.getQueryData<UserProfile>(['profile'])

return (
<form onSubmit={handleSubmit(onSubmit)} className='space-y-6'>
<div>
<label
htmlFor='bio'
className='block text-sm font-medium text-gray-700 dark:text-gray-300'
>
Your Bio
</label>
<textarea
id='bio'
{...register('bio')}
rows={4}
className='mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600'
defaultValue={currentProfile?.bio}
/>
{errors.bio && (
<p className='mt-2 text-sm text-red-600'>{errors.bio.message}</p>
)}
</div>

<div>
<button
type='submit'
disabled={isSubmitting || mutation.isPending}
className='inline-flex justify-center rounded-md border border-transparent bg-indigo-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:bg-gray-400'
>
{mutation.isPending ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
)
}
4 changes: 4 additions & 0 deletions lib/api-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,7 @@ export type Message = {
timestamp: string
status?: 'sending' | 'failed'
}

export type ProfileUpdateRequest = {
bio: string
}
34 changes: 26 additions & 8 deletions lib/msw/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const FAKE_MATCHES: Match[] = Array.from({ length: 5 }, () => ({
id: faker.string.uuid(),
user: createFakeUser(),
}))
const FAKE_LOGGED_IN_USER_PROFILE = createFakeUser()

const FAKE_MESSAGES: Record<string, Message[]> = FAKE_MATCHES.reduce(
(acc, match) => {
Expand Down Expand Up @@ -116,20 +117,14 @@ export const handlers = [
return HttpResponse.json(FAKE_MATCHES)
}),

http.get('/api/matches/:matchId/messages', async ({ params }) => {
const { matchId } = params
await delay(400)
const messages = FAKE_MESSAGES[matchId as string] || []
return HttpResponse.json(messages)
}),

http.post('/api/matches/:matchId/messages', async ({ request, params }) => {
const { matchId } = params
const { text, tempId } = (await request.json()) as {
text: string
tempId: string
}

// Simulate network failure 25% of the time to test rollbacks
if (Math.random() < 0.25) {
await delay(1000)
return new HttpResponse(null, {
Expand All @@ -138,6 +133,7 @@ export const handlers = [
})
}

// This is the correct logic for sending a new message
const newMessage: Message = {
id: faker.string.uuid(),
tempId,
Expand All @@ -153,5 +149,27 @@ export const handlers = [

await delay(500)
return HttpResponse.json(newMessage)
}),
}), // <-- Correctly closes the POST handler

// Handler for getting the current user's profile
http.get('/api/profile', async () => {
await delay(200)
return HttpResponse.json(FAKE_LOGGED_IN_USER_PROFILE)
}), // <-- Correctly closes the GET handler

// Handler for updating the user's profile
http.patch('/api/profile', async ({ request }) => {
const { bio } = (await request.json()) as { bio: string } // Assume this is the shape

// Simulate network failure 30% of the time to test rollbacks
if (Math.random() < 0.3) {
await delay(1500)
return new HttpResponse(null, { status: 500, statusText: 'Server Error' })
}

// This is the correct logic for updating the profile
FAKE_LOGGED_IN_USER_PROFILE.bio = bio
await delay(1000)
return HttpResponse.json(FAKE_LOGGED_IN_USER_PROFILE)
}), // <-- Correctly closes the PATCH handler
]
10 changes: 10 additions & 0 deletions lib/validators.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { z } from 'zod'

export const profileFormSchema = z.object({
bio: z
.string()
.min(10, { message: 'Bio must be at least 10 characters long.' })
.max(160, { message: 'Bio must not be longer than 160 characters.' }),
})

export type ProfileFormValues = z.infer<typeof profileFormSchema>
46 changes: 46 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@
"prepare": "husky"
},
"dependencies": {
"@hookform/resolvers": "^5.2.1",
"@tanstack/react-query": "^5.85.5",
"framer-motion": "^12.23.12",
"next": "15.5.0",
"next-auth": "^4.24.11",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.62.0",
"zod": "^4.1.5",
"zustand": "^5.0.8"
},
"devDependencies": {
Expand Down
1 change: 1 addition & 0 deletions public/mockServiceWorker.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable */
/* tslint:disable */

/**
Expand Down
Loading