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:
2026-03-15 00:24:39 -07:00
committed by GitHub
parent 77e894e164
commit c80fe41bc9
16 changed files with 305 additions and 136 deletions
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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';
+4 -2
View File
@@ -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);
+85 -57
View File
@@ -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;
}
+10
View File
@@ -177,4 +177,14 @@
html {
@apply font-sans;
}
button,
[role="button"],
a,
select,
summary,
[type="checkbox"],
[type="radio"],
label[for] {
cursor: pointer;
}
}
+13
View File
@@ -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
View File
@@ -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;
}
+22
View File
@@ -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),
};
+3 -3
View File
@@ -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');
};
+7 -6
View File
@@ -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
View File
@@ -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 });
}
},
+10 -10
View File
@@ -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 });
+7 -4
View File
@@ -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 {
+8 -13
View File
@@ -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
View File
@@ -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 });