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>
)
}
127 changes: 127 additions & 0 deletions components/charts/CompatibilityChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
'use client'

import type { CompatibilityDetails } from '@/lib/api-schema'
import { Group } from '@visx/group'
import { scaleLinear, scalePoint } from '@visx/scale'
import { Line, Polygon } from '@visx/shape'

Check warning on line 6 in components/charts/CompatibilityChart.tsx

View workflow job for this annotation

GitHub Actions / build-and-test

'Polygon' is defined but never used

Check warning on line 6 in components/charts/CompatibilityChart.tsx

View workflow job for this annotation

GitHub Actions / build-and-test

'Polygon' is defined but never used
import { Text } from '@visx/text'
import { motion } from 'framer-motion'
import { useMemo } from 'react'

interface CompatibilityChartProps {
details: CompatibilityDetails
width: number
height: number
}

// Helper function to convert polar coordinates to cartesian
function polarToCartesian(angle: number, radius: number) {
return { x: radius * Math.cos(angle), y: radius * Math.sin(angle) }
}

export default function CompatibilityChart({
details,
width,
height,
}: CompatibilityChartProps) {
const data = useMemo(
() => [
{ label: 'Genre', score: details.genre.score },
{ label: 'Era', score: details.era.score },
{ label: 'Artist', score: details.artist.score },
{ label: 'Obscurity', score: details.obscurity.score },
],
[details]
)

const margin = { top: 60, right: 60, bottom: 60, left: 60 }
const centerX = width / 2
const centerY = height / 2
const radius =
Math.min(width, height) / 2 - Math.max(...Object.values(margin))

// Define scales
const angleScale = scalePoint({
domain: data.map((d) => d.label),
range: [0, 2 * Math.PI],
})

const radiusScale = scaleLinear({
domain: [0, 100],
range: [0, radius],
})

// Generate points for the polygon
const polygonPoints = data.map((d) => {
const angle = angleScale(d.label)! - Math.PI / 2
const r = radiusScale(d.score)
const { x, y } = polarToCartesian(angle, r)
return { x: centerX + x, y: centerY + y }
})

const pointsString = polygonPoints.map((p) => `${p.x},${p.y}`).join(' ')

// Levels for the radar grid
const levels = [25, 50, 75, 100]

return (
<svg width={width} height={height}>
<Group top={centerY} left={centerX}>
{/* Radar grid lines */}
{levels.map((level, i) => (
<circle
key={i}
r={radiusScale(level)}
fill='none'
stroke='white'
strokeOpacity={0.3}
strokeWidth={1}
/>
))}
{/* Axis lines and labels */}
{data.map((d, i) => {
const angle = angleScale(d.label)! - Math.PI / 2
const point = polarToCartesian(angle, radius + 10)
return (
<Group key={i}>
<Line
from={{ x: 0, y: 0 }}
to={point}
stroke='white'
strokeOpacity={0.3}
/>
<Text
x={point.x}
y={point.y}
verticalAnchor='middle'
textAnchor='middle'
fill='white'
fontSize={12}
fontWeight='bold'
>
{d.label}
</Text>
</Group>
)
})}
{/* Data polygon with animation */}
<motion.polygon
points={pointsString}
fill='rgba(255, 255, 255, 0.4)'
stroke='white'
strokeWidth={2}
initial={{
points: data.map(() => `${centerX},${centerY}`).join(' '),
}}
animate={{
points: pointsString,
}}
transition={{
duration: 0.8,
ease: 'easeInOut',
}}
/>
</Group>
</svg>
)
}
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>
)
}
41 changes: 29 additions & 12 deletions components/shared/MatchModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,54 +3,71 @@
import { useAuthStore } from '@/stores/auth-store'
import { AnimatePresence, motion } from 'framer-motion'
import Image from 'next/image'
import CompatibilityChart from '../charts/CompatibilityChart' // Import the chart

export default function MatchModal() {
const { isMatchModalOpen, matchedUser, closeMatchModal } = useAuthStore()
// Get the new compatibilityDetails from the store
const {
isMatchModalOpen,
matchedUser,
compatibilityDetails,
closeMatchModal,
} = useAuthStore()

return (
<AnimatePresence>
{isMatchModalOpen && matchedUser && (
{isMatchModalOpen && matchedUser && compatibilityDetails && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className='fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50'
className='fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50 p-4'
onClick={closeMatchModal}
>
<motion.div
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.5, opacity: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
className='bg-gradient-to-br from-pink-500 to-purple-600 p-8 rounded-2xl text-white text-center shadow-2xl'
className='bg-gradient-to-br from-pink-500 to-purple-600 p-6 rounded-2xl text-white text-center shadow-2xl w-full max-w-md'
onClick={(e) => e.stopPropagation()}
>
<h1 className='text-4xl font-bold mb-4'>It&apos;s a Chord!</h1>
<p className='text-lg mb-6'>
You and {matchedUser.name} have matched!
<h1 className='text-4xl font-bold'>It&apso;s a Chord!</h1>
<p className='text-lg mb-4'>
You and {matchedUser.name} have a strong musical connection.
</p>

{/* Render the chart */}
<div className='my-4'>
<CompatibilityChart
details={compatibilityDetails}
width={300}
height={300}
/>
</div>

<div className='flex justify-center items-center space-x-4'>
<Image
src={
useAuthStore.getState().user?.avatarUrl ||
'/default-avatar.png'
}
alt='Your avatar'
width={100}
height={100}
width={80}
height={80}
className='rounded-full border-4 border-white'
/>
<Image
src={matchedUser.avatarUrl}
alt={matchedUser.name}
width={100}
height={100}
width={80}
height={80}
className='rounded-full border-4 border-white'
/>
</div>
<button
onClick={closeMatchModal}
className='mt-8 bg-white text-purple-600 font-bold py-2 px-6 rounded-full'
className='mt-6 bg-white text-purple-600 font-bold py-2 px-6 rounded-full'
>
Keep Swiping
</button>
Expand Down
6 changes: 3 additions & 3 deletions components/shared/SwipeDeck.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,10 @@ export default function SwipeDeck() {
const swipeMutation = useMutation({
mutationFn: postSwipe,
onSuccess: ({ res, swipedUser }) => {
if (res.isMatch) {
openMatchModal(swipedUser)
// Check for match and the new details payload
if (res.isMatch && res.compatibilityDetails) {
openMatchModal(swipedUser, res.compatibilityDetails) // Pass details to the store
}

queryClient.prefetchQuery({
queryKey: ['potentialMatches'],
queryFn: fetchPotentialMatches,
Expand Down
Loading
Loading