diff --git a/app/chat/page.tsx b/app/chat/page.tsx
new file mode 100644
index 0000000..7731110
--- /dev/null
+++ b/app/chat/page.tsx
@@ -0,0 +1,38 @@
+'use client'
+
+import ChatWindow from '@/components/chat/ChatWindow'
+import ConversationList from '@/components/chat/ConversationList'
+import { useSession } from 'next-auth/react'
+import { redirect } from 'next/navigation'
+import OfflineQueueProcessor from '@/components/chat/OfflineQueueProcessor'
+
+export default function ChatPage() {
+ const { status } = useSession({
+ required: true,
+ onUnauthenticated() {
+ redirect('/')
+ },
+ })
+
+ if (status === 'loading') {
+ return (
+
+ Loading...
+
+ )
+ }
+
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/app/page.tsx b/app/page.tsx
index 5bd625d..83570fb 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -4,6 +4,7 @@ import MatchModal from '@/components/shared/MatchModal'
import SpotifyConnect from '@/components/shared/SpotifyConnect'
import SwipeDeck from '@/components/shared/SwipeDeck'
import { useSession } from 'next-auth/react'
+import Link from 'next/link'
export default function Home() {
const { data: session, status } = useSession()
@@ -11,11 +12,17 @@ export default function Home() {
return (
-
Chordially
+ {status === 'authenticated' && (
+
+ Go to Chat
+
+ )}
-
{status === 'authenticated' ? : }
)
diff --git a/components/chat/ChatWindow.tsx b/components/chat/ChatWindow.tsx
new file mode 100644
index 0000000..12e09a2
--- /dev/null
+++ b/components/chat/ChatWindow.tsx
@@ -0,0 +1,68 @@
+'use client'
+
+import type { Message } from '@/lib/api-schema'
+import { useAuthStore } from '@/stores/auth-store'
+import { useQuery } from '@tanstack/react-query'
+import { useEffect, useRef } from 'react'
+import MessageBubble from './MessageBubble'
+import MessageInput from './MessageInput'
+
+const fetchMessages = async (matchId: string): Promise => {
+ const res = await fetch(`/api/matches/${matchId}/messages`)
+ if (!res.ok) throw new Error('Failed to fetch messages')
+ return res.json()
+}
+
+export default function ChatWindow() {
+ const { selectedMatchId } = useAuthStore()
+ const messageQueue = useAuthStore((state) => state.messageQueue)
+ const messagesEndRef = useRef(null)
+
+ const { data: messages, isLoading } = useQuery({
+ queryKey: ['messages', selectedMatchId],
+ queryFn: () => fetchMessages(selectedMatchId!),
+ enabled: !!selectedMatchId,
+ refetchInterval: 3000,
+ })
+
+ useEffect(() => {
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
+ }, [messages, messageQueue])
+
+ if (!selectedMatchId) {
+ return (
+
+ Select a conversation to start chatting
+
+ )
+ }
+
+ if (isLoading)
+ return (
+
+ Loading messages...
+
+ )
+
+ // Combine messages from the server and any pending messages from the offline queue
+ const allMessages = [
+ ...(messages || []),
+ ...messageQueue.filter((m) => m.matchId === selectedMatchId),
+ ].sort(
+ (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
+ )
+
+ return (
+
+
+ {allMessages.map((msg) => (
+
+ ))}
+
+
+
+
+
+
+ )
+}
diff --git a/components/chat/ConversationList.tsx b/components/chat/ConversationList.tsx
new file mode 100644
index 0000000..18adf29
--- /dev/null
+++ b/components/chat/ConversationList.tsx
@@ -0,0 +1,51 @@
+'use client'
+
+import type { Match } from '@/lib/api-schema'
+import { useAuthStore } from '@/stores/auth-store'
+import { useQuery } from '@tanstack/react-query'
+import Image from 'next/image'
+
+const fetchMatches = async (): Promise => {
+ const res = await fetch('/api/matches')
+ if (!res.ok) throw new Error('Failed to fetch matches')
+ return res.json()
+}
+
+export default function ConversationList() {
+ const { data: matches, isLoading } = useQuery({
+ queryKey: ['matches'],
+ queryFn: fetchMatches,
+ })
+ const { selectedMatchId, selectMatch } = useAuthStore()
+
+ if (isLoading) return Loading conversations...
+
+ return (
+
+
+ Matches
+
+
+ {matches?.map((match) => (
+ - selectMatch(match.id)}
+ className={`flex items-center p-4 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-800 ${selectedMatchId === match.id ? 'bg-gray-300 dark:bg-gray-700' : ''}`}
+ >
+
+
+
{match.user.name}
+
Start chatting...
+
+
+ ))}
+
+
+ )
+}
diff --git a/components/chat/MessageBubble.tsx b/components/chat/MessageBubble.tsx
new file mode 100644
index 0000000..37e94ed
--- /dev/null
+++ b/components/chat/MessageBubble.tsx
@@ -0,0 +1,22 @@
+import type { Message } from '@/lib/api-schema'
+
+export default function MessageBubble({ message }: { message: Message }) {
+ const isMe = message.senderId === 'me'
+ const alignment = isMe ? 'justify-end' : 'justify-start'
+ const colors = isMe
+ ? 'bg-blue-500 text-white'
+ : 'bg-gray-300 dark:bg-gray-600 text-black dark:text-white'
+
+ return (
+
+
+
{message.text}
+ {message.status && (
+
+ {message.status}...
+
+ )}
+
+
+ )
+}
diff --git a/components/chat/MessageInput.tsx b/components/chat/MessageInput.tsx
new file mode 100644
index 0000000..08b0596
--- /dev/null
+++ b/components/chat/MessageInput.tsx
@@ -0,0 +1,95 @@
+'use client'
+
+import type { Message } from '@/lib/api-schema'
+import { useAuthStore } from '@/stores/auth-store'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { useState } from 'react'
+
+const postMessage = async ({
+ matchId,
+ text,
+ tempId,
+}: {
+ matchId: string
+ text: string
+ tempId: string
+}) => {
+ const res = await fetch(`/api/matches/${matchId}/messages`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ text, tempId }),
+ })
+ if (!res.ok) throw new Error('Failed to send message')
+ return res.json()
+}
+
+export default function MessageInput({ matchId }: { matchId: string }) {
+ const [text, setText] = useState('')
+ const queryClient = useQueryClient()
+ const { addMessageToQueue, removeMessageFromQueue } = useAuthStore()
+
+ const mutation = useMutation({
+ mutationFn: postMessage,
+ onSuccess: (newMessage) => {
+ if (newMessage.tempId) {
+ removeMessageFromQueue(newMessage.tempId)
+ }
+
+ queryClient.invalidateQueries({ queryKey: ['messages', matchId] })
+ },
+ onError: (error, variables) => {
+ console.error('Message failed to send:', error)
+
+ const failedMessage: Message = {
+ id: variables.tempId,
+ tempId: variables.tempId,
+ matchId: variables.matchId,
+ senderId: 'me',
+ text: variables.text,
+ timestamp: new Date().toISOString(),
+ }
+ addMessageToQueue(failedMessage)
+ },
+ })
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault()
+ if (!text.trim()) return
+
+ const tempId = crypto.randomUUID()
+
+ queryClient.setQueryData(
+ ['messages', matchId],
+ (oldData: Message[] | undefined) => {
+ const optimisticMessage: Message = {
+ id: tempId,
+ tempId,
+ matchId,
+ senderId: 'me',
+ text,
+ timestamp: new Date().toISOString(),
+ status: 'sending',
+ }
+ return oldData ? [...oldData, optimisticMessage] : [optimisticMessage]
+ }
+ )
+
+ mutation.mutate({ matchId, text, tempId })
+ setText('')
+ }
+
+ return (
+
+ )
+}
diff --git a/components/chat/OfflineQueueProcessor.tsx b/components/chat/OfflineQueueProcessor.tsx
new file mode 100644
index 0000000..d0306f8
--- /dev/null
+++ b/components/chat/OfflineQueueProcessor.tsx
@@ -0,0 +1,61 @@
+'use client'
+
+import { useAuthStore } from '@/stores/auth-store'
+import { useIsOnline } from '@/hooks/useIsOnline'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { useEffect } from 'react'
+
+//
+const postMessage = async (message: {
+ matchId: string
+ text: string
+ tempId: string
+}) => {
+ const res = await fetch(`/api/matches/${message.matchId}/messages`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ text: message.text, tempId: message.tempId }),
+ })
+ if (!res.ok) throw new Error('Failed to send message')
+ return res.json()
+}
+
+export default function OfflineQueueProcessor() {
+ const isOnline = useIsOnline()
+ const queryClient = useQueryClient()
+ const { messageQueue, removeMessageFromQueue, updateMessageStatusInQueue } =
+ useAuthStore()
+
+ const mutation = useMutation({
+ mutationFn: postMessage,
+ onSuccess: (sentMessage) => {
+ if (sentMessage.tempId) {
+ removeMessageFromQueue(sentMessage.tempId)
+ }
+ queryClient.invalidateQueries({
+ queryKey: ['messages', sentMessage.matchId],
+ })
+ },
+ onError: (error, variables) => {
+ console.error('Queue processor failed to send:', error)
+ updateMessageStatusInQueue(variables.tempId, 'failed')
+ },
+ })
+
+ useEffect(() => {
+ if (isOnline && messageQueue.length > 0) {
+ messageQueue.forEach((message) => {
+ if (message.status === 'failed' && message.tempId) {
+ updateMessageStatusInQueue(message.tempId, 'sending')
+ mutation.mutate({
+ matchId: message.matchId,
+ text: message.text,
+ tempId: message.tempId,
+ })
+ }
+ })
+ }
+ }, [isOnline, messageQueue, mutation, updateMessageStatusInQueue])
+
+ return null
+}
diff --git a/components/shared/SpotifyConnect.tsx b/components/shared/SpotifyConnect.tsx
index 4ad203b..42de412 100644
--- a/components/shared/SpotifyConnect.tsx
+++ b/components/shared/SpotifyConnect.tsx
@@ -1,3 +1,4 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
'use client'
import { useSession, signIn, signOut } from 'next-auth/react'
@@ -45,7 +46,7 @@ export default function SpotifyConnect() {
Your Top 5 Artists:
{topArtists ? (
- {topArtists.slice(0, 5).map((artist) => (
+ {topArtists.slice(0, 5).map((artist: any) => (
-
{artist.name}
diff --git a/components/shared/SwipeDeck.tsx b/components/shared/SwipeDeck.tsx
index b9a71df..03d57dc 100644
--- a/components/shared/SwipeDeck.tsx
+++ b/components/shared/SwipeDeck.tsx
@@ -1,3 +1,4 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
'use client'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
@@ -45,7 +46,7 @@ export default function SwipeDeck() {
const { isLoading, isError } = useQuery({
queryKey: ['potentialMatches'],
queryFn: fetchPotentialMatches,
- onSuccess: (data) => {
+ onSuccess: (data: UserProfile[]) => {
setUsers(data)
},
})
diff --git a/hooks/useIsOnline.ts b/hooks/useIsOnline.ts
new file mode 100644
index 0000000..4645f12
--- /dev/null
+++ b/hooks/useIsOnline.ts
@@ -0,0 +1,18 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { onlineManager } from '@tanstack/react-query'
+
+export function useIsOnline() {
+ const [isOnline, setIsOnline] = useState(true)
+
+ useEffect(() => {
+ setIsOnline(onlineManager.isOnline())
+
+ return onlineManager.subscribe(() => {
+ setIsOnline(onlineManager.isOnline())
+ })
+ }, [])
+
+ return isOnline
+}
diff --git a/lib/api-schema.ts b/lib/api-schema.ts
index 21b48b2..4a6a986 100644
--- a/lib/api-schema.ts
+++ b/lib/api-schema.ts
@@ -46,3 +46,18 @@ export type LoginResponse = {
token: string
user: UserProfile
}
+
+export type Match = {
+ id: string
+ user: UserProfile
+}
+
+export type Message = {
+ id: string
+ tempId?: string
+ matchId: string
+ senderId: string
+ text: string
+ timestamp: string
+ status?: 'sending' | 'failed'
+}
diff --git a/lib/msw/handlers.ts b/lib/msw/handlers.ts
index 4492f6d..3cc01a1 100644
--- a/lib/msw/handlers.ts
+++ b/lib/msw/handlers.ts
@@ -1,4 +1,4 @@
-import { http, HttpResponse, delay } from 'msw'
+import { http, HttpResponse, delay, Match, Message } from 'msw'
import { faker } from '@faker-js/faker'
import type {
UserProfile,
@@ -44,6 +44,31 @@ const createFakeUser = (): UserProfile => {
}
}
+const FAKE_MATCHES: Match[] = Array.from({ length: 5 }, () => ({
+ id: faker.string.uuid(),
+ user: createFakeUser(),
+}))
+
+const FAKE_MESSAGES: Record = FAKE_MATCHES.reduce(
+ (acc, match) => {
+ acc[match.id] = Array.from(
+ { length: faker.number.int({ min: 5, max: 15 }) },
+ (_, i) => {
+ const isMe = i % 2 === 0
+ return {
+ id: faker.string.uuid(),
+ matchId: match.id,
+ senderId: isMe ? 'me' : match.user.id,
+ text: faker.lorem.sentence(),
+ timestamp: faker.date.past().toISOString(),
+ }
+ }
+ )
+ return acc
+ },
+ {} as Record
+)
+
export const handlers = [
http.post('/api/auth/login', async () => {
await delay(300)
@@ -85,4 +110,48 @@ export const handlers = [
return HttpResponse.json(response)
}),
+
+ http.get('/api/matches', async () => {
+ await delay(300)
+ 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
+ }
+
+ if (Math.random() < 0.25) {
+ await delay(1000)
+ return new HttpResponse(null, {
+ status: 500,
+ statusText: 'Network Error',
+ })
+ }
+
+ const newMessage: Message = {
+ id: faker.string.uuid(),
+ tempId,
+ matchId: matchId as string,
+ senderId: 'me',
+ text,
+ timestamp: new Date().toISOString(),
+ }
+
+ if (FAKE_MESSAGES[matchId as string]) {
+ FAKE_MESSAGES[matchId as string].push(newMessage)
+ }
+
+ await delay(500)
+ return HttpResponse.json(newMessage)
+ }),
]
diff --git a/stores/auth-store.ts b/stores/auth-store.ts
index 1715d0c..263d7e9 100644
--- a/stores/auth-store.ts
+++ b/stores/auth-store.ts
@@ -1,10 +1,12 @@
import { create } from 'zustand'
-import type { UserProfile } from '@/lib/api-schema'
+import type { Message, UserProfile } from '@/lib/api-schema'
interface AuthState {
user: UserProfile | null
token: string | null
isAuthenticated: boolean
+ selectedMatchId: string | null
+ messageQueue: Message[]
isMatchModalOpen: boolean
matchedUser: UserProfile | null
@@ -13,6 +15,14 @@ interface AuthState {
openMatchModal: (user: UserProfile) => void
closeMatchModal: () => void
+
+ selectMatch: (matchId: string | null) => void
+ addMessageToQueue: (message: Message) => void
+ removeMessageFromQueue: (tempId: string) => void
+ updateMessageStatusInQueue: (
+ tempId: string,
+ status: 'sending' | 'failed'
+ ) => void
}
export const useAuthStore = create((set) => ({
@@ -21,6 +31,8 @@ export const useAuthStore = create((set) => ({
isAuthenticated: false,
isMatchModalOpen: false,
matchedUser: null,
+ selectedMatchId: null,
+ messageQueue: [],
login: (user, token) =>
set({
@@ -47,4 +59,23 @@ export const useAuthStore = create((set) => ({
isMatchModalOpen: false,
matchedUser: null,
}),
+
+ selectMatch: (matchId) => set({ selectedMatchId: matchId }),
+
+ addMessageToQueue: (message) =>
+ set((state) => ({
+ messageQueue: [...state.messageQueue, { ...message, status: 'failed' }],
+ })),
+
+ removeMessageFromQueue: (tempId) =>
+ set((state) => ({
+ messageQueue: state.messageQueue.filter((m) => m.tempId !== tempId),
+ })),
+
+ updateMessageStatusInQueue: (tempId, status) =>
+ set((state) => ({
+ messageQueue: state.messageQueue.map((m) =>
+ m.tempId === tempId ? { ...m, status } : m
+ ),
+ })),
}))