From 3e1ef4b64e590857fa5d464f7f2509e64bf9b5cb Mon Sep 17 00:00:00 2001 From: Assad Isah Date: Sun, 31 Aug 2025 11:41:20 +0100 Subject: [PATCH] feat: implement match modal and swipe functionality with user profiles --- app/api/auth/[...nextauth]/route.ts | 9 +- app/page.tsx | 18 ++- components/shared/MatchModal.tsx | 62 +++++++++ components/shared/MusicCard.tsx | 30 ++++ components/shared/SwipeDeck.tsx | 155 +++++++++++++++++++++ package-lock.json | 209 ++++++++++++++++++++-------- package.json | 3 +- stores/auth-store.ts | 20 +++ 8 files changed, 438 insertions(+), 68 deletions(-) create mode 100644 components/shared/MatchModal.tsx create mode 100644 components/shared/MusicCard.tsx create mode 100644 components/shared/SwipeDeck.tsx diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index 485920b..48d6303 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -1,8 +1,7 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import NextAuth from 'next-auth' import SpotifyProvider from 'next-auth/providers/spotify' -import type { JWT } from 'next-auth/jwt' -import type { Account, User } from 'next-auth' +import { JWT } from 'next-auth/jwt' +import { Account, User } from 'next-auth' const SPOTIFY_SCOPES = [ 'user-read-email', @@ -36,7 +35,7 @@ async function refreshAccessToken(token: JWT): Promise { ...token, accessToken: refreshedTokens.access_token, accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000, - refreshToken: refreshedTokens.refresh_token ?? token.refreshToken, // Fall back to old refresh token + refreshToken: refreshedTokens.refresh_token ?? token.refreshToken, } } catch (error) { console.error('Error refreshing access token', error) @@ -55,6 +54,7 @@ export const authOptions = { authorization: `https://accounts.spotify.com/authorize?scope=${SPOTIFY_SCOPES}`, }), ], + secret: process.env.NEXTAUTH_SECRET, callbacks: { async jwt({ token, @@ -90,7 +90,6 @@ export const authOptions = { }, } -//@ts-expect-error ??? const handler = NextAuth(authOptions) export { handler as GET, handler as POST } diff --git a/app/page.tsx b/app/page.tsx index 57019f7..5bd625d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,12 +1,22 @@ +'use client' + +import MatchModal from '@/components/shared/MatchModal' import SpotifyConnect from '@/components/shared/SpotifyConnect' +import SwipeDeck from '@/components/shared/SwipeDeck' +import { useSession } from 'next-auth/react' export default function Home() { + const { data: session, status } = useSession() + return ( -
-
-

Chordially

- +
+ + +
+

Chordially

+ + {status === 'authenticated' ? : }
) } diff --git a/components/shared/MatchModal.tsx b/components/shared/MatchModal.tsx new file mode 100644 index 0000000..c01e63e --- /dev/null +++ b/components/shared/MatchModal.tsx @@ -0,0 +1,62 @@ +'use client' + +import { useAuthStore } from '@/stores/auth-store' +import { AnimatePresence, motion } from 'framer-motion' +import Image from 'next/image' + +export default function MatchModal() { + const { isMatchModalOpen, matchedUser, closeMatchModal } = useAuthStore() + + return ( + + {isMatchModalOpen && matchedUser && ( + + e.stopPropagation()} + > +

It's a Chord!

+

+ You and {matchedUser.name} have matched! +

+
+ Your avatar + {matchedUser.name} +
+ +
+
+ )} +
+ ) +} diff --git a/components/shared/MusicCard.tsx b/components/shared/MusicCard.tsx new file mode 100644 index 0000000..ec9aff7 --- /dev/null +++ b/components/shared/MusicCard.tsx @@ -0,0 +1,30 @@ +import type { UserProfile } from '@/lib/api-schema' +import Image from 'next/image' + +interface MusicCardProps { + user: UserProfile +} + +export default function MusicCard({ user }: MusicCardProps) { + return ( +
+ {user.name} +
+
+

+ {user.name}, {user.age} +

+

Anthem:

+

+ {user.musicProfile.anthem.title} - {user.musicProfile.anthem.artist} +

+
+
+ ) +} diff --git a/components/shared/SwipeDeck.tsx b/components/shared/SwipeDeck.tsx new file mode 100644 index 0000000..b9a71df --- /dev/null +++ b/components/shared/SwipeDeck.tsx @@ -0,0 +1,155 @@ +'use client' + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import type { + PotentialMatchesResponse, + SwipeRequest, + SwipeResponse, + UserProfile, +} from '@/lib/api-schema' +import { useState } from 'react' +import { + motion, + useMotionValue, + useTransform, + AnimatePresence, +} from 'framer-motion' +import MusicCard from './MusicCard' +import { useAuthStore } from '@/stores/auth-store' + +const fetchPotentialMatches = async (): Promise => { + const res = await fetch('/api/users/potential-matches') + if (!res.ok) throw new Error('Network response was not ok') + const data: PotentialMatchesResponse = await res.json() + return data.users +} + +const postSwipe = async ( + swipe: SwipeRequest +): Promise<{ res: SwipeResponse; swipedUser: UserProfile }> => { + const swipedUser = (swipe as any).swipedUser + const res = await fetch('/api/swipes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId: swipe.userId, direction: swipe.direction }), + }) + if (!res.ok) throw new Error('Swipe failed') + return { res: await res.json(), swipedUser } +} + +export default function SwipeDeck() { + const [users, setUsers] = useState([]) + const queryClient = useQueryClient() + const openMatchModal = useAuthStore((state) => state.openMatchModal) + + const { isLoading, isError } = useQuery({ + queryKey: ['potentialMatches'], + queryFn: fetchPotentialMatches, + onSuccess: (data) => { + setUsers(data) + }, + }) + + const swipeMutation = useMutation({ + mutationFn: postSwipe, + onSuccess: ({ res, swipedUser }) => { + if (res.isMatch) { + openMatchModal(swipedUser) + } + + queryClient.prefetchQuery({ + queryKey: ['potentialMatches'], + queryFn: fetchPotentialMatches, + }) + }, + onError: (error) => { + console.error('Swipe mutation failed:', error) + }, + }) + + const onSwipe = (direction: 'left' | 'right') => { + if (users.length === 0) return + + const swipedUser = users[0] + swipeMutation.mutate({ + userId: swipedUser.id, + direction, + swipedUser: swipedUser as any, + }) + + setUsers((prevUsers) => prevUsers.slice(1)) + + if (users.length <= 3) { + queryClient.invalidateQueries({ queryKey: ['potentialMatches'] }) + } + } + + if (isLoading) + return
Finding potential matches...
+ if (isError) + return
Something went wrong.
+ + return ( +
+ + {users.length > 0 ? ( + users + .map((user, index) => { + if (index === 0) { + return ( + + ) + } + return ( + + + + ) + }) + .reverse() + ) : ( +
+ No more users to show. Come back later! +
+ )} +
+
+ ) +} + +function DraggableCard({ + user, + onSwipe, +}: { + user: UserProfile + onSwipe: (direction: 'left' | 'right') => void +}) { + const x = useMotionValue(0) + const rotate = useTransform(x, [-200, 200], [-30, 30]) + const opacity = useTransform(x, [-200, -100, 0, 100, 200], [0, 1, 1, 1, 0]) + + return ( + { + if (info.offset.x > 100) { + onSwipe('right') + } else if (info.offset.x < -100) { + onSwipe('left') + } + }} + > + + + ) +} diff --git a/package-lock.json b/package-lock.json index 079fe88..a39a1bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,9 @@ "version": "0.1.0", "dependencies": { "@tanstack/react-query": "^5.85.5", + "framer-motion": "^12.23.12", "next": "15.5.0", - "next-auth": "^5.0.0-beta.29", + "next-auth": "^4.24.11", "react": "19.1.0", "react-dom": "19.1.0", "zustand": "^5.0.8" @@ -45,33 +46,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@auth/core": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.40.0.tgz", - "integrity": "sha512-n53uJE0RH5SqZ7N1xZoMKekbHfQgjd0sAEyUbE+IYJnmuQkbvuZnXItCU7d+i7Fj8VGOgqvNO7Mw4YfBTlZeQw==", - "license": "ISC", - "dependencies": { - "@panva/hkdf": "^1.2.1", - "jose": "^6.0.6", - "oauth4webapi": "^3.3.0", - "preact": "10.24.3", - "preact-render-to-string": "6.5.11" - }, - "peerDependencies": { - "@simplewebauthn/browser": "^9.0.1", - "@simplewebauthn/server": "^9.0.2", - "nodemailer": "^6.8.0" - }, - "peerDependenciesMeta": { - "@simplewebauthn/browser": { - "optional": true - }, - "@simplewebauthn/server": { - "optional": true - }, - "nodemailer": { - "optional": true - } + "node_modules/@babel/runtime": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz", + "integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" } }, "node_modules/@bundled-es-modules/cookie": { @@ -2711,7 +2692,6 @@ "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -3703,6 +3683,33 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/framer-motion": { + "version": "12.23.12", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.12.tgz", + "integrity": "sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.23.12", + "motion-utils": "^12.23.6", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -4541,9 +4548,9 @@ } }, "node_modules/jose": { - "version": "6.0.13", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.0.13.tgz", - "integrity": "sha512-Yms4GpbmdANamS51kKK6w4hRlKx8KTxbWyAAKT/MhUMtqbIqh5mb2HjhTNUbk7TFL8/MBB5zWSDohL7ed4k/UA==", + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -4938,6 +4945,24 @@ "loose-envify": "cli.js" } }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lru-cache/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/magic-string": { "version": "0.30.18", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.18.tgz", @@ -5044,6 +5069,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/motion-dom": { + "version": "12.23.12", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.12.tgz", + "integrity": "sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.23.6" + } + }, + "node_modules/motion-utils": { + "version": "12.23.6", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", + "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5200,25 +5240,30 @@ } }, "node_modules/next-auth": { - "version": "5.0.0-beta.29", - "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.29.tgz", - "integrity": "sha512-Ukpnuk3NMc/LiOl32njZPySk7pABEzbjhMUFd5/n10I0ZNC7NCuVv8IY2JgbDek2t/PUOifQEoUiOOTLy4os5A==", + "version": "4.24.11", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.11.tgz", + "integrity": "sha512-pCFXzIDQX7xmHFs4KVH4luCjaCbuPRtZ9oBUjUhOk84mZ9WVPf94n87TxYI4rSRf9HmfHEF8Yep3JrYDVOo3Cw==", "license": "ISC", "dependencies": { - "@auth/core": "0.40.0" + "@babel/runtime": "^7.20.13", + "@panva/hkdf": "^1.0.2", + "cookie": "^0.7.0", + "jose": "^4.15.5", + "oauth": "^0.9.15", + "openid-client": "^5.4.0", + "preact": "^10.6.3", + "preact-render-to-string": "^5.1.19", + "uuid": "^8.3.2" }, "peerDependencies": { - "@simplewebauthn/browser": "^9.0.1", - "@simplewebauthn/server": "^9.0.2", - "next": "^14.0.0-0 || ^15.0.0-0", + "@auth/core": "0.34.2", + "next": "^12.2.5 || ^13 || ^14 || ^15", "nodemailer": "^6.6.5", - "react": "^18.2.0 || ^19.0.0-0" + "react": "^17.0.2 || ^18 || ^19", + "react-dom": "^17.0.2 || ^18 || ^19" }, "peerDependenciesMeta": { - "@simplewebauthn/browser": { - "optional": true - }, - "@simplewebauthn/server": { + "@auth/core": { "optional": true }, "nodemailer": { @@ -5254,14 +5299,11 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/oauth4webapi": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.7.0.tgz", - "integrity": "sha512-Q52wTPUWPsVLVVmTViXPQFMW2h2xv2jnDGxypjpelCFKaOjLsm7AxYuOk1oQgFm95VNDbuggasu9htXrz6XwKw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } + "node_modules/oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==", + "license": "MIT" }, "node_modules/object-assign": { "version": "4.1.1", @@ -5273,6 +5315,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -5386,6 +5437,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oidc-token-hash": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.1.1.tgz", + "integrity": "sha512-D7EmwxJV6DsEB6vOFLrBM2OzsVgQzgPWyHlV2OOAVj772n+WTXpudC9e9u5BVKQnYwaD30Ivhi9b+4UeBcGu9g==", + "license": "MIT", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, + "node_modules/openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "license": "MIT", + "dependencies": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5567,9 +5642,9 @@ } }, "node_modules/preact": { - "version": "10.24.3", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", - "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", + "version": "10.27.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.27.1.tgz", + "integrity": "sha512-V79raXEWch/rbqoNc7nT9E4ep7lu+mI3+sBmfRD4i1M73R3WLYcCtdI0ibxGVf4eQL8ZIz2nFacqEC+rmnOORQ==", "license": "MIT", "funding": { "type": "opencollective", @@ -5577,10 +5652,13 @@ } }, "node_modules/preact-render-to-string": { - "version": "6.5.11", - "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", - "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz", + "integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==", "license": "MIT", + "dependencies": { + "pretty-format": "^3.8.0" + }, "peerDependencies": { "preact": ">=10" } @@ -5611,6 +5689,12 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-format": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -6752,6 +6836,15 @@ "requires-port": "^1.0.0" } }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index c696882..449e0b8 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,9 @@ }, "dependencies": { "@tanstack/react-query": "^5.85.5", + "framer-motion": "^12.23.12", "next": "15.5.0", - "next-auth": "^5.0.0-beta.29", + "next-auth": "^4.24.11", "react": "19.1.0", "react-dom": "19.1.0", "zustand": "^5.0.8" diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 15fae8a..1715d0c 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -5,14 +5,22 @@ interface AuthState { user: UserProfile | null token: string | null isAuthenticated: boolean + + isMatchModalOpen: boolean + matchedUser: UserProfile | null login: (user: UserProfile, token: string) => void logout: () => void + + openMatchModal: (user: UserProfile) => void + closeMatchModal: () => void } export const useAuthStore = create((set) => ({ user: null, token: null, isAuthenticated: false, + isMatchModalOpen: false, + matchedUser: null, login: (user, token) => set({ @@ -27,4 +35,16 @@ export const useAuthStore = create((set) => ({ token: null, isAuthenticated: false, }), + + openMatchModal: (user) => + set({ + isMatchModalOpen: true, + matchedUser: user, + }), + + closeMatchModal: () => + set({ + isMatchModalOpen: false, + matchedUser: null, + }), }))