Chores/code refactor (#1)
* Add typed localStorage utility helpers * Add socket connection utility to deduplicate store boilerplate * Use storage utility for auth token management * Use createSocket utility and ScoreBreakdown type in versus store * Use createSocket utility and ScoreBreakdown type in game-night store * Use createSocket, fix typing indicator leak, add error logging * Use createSocket utility and add error logging in notification store * Add error logging for fire-and-forget backend calls * Use storage utility for auth token in API client * Add validateLink API function and use ScoreBreakdown type * Complete API index re-exports for all modules * Delegate chain validation to backend for authenticated users * Use storage utility for sound preference * Use storage utility for daily completion check * Use storage utility for daily completion write * Updated css so clickable elements properly display pointer cursor
This commit is contained in:
+2
-1
@@ -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}`;
|
||||
}
|
||||
|
||||
+30
-1
@@ -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<ValidateLinkResponse> {
|
||||
const { data } = await apiClient.post<ValidateLinkResponse>(
|
||||
`/games/${gameId}/validate-link`,
|
||||
link,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getGameSession(id: string): Promise<GameHistoryEntry> {
|
||||
const { data } = await apiClient.get<GameHistoryEntry>(`/games/${id}`);
|
||||
return data;
|
||||
|
||||
+64
-1
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -177,4 +177,14 @@
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
button,
|
||||
[role="button"],
|
||||
a,
|
||||
select,
|
||||
summary,
|
||||
[type="checkbox"],
|
||||
[type="radio"],
|
||||
label[for] {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
});
|
||||
}
|
||||
+4
-9
@@ -1,3 +1,5 @@
|
||||
import { storage } from './storage';
|
||||
|
||||
type SoundName = 'valid' | 'invalid' | 'completion';
|
||||
|
||||
const SOUND_PATHS: Record<SoundName, string> = {
|
||||
@@ -6,12 +8,9 @@ const SOUND_PATHS: Record<SoundName, string> = {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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<string, boolean> = {};
|
||||
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');
|
||||
};
|
||||
|
||||
@@ -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<AuthState>((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<AuthState>((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<AuthState>((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<AuthState>((set) => ({
|
||||
useNotificationStore.getState().connect();
|
||||
registerServiceWorker();
|
||||
} catch {
|
||||
localStorage.removeItem('auth_token');
|
||||
storage.removeAuthToken();
|
||||
set({ user: null, isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
+25
-14
@@ -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<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
interface ChatState {
|
||||
conversations: ConversationEntry[];
|
||||
messages: ChatMessage[];
|
||||
@@ -38,13 +40,8 @@ export const useChatStore = create<ChatState>((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<ChatState>((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<ChatState>((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<ChatState>((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<ChatState>((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<ChatState>((set, get) => ({
|
||||
nextCursor: result.nextCursor,
|
||||
loading: false,
|
||||
}));
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error('[ChatStore] Failed to load more messages:', err);
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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<GameNightState>((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<GameNightState>((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<GameNightState>((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 });
|
||||
|
||||
@@ -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<void>;
|
||||
startGame: (pair: MoviePair, options?: { isCustom?: boolean; isDaily?: boolean; dailyDate?: string; dailyChallengeId?: string; difficulty?: string }) => Promise<void>;
|
||||
startGameFromVersus: (pair: MoviePair, startTime: number) => Promise<void>;
|
||||
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<GameState>((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<GameState>((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 {
|
||||
|
||||
@@ -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<NotificationState>((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<NotificationState>((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<NotificationState>((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);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
+11
-15
@@ -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<VersusState>((set, get) => ({
|
||||
@@ -62,13 +63,8 @@ export const useVersusStore = create<VersusState>((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<VersusState>((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 });
|
||||
|
||||
Reference in New Issue
Block a user