diff --git a/src/api/client.ts b/src/api/client.ts index c71c438..af66880 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,4 +1,5 @@ import axios from 'axios'; +import { storage } from '@/lib/storage'; const apiClient = axios.create({ baseURL: import.meta.env.VITE_API_URL || '/api/v1', @@ -10,7 +11,7 @@ const apiClient = axios.create({ // Attach JWT token to requests if available apiClient.interceptors.request.use((config) => { - const token = localStorage.getItem('auth_token'); + const token = storage.getAuthToken(); if (token) { config.headers.Authorization = `Bearer ${token}`; } diff --git a/src/api/games.ts b/src/api/games.ts index cd4866e..e44a72e 100644 --- a/src/api/games.ts +++ b/src/api/games.ts @@ -42,7 +42,7 @@ export interface GameHistoryEntry { movieBId: number; movieBTitle: string; chain: ChainLink[]; - score: any; + score: ScoreBreakdown; hintsUsed: number; startedAt: string; completedAt: string; @@ -67,6 +67,35 @@ export async function getGameHistory( return data; } +export interface ValidateLinkRequest { + type: 'actor' | 'movie'; + id: number; + name?: string; + title?: string; + profilePath?: string; + posterPath?: string; + releaseDate?: string; + popularity?: number; +} + +export interface ValidateLinkResponse { + valid: boolean; + chain: ChainLink[]; + loopComplete: boolean; + passedMovieB: boolean; +} + +export async function validateLink( + gameId: string, + link: ValidateLinkRequest, +): Promise { + const { data } = await apiClient.post( + `/games/${gameId}/validate-link`, + link, + ); + return data; +} + export async function getGameSession(id: string): Promise { const { data } = await apiClient.get(`/games/${id}`); return data; diff --git a/src/api/index.ts b/src/api/index.ts index ff81fd7..5f0f362 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -4,10 +4,11 @@ export { getPersonDetails, getPersonMovieCredits, } from './persons'; -export { createGameSession, completeGameSession } from './games'; +export { createGameSession, completeGameSession, getGameHistory, getGameSession } from './games'; export { register, login, getProfile } from './auth'; export { getTodaysChallenge, + getTodaysChallenges, getChallengeHistory, getChallengeByDate, } from './daily-challenges'; @@ -15,4 +16,66 @@ export { getLeaderboard, getUserStats, getUserStreak, + checkDailyCompletion, } from './leaderboards'; +export { + getFriends, + getPendingRequests, + getSentRequests, + getPendingCount, + searchUsers, + sendFriendRequest, + acceptRequest, + rejectRequest, + removeFriend, + blockUser, + createFriendChallenge, +} from './friends'; +export { + getNotifications, + getUnreadCount, + markNotificationRead, + markAllNotificationsRead, + deleteNotification, + deleteAllReadNotifications, +} from './notifications'; +export { + getConversations, + getMessages, + deleteMessage, +} from './chat'; +export { + createMatch, + getWaitingMatches, + joinMatch, + cancelMatch, + getMyWaitingMatches, + getMatch, + getUserMatchHistory, + submitCreatorScore, + startAsyncAttempt, + submitAsyncAttempt, + getAsyncLeaderboard, +} from './versus'; +export { + createRoom, + getOpenRooms, + getMyRooms, + joinRoom, + leaveRoom, + getRoom, + getRoomResults, +} from './game-night'; +export { updateProfile, changePassword } from './users'; +export { getAllAchievements, getUserAchievements } from './achievements'; +export { getVapidKey, subscribeToPush, unsubscribeFromPush } from './push'; +export { + getPlatformStats, + listUsers, + recalculateScores, + generateChallenge, + deleteChallenge, + generateAllChallenges, + retroactiveAchievements, + backfillUsernames, +} from './admin'; diff --git a/src/components/game/GameCompletionModal.tsx b/src/components/game/GameCompletionModal.tsx index 52e4deb..c5d1167 100644 --- a/src/components/game/GameCompletionModal.tsx +++ b/src/components/game/GameCompletionModal.tsx @@ -13,6 +13,7 @@ import ShareableResult from './ShareableResult'; import type { GameMode } from './ShareableResult'; import CelebrationOverlay from './CelebrationOverlay'; import { playSound } from '@/lib/sounds'; +import { storage } from '@/lib/storage'; import { useNavigate } from 'react-router'; import { Loader2 } from 'lucide-react'; @@ -31,6 +32,7 @@ export default function GameCompletionModal() { const hintsUsed = useGameStore((s) => s.hintsUsed); const isDailyChallenge = useGameStore((s) => s.isDailyChallenge); const dailyChallengeDate = useGameStore((s) => s.dailyChallengeDate); + const dailyChallengeDifficulty = useGameStore((s) => s.dailyChallengeDifficulty); const validationError = useGameStore((s) => s.validationError); const resetGame = useGameStore((s) => s.resetGame); const navigate = useNavigate(); @@ -45,9 +47,9 @@ export default function GameCompletionModal() { setShowModal(true); playSound('completion'); - // Mark daily challenge as completed in localStorage + // Mark daily challenge as completed in localStorage (fallback for anonymous users) if (isDailyChallenge && dailyChallengeDate) { - localStorage.setItem('daily-completed-' + dailyChallengeDate, 'true'); + storage.setDailyCompleted(dailyChallengeDate, dailyChallengeDifficulty ?? undefined); } } else { setShowModal(false); diff --git a/src/hooks/use-chain-validation.ts b/src/hooks/use-chain-validation.ts index 956a6bf..39da2ed 100644 --- a/src/hooks/use-chain-validation.ts +++ b/src/hooks/use-chain-validation.ts @@ -1,5 +1,6 @@ import { useCallback } from 'react'; import { useGameStore } from '@/stores/game-store'; +import { validateLink } from '@/api/games'; import { getMovieCredits } from '@/api/movies'; import { getPersonMovieCredits } from '@/api/persons'; import { playSound } from '@/lib/sounds'; @@ -10,6 +11,10 @@ import type { TmdbMovieResult, } from '@/types'; +/** + * Client-side validation fallback for unauthenticated users without a session. + * When a backend session exists, validation is delegated to the server. + */ export function useChainValidation() { const { chain, @@ -21,15 +26,17 @@ export function useChainValidation() { const validateAndAddActor = useCallback( async (person: TmdbPersonResult) => { - const lastLink = chain[chain.length - 1]; - if (lastLink.type !== 'movie') { + const { sessionId, chain: currentChain } = useGameStore.getState(); + const lastLink = currentChain[currentChain.length - 1]; + + if (!lastLink || lastLink.type !== 'movie') { setValidationError('Expected a movie as the last chain link.'); playSound('invalid'); return; } - // Check if actor is already in the chain - if (chain.some((link) => link.type === 'actor' && link.id === person.id)) { + // Quick client-side duplicate check (fast feedback) + if (currentChain.some((link) => link.type === 'actor' && link.id === person.id)) { setValidationError(`${person.name} is already in your chain.`); playSound('invalid'); return; @@ -39,20 +46,6 @@ export function useChainValidation() { setValidationError(null); try { - const credits = await getMovieCredits(lastLink.id); - const isInCast = credits.cast.some( - (member) => member.id === person.id, - ); - - if (!isInCast) { - setValidationError( - `${person.name} was not in "${lastLink.title}".`, - ); - playSound('invalid'); - setValidating(false); - return; - } - const actorLink: ActorChainLink = { type: 'actor', id: person.id, @@ -61,16 +54,33 @@ export function useChainValidation() { popularity: person.popularity, }; - addActorToChain(actorLink); + if (sessionId) { + // Backend validation + await validateLink(sessionId, { + type: 'actor', + id: person.id, + name: person.name, + profilePath: person.profile_path ?? undefined, + popularity: person.popularity, + }); + addActorToChain(actorLink); + } else { + // Client-side fallback for anonymous/casual play without a session + const credits = await getMovieCredits(lastLink.id); + const isInCast = credits.cast.some((member) => member.id === person.id); + if (!isInCast) { + setValidationError(`${person.name} was not in "${lastLink.title}".`); + playSound('invalid'); + setValidating(false); + return; + } + addActorToChain(actorLink); + } + playSound('valid'); } catch (error: unknown) { - const isNetwork = - error instanceof Error && error.message === 'Network Error'; - setValidationError( - isNetwork - ? 'Network error — check your connection and try again.' - : 'Failed to validate. Please try again.', - ); + const message = extractErrorMessage(error, 'Failed to validate. Please try again.'); + setValidationError(message); playSound('invalid'); } finally { setValidating(false); @@ -81,26 +91,24 @@ export function useChainValidation() { const validateAndAddMovie = useCallback( async (movie: TmdbMovieResult) => { - const lastLink = chain[chain.length - 1]; - if (lastLink.type !== 'actor') { + const { sessionId, chain: currentChain, movieA, movieB, passedMovieB } = + useGameStore.getState(); + const lastLink = currentChain[currentChain.length - 1]; + + if (!lastLink || lastLink.type !== 'actor') { setValidationError('Expected an actor as the last chain link.'); playSound('invalid'); return; } - // Check if movie is already in the chain (except movieA for loop closure) - const movieA = useGameStore.getState().movieA; - const movieB = useGameStore.getState().movieB; - const passedMovieB = useGameStore.getState().passedMovieB; - - // Allow movieA only if we've passed movieB (closing the loop) + // Quick client-side duplicate / loop-closure checks const isMovieA = movie.id === movieA?.id; const isMovieB = movie.id === movieB?.id; if ( !isMovieA && !isMovieB && - chain.some((link) => link.type === 'movie' && link.id === movie.id) + currentChain.some((link) => link.type === 'movie' && link.id === movie.id) ) { setValidationError(`"${movie.title}" is already in your chain.`); playSound('invalid'); @@ -119,20 +127,6 @@ export function useChainValidation() { setValidationError(null); try { - const credits = await getPersonMovieCredits(lastLink.id); - const isInFilmography = credits.cast.some( - (credit) => credit.id === movie.id, - ); - - if (!isInFilmography) { - setValidationError( - `${lastLink.name} was not in "${movie.title}".`, - ); - playSound('invalid'); - setValidating(false); - return; - } - const movieLink: MovieChainLink = { type: 'movie', id: movie.id, @@ -142,16 +136,34 @@ export function useChainValidation() { popularity: movie.popularity, }; - addMovieToChain(movieLink); + if (sessionId) { + // Backend validation + await validateLink(sessionId, { + type: 'movie', + id: movie.id, + title: movie.title, + posterPath: movie.poster_path ?? undefined, + releaseDate: movie.release_date, + popularity: movie.popularity, + }); + addMovieToChain(movieLink); + } else { + // Client-side fallback + const credits = await getPersonMovieCredits(lastLink.id); + const isInFilmography = credits.cast.some((credit) => credit.id === movie.id); + if (!isInFilmography) { + setValidationError(`${lastLink.name} was not in "${movie.title}".`); + playSound('invalid'); + setValidating(false); + return; + } + addMovieToChain(movieLink); + } + playSound('valid'); } catch (error: unknown) { - const isNetwork = - error instanceof Error && error.message === 'Network Error'; - setValidationError( - isNetwork - ? 'Network error — check your connection and try again.' - : 'Failed to validate. Please try again.', - ); + const message = extractErrorMessage(error, 'Failed to validate. Please try again.'); + setValidationError(message); playSound('invalid'); } finally { setValidating(false); @@ -162,3 +174,19 @@ export function useChainValidation() { return { validateAndAddActor, validateAndAddMovie }; } + +/** Extract a human-readable error message from an Axios error or generic error. */ +function extractErrorMessage(error: unknown, fallback: string): string { + if (error && typeof error === 'object' && 'response' in error) { + const resp = (error as any).response; + if (resp?.data?.message) { + return typeof resp.data.message === 'string' + ? resp.data.message + : resp.data.message[0]; + } + } + if (error instanceof Error && error.message === 'Network Error') { + return 'Network error \u2014 check your connection and try again.'; + } + return fallback; +} diff --git a/src/index.css b/src/index.css index 9d6166c..4d42f4e 100644 --- a/src/index.css +++ b/src/index.css @@ -177,4 +177,14 @@ html { @apply font-sans; } + button, + [role="button"], + a, + select, + summary, + [type="checkbox"], + [type="radio"], + label[for] { + cursor: pointer; + } } diff --git a/src/lib/create-socket.ts b/src/lib/create-socket.ts new file mode 100644 index 0000000..fd69c70 --- /dev/null +++ b/src/lib/create-socket.ts @@ -0,0 +1,13 @@ +import { io, Socket } from 'socket.io-client'; +import { getWsUrl } from './ws'; +import { storage } from './storage'; + +export function createSocket(namespace: string): Socket | null { + const token = storage.getAuthToken(); + if (!token) return null; + + return io(getWsUrl(namespace), { + auth: { token }, + transports: ['websocket'], + }); +} diff --git a/src/lib/sounds.ts b/src/lib/sounds.ts index 8f4b8e8..aa50762 100644 --- a/src/lib/sounds.ts +++ b/src/lib/sounds.ts @@ -1,3 +1,5 @@ +import { storage } from './storage'; + type SoundName = 'valid' | 'invalid' | 'completion'; const SOUND_PATHS: Record = { @@ -6,12 +8,9 @@ const SOUND_PATHS: Record = { completion: '/sounds/completion.mp3', }; -const STORAGE_KEY = 'movieloop-sound-enabled'; - export function isSoundEnabled(): boolean { try { - const stored = localStorage.getItem(STORAGE_KEY); - return stored === null ? true : stored === 'true'; + return storage.isSoundEnabled(); } catch { return true; } @@ -19,11 +18,7 @@ export function isSoundEnabled(): boolean { export function toggleSound(): boolean { const next = !isSoundEnabled(); - try { - localStorage.setItem(STORAGE_KEY, String(next)); - } catch { - // localStorage unavailable - } + storage.setSoundEnabled(next); return next; } diff --git a/src/lib/storage.ts b/src/lib/storage.ts new file mode 100644 index 0000000..bbee1f9 --- /dev/null +++ b/src/lib/storage.ts @@ -0,0 +1,22 @@ +export const storage = { + getAuthToken: (): string | null => localStorage.getItem('auth_token'), + setAuthToken: (token: string) => localStorage.setItem('auth_token', token), + removeAuthToken: () => localStorage.removeItem('auth_token'), + + isSoundEnabled: (): boolean => localStorage.getItem('movieloop-sound-enabled') !== 'false', + setSoundEnabled: (enabled: boolean) => { + try { localStorage.setItem('movieloop-sound-enabled', String(enabled)); } catch {} + }, + + isDailyCompleted: (date: string, difficulty?: string): boolean => { + const key = difficulty ? `daily-completed-${date}-${difficulty}` : `daily-completed-${date}`; + return localStorage.getItem(key) === 'true'; + }, + setDailyCompleted: (date: string, difficulty?: string) => { + const key = difficulty ? `daily-completed-${date}-${difficulty}` : `daily-completed-${date}`; + localStorage.setItem(key, 'true'); + }, + + getTheme: (): string | null => localStorage.getItem('theme'), + setTheme: (theme: string) => localStorage.setItem('theme', theme), +}; diff --git a/src/pages/DailyChallenge.tsx b/src/pages/DailyChallenge.tsx index 1c86e46..5eff68b 100644 --- a/src/pages/DailyChallenge.tsx +++ b/src/pages/DailyChallenge.tsx @@ -9,6 +9,7 @@ import { getTodaysChallenges, type DailyChallenge as DailyChallengeType } from ' import { checkDailyCompletion, type DailyCompletionStatus } from '@/api/leaderboards'; import { useAuthStore } from '@/stores/auth-store'; import { Loader2, Calendar, Trophy, Clock } from 'lucide-react'; +import { storage } from '@/lib/storage'; type Difficulty = 'easy' | 'medium' | 'hard'; @@ -34,8 +35,7 @@ export default function DailyChallenge() { // Check localStorage for anonymous replay prevention const localCompleted: Record = {}; for (const c of list) { - const key = `daily-completed-${c.date}-${c.difficulty}`; - if (localStorage.getItem(key)) { + if (storage.isDailyCompleted(c.date, c.difficulty)) { localCompleted[c.difficulty] = true; } } @@ -66,7 +66,7 @@ export default function DailyChallenge() { movieA: { id: challenge.movieAId, title: challenge.movieATitle }, movieB: { id: challenge.movieBId, title: challenge.movieBTitle }, }, - { isDaily: true, dailyDate: challenge.date, dailyChallengeId: challenge.id }, + { isDaily: true, dailyDate: challenge.date, dailyChallengeId: challenge.id, difficulty: challenge.difficulty }, ); navigate('/game'); }; diff --git a/src/stores/auth-store.ts b/src/stores/auth-store.ts index 57294f3..404f6e6 100644 --- a/src/stores/auth-store.ts +++ b/src/stores/auth-store.ts @@ -2,6 +2,7 @@ import { create } from 'zustand'; import * as authApi from '@/api/auth'; import { useNotificationStore } from '@/stores/notification-store'; import { registerServiceWorker } from '@/lib/push'; +import { storage } from '@/lib/storage'; interface User { id: string; @@ -23,14 +24,14 @@ interface AuthState { export const useAuthStore = create((set) => ({ user: null, - isLoading: !!localStorage.getItem('auth_token'), + isLoading: !!storage.getAuthToken(), error: null, login: async (email, password) => { set({ isLoading: true, error: null }); try { const response = await authApi.login(email, password); - localStorage.setItem('auth_token', response.access_token); + storage.setAuthToken(response.access_token); set({ user: response.user, isLoading: false }); useNotificationStore.getState().connect(); registerServiceWorker(); @@ -46,7 +47,7 @@ export const useAuthStore = create((set) => ({ set({ isLoading: true, error: null }); try { const response = await authApi.register(email, username, password); - localStorage.setItem('auth_token', response.access_token); + storage.setAuthToken(response.access_token); set({ user: response.user, isLoading: false }); useNotificationStore.getState().connect(); registerServiceWorker(); @@ -59,13 +60,13 @@ export const useAuthStore = create((set) => ({ }, logout: () => { - localStorage.removeItem('auth_token'); + storage.removeAuthToken(); useNotificationStore.getState().disconnect(); set({ user: null, error: null }); }, loadUser: async () => { - const token = localStorage.getItem('auth_token'); + const token = storage.getAuthToken(); if (!token) return; set({ isLoading: true }); @@ -75,7 +76,7 @@ export const useAuthStore = create((set) => ({ useNotificationStore.getState().connect(); registerServiceWorker(); } catch { - localStorage.removeItem('auth_token'); + storage.removeAuthToken(); set({ user: null, isLoading: false }); } }, diff --git a/src/stores/chat-store.ts b/src/stores/chat-store.ts index 67e0542..326a655 100644 --- a/src/stores/chat-store.ts +++ b/src/stores/chat-store.ts @@ -1,9 +1,11 @@ import { create } from 'zustand'; -import { io, Socket } from 'socket.io-client'; -import { getWsUrl } from '@/lib/ws'; +import type { Socket } from 'socket.io-client'; +import { createSocket } from '@/lib/create-socket'; import * as chatApi from '@/api/chat'; import type { ChatMessage, ConversationEntry } from '@/api/chat'; +const typingTimers = new Map>(); + interface ChatState { conversations: ConversationEntry[]; messages: ChatMessage[]; @@ -38,13 +40,8 @@ export const useChatStore = create((set, get) => ({ const existing = get().socket; if (existing?.connected) return; - const token = localStorage.getItem('auth_token'); - if (!token) return; - - const socket = io(getWsUrl('/chat'), { - auth: { token }, - transports: ['websocket'], - }); + const socket = createSocket('/chat'); + if (!socket) return; socket.on('new-message', (msg: ChatMessage) => { const { activeFriendId, messages } = get(); @@ -67,13 +64,20 @@ export const useChatStore = create((set, get) => ({ next.add(data.senderId); return { typingFriendIds: next }; }); - setTimeout(() => { + + // Clear any existing timer for this sender before setting a new one + const existing = typingTimers.get(data.senderId); + if (existing) clearTimeout(existing); + + const timerId = setTimeout(() => { set((state) => { const next = new Set(state.typingFriendIds); next.delete(data.senderId); return { typingFriendIds: next }; }); + typingTimers.delete(data.senderId); }, 3000); + typingTimers.set(data.senderId, timerId); }); set({ socket }); @@ -83,6 +87,11 @@ export const useChatStore = create((set, get) => ({ const { socket } = get(); if (socket) { socket.disconnect(); + // Clear all typing indicator timers + for (const timerId of typingTimers.values()) { + clearTimeout(timerId); + } + typingTimers.clear(); set({ socket: null, messages: [], activeFriendId: null }); } }, @@ -91,8 +100,8 @@ export const useChatStore = create((set, get) => ({ try { const conversations = await chatApi.getConversations(); set({ conversations }); - } catch { - // Silently fail + } catch (err) { + console.error('[ChatStore] Failed to fetch conversations:', err); } }, @@ -112,7 +121,8 @@ export const useChatStore = create((set, get) => ({ nextCursor: result.nextCursor, loading: false, }); - } catch { + } catch (err) { + console.error('[ChatStore] Failed to open conversation:', err); set({ loading: false }); } }, @@ -130,7 +140,8 @@ export const useChatStore = create((set, get) => ({ nextCursor: result.nextCursor, loading: false, })); - } catch { + } catch (err) { + console.error('[ChatStore] Failed to load more messages:', err); set({ loading: false }); } }, diff --git a/src/stores/game-night-store.ts b/src/stores/game-night-store.ts index 6cca028..c16925c 100644 --- a/src/stores/game-night-store.ts +++ b/src/stores/game-night-store.ts @@ -1,7 +1,9 @@ import { create } from 'zustand'; -import { io, Socket } from 'socket.io-client'; -import { getWsUrl } from '@/lib/ws'; +import type { Socket } from 'socket.io-client'; +import { createSocket } from '@/lib/create-socket'; +import { storage } from '@/lib/storage'; import type { GameNightPlayer, GameNightRoom, GameNightResults } from '@/api/game-night'; +import type { ScoreBreakdown } from '@/types'; export type GameNightLobbyState = | 'idle' @@ -30,7 +32,7 @@ interface GameNightState { toggleReady: () => void; startCountdown: () => void; sendChainUpdate: (chainLength: number) => void; - sendPlayerComplete: (score: object, chainLength: number) => void; + sendPlayerComplete: (score: ScoreBreakdown, chainLength: number) => void; sendPlayerForfeit: () => void; leaveRoom: () => void; reset: () => void; @@ -58,13 +60,11 @@ export const useGameNightStore = create((set, get) => ({ const existing = get().socket; if (existing?.connected) return; - const token = localStorage.getItem('auth_token'); + const token = storage.getAuthToken(); if (!token) return; - const socket = io(getWsUrl('/game-night'), { - auth: { token }, - transports: ['websocket'], - }); + const socket = createSocket('/game-night'); + if (!socket) return; socket.on('connect', () => { set({ connected: true }); @@ -134,7 +134,7 @@ export const useGameNightStore = create((set, get) => ({ socket.on( 'player-finished', - (data: { userId: string; score: object; completedAt: string }) => { + (data: { userId: string; score: ScoreBreakdown; completedAt: string }) => { set((state) => { const newFinished = new Set(state.finishedPlayers); newFinished.add(data.userId); @@ -218,7 +218,7 @@ export const useGameNightStore = create((set, get) => ({ socket.emit('chain-update', { roomId, chainLength }); }, - sendPlayerComplete: (score: object, chainLength: number) => { + sendPlayerComplete: (score: ScoreBreakdown, chainLength: number) => { const { socket, roomId } = get(); if (!socket || !roomId) return; socket.emit('player-complete', { roomId, score, chainLength }); diff --git a/src/stores/game-store.ts b/src/stores/game-store.ts index d848674..2784118 100644 --- a/src/stores/game-store.ts +++ b/src/stores/game-store.ts @@ -26,8 +26,9 @@ interface GameState { isCustomGame: boolean; isDailyChallenge: boolean; dailyChallengeDate: string | null; + dailyChallengeDifficulty: string | null; - startGame: (pair: MoviePair, options?: { isCustom?: boolean; isDaily?: boolean; dailyDate?: string; dailyChallengeId?: string }) => Promise; + startGame: (pair: MoviePair, options?: { isCustom?: boolean; isDaily?: boolean; dailyDate?: string; dailyChallengeId?: string; difficulty?: string }) => Promise; startGameFromVersus: (pair: MoviePair, startTime: number) => Promise; addActorToChain: (actor: ActorChainLink) => void; addMovieToChain: (movie: MovieChainLink) => void; @@ -54,14 +55,15 @@ const initialState = { isCustomGame: false, isDailyChallenge: false, dailyChallengeDate: null as string | null, + dailyChallengeDifficulty: null as string | null, }; export const useGameStore = create((set, get) => ({ ...initialState, - startGame: async (pair: MoviePair, options?: { isCustom?: boolean; isDaily?: boolean; dailyDate?: string; dailyChallengeId?: string }) => { + startGame: async (pair: MoviePair, options?: { isCustom?: boolean; isDaily?: boolean; dailyDate?: string; dailyChallengeId?: string; difficulty?: string }) => { // Reset if already playing - set({ ...initialState, status: 'playing', isValidating: true, isCustomGame: options?.isCustom ?? false, isDailyChallenge: options?.isDaily ?? false, dailyChallengeDate: options?.dailyDate ?? null }); + set({ ...initialState, status: 'playing', isValidating: true, isCustomGame: options?.isCustom ?? false, isDailyChallenge: options?.isDaily ?? false, dailyChallengeDate: options?.dailyDate ?? null, dailyChallengeDifficulty: options?.difficulty ?? null }); try { const [detailsA, detailsB] = await Promise.all([ @@ -106,8 +108,9 @@ export const useGameStore = create((set, get) => ({ options?.dailyChallengeId, ) .then((session) => set({ sessionId: session.id })) - .catch(() => { + .catch((err) => { // Backend unavailable — game continues without persistence + console.error('[GameStore] Failed to create game session:', err); }); }); } catch { diff --git a/src/stores/notification-store.ts b/src/stores/notification-store.ts index 21ee7f0..8859137 100644 --- a/src/stores/notification-store.ts +++ b/src/stores/notification-store.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; -import { io, Socket } from 'socket.io-client'; -import { getWsUrl } from '@/lib/ws'; +import type { Socket } from 'socket.io-client'; +import { createSocket } from '@/lib/create-socket'; import * as notificationsApi from '@/api/notifications'; import type { Notification } from '@/api/notifications'; import { useFriendsStore } from '@/stores/friends-store'; @@ -28,13 +28,8 @@ export const useNotificationStore = create((set, get) => ({ const existing = get().socket; if (existing?.connected) return; - const token = localStorage.getItem('auth_token'); - if (!token) return; - - const socket = io(getWsUrl('/notifications'), { - auth: { token }, - transports: ['websocket'], - }); + const socket = createSocket('/notifications'); + if (!socket) return; socket.on('connect', () => { // Fetch initial state on connect @@ -67,8 +62,8 @@ export const useNotificationStore = create((set, get) => ({ try { const result = await notificationsApi.getNotifications(); set({ notifications: result.data, unreadCount: result.unreadCount }); - } catch { - // Silently fail + } catch (err) { + console.error('[NotificationStore] Failed to fetch notifications:', err); } }, @@ -76,8 +71,8 @@ export const useNotificationStore = create((set, get) => ({ try { const count = await notificationsApi.getUnreadCount(); set({ unreadCount: count }); - } catch { - // Silently fail + } catch (err) { + console.error('[NotificationStore] Failed to fetch unread count:', err); } }, diff --git a/src/stores/versus-store.ts b/src/stores/versus-store.ts index 33129ae..c4ca061 100644 --- a/src/stores/versus-store.ts +++ b/src/stores/versus-store.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; -import { io, Socket } from 'socket.io-client'; -import { getWsUrl } from '@/lib/ws'; +import type { Socket } from 'socket.io-client'; +import { createSocket } from '@/lib/create-socket'; +import type { ScoreBreakdown } from '@/types'; export type LobbyState = | 'idle' @@ -14,8 +15,8 @@ interface MatchResult { winnerId: string | null; player1: { id: string; username: string }; player2: { id: string; username: string }; - player1Score: object | null; - player2Score: object | null; + player1Score: ScoreBreakdown | null; + player2Score: ScoreBreakdown | null; } interface VersusState { @@ -29,14 +30,14 @@ interface VersusState { opponentChainLength: number; opponentFinished: boolean; matchResult: MatchResult | null; - myScore: object | null; + myScore: ScoreBreakdown | null; connect: () => void; disconnect: () => void; joinLobby: (matchId: string) => void; startCountdown: () => void; sendChainUpdate: (chainLength: number) => void; - sendMatchComplete: (score: object) => void; + sendMatchComplete: (score: ScoreBreakdown) => void; leaveLobby: () => void; reset: () => void; } @@ -52,7 +53,7 @@ const initialState = { opponentChainLength: 0, opponentFinished: false, matchResult: null as MatchResult | null, - myScore: null as object | null, + myScore: null as ScoreBreakdown | null, }; export const useVersusStore = create((set, get) => ({ @@ -62,13 +63,8 @@ export const useVersusStore = create((set, get) => ({ const existing = get().socket; if (existing?.connected) return; - const token = localStorage.getItem('auth_token'); - if (!token) return; - - const socket = io(getWsUrl('/versus'), { - auth: { token }, - transports: ['websocket'], - }); + const socket = createSocket('/versus'); + if (!socket) return; socket.on('connect', () => { set({ connected: true }); @@ -168,7 +164,7 @@ export const useVersusStore = create((set, get) => ({ socket.emit('chain-update', { matchId, chainLength }); }, - sendMatchComplete: (score: object) => { + sendMatchComplete: (score: ScoreBreakdown) => { const { socket, matchId } = get(); if (!socket || !matchId) return; set({ myScore: score });