diff --git a/client/src/api/api.config.ts b/client/src/api/api.config.ts index 76ff11f0..672c05c9 100644 --- a/client/src/api/api.config.ts +++ b/client/src/api/api.config.ts @@ -20,8 +20,8 @@ export const API_CONFIG = { export class ApiError extends Error { constructor( public status: number, - message?: string, public data?: unknown, + message?: string, ) { super(message); this.name = 'ApiError'; @@ -62,14 +62,34 @@ export class ApiError extends Error { * } * ); * - * // 에러 처리 - * try { - * const data = await fetchApi>(endpoint); - * } catch (error) { - * if (error instanceof ApiError) { - * console.error(`API Error ${error.status}: ${error.message}`); + * // 에러 처리: 방 생성 함수 + * const createRoom = async (): Promise => { + * setIsLoading(true); + * try { + * const response = await gameApi.createRoom(); + * + * // 성공 토스트 메시지 + * // actions.addToast({ + * // title: '방 생성 성공', + * // description: `방이 생성됐습니다! 초대 버튼을 눌러 초대 후 게임을 즐겨보세요!`, + * // variant: 'success', + * // }); + * + * return response; + * } catch (error) { + * if (error instanceof ApiError) { + * // 에러 토스트 메시지 + * actions.addToast({ + * title: '방 생성 실패', + * description: error.message, + * variant: 'error', + * }); + * console.error(error); + * } + * } finally { + * setIsLoading(false); * } - * } + * }; */ export async function fetchApi(endpoint: string, options?: RequestInit): Promise { const response = await fetch(`${API_CONFIG.BASE_URL}${endpoint}`, { @@ -84,7 +104,7 @@ export async function fetchApi(endpoint: string, options?: RequestInit): Prom const data = await response.json(); if (!response.ok) { - throw new ApiError(response.status, data.message || 'An error occurred', data); + throw new ApiError(response.status, data); } return data; diff --git a/client/src/components/toast/ToastContainer.tsx b/client/src/components/toast/ToastContainer.tsx index 10ad4e9c..fcd2df76 100644 --- a/client/src/components/toast/ToastContainer.tsx +++ b/client/src/components/toast/ToastContainer.tsx @@ -78,7 +78,7 @@ export const ToastContainer = () => {
( {/* Content */}
{title && ( -
+
{title}
)} {description && ( -
+
{description}
)} @@ -57,10 +57,10 @@ const Toast = forwardRef( {onClose && ( diff --git a/client/src/constants/socket-error-messages.ts b/client/src/constants/socket-error-messages.ts new file mode 100644 index 00000000..0fc3f8b2 --- /dev/null +++ b/client/src/constants/socket-error-messages.ts @@ -0,0 +1,44 @@ +import { SocketErrorCode } from '@troublepainter/core'; +import { SocketNamespace } from '@/stores/socket/socket.config'; + +export const ERROR_MESSAGES: Record = { + // 클라이언트 에러 (4xxx) + [SocketErrorCode.BAD_REQUEST]: '잘못된 요청입니다. 다시 시도해 주세요.', + [SocketErrorCode.UNAUTHORIZED]: '인증이 필요합니다.', + [SocketErrorCode.FORBIDDEN]: '접근 권한이 없습니다.', + [SocketErrorCode.NOT_FOUND]: '요청한 리소스를 찾을 수 없습니다.', + [SocketErrorCode.VALIDATION_ERROR]: '입력 데이터가 유효하지 않습니다.', + [SocketErrorCode.RATE_LIMIT]: '너무 많은 요청을 보냈습니다. 잠시 후 다시 시도해주세요.', + + // 서버 에러 (5xxx) + [SocketErrorCode.INTERNAL_ERROR]: '서버 내부 오류가 발생했습니다.', + [SocketErrorCode.NOT_IMPLEMENTED]: '아직 구현되지 않은 기능입니다.', + [SocketErrorCode.SERVICE_UNAVAILABLE]: '서비스를 일시적으로 사용할 수 없습니다.', + + // 게임 로직 에러 (6xxx) + [SocketErrorCode.GAME_NOT_STARTED]: '게임이 아직 시작되지 않았습니다.', + [SocketErrorCode.GAME_ALREADY_STARTED]: '이미 게임이 진행 중입니다.', + [SocketErrorCode.INVALID_TURN]: '유효하지 않은 턴입니다.', + [SocketErrorCode.ROOM_FULL]: '방이 가득 찼습니다.', + [SocketErrorCode.ROOM_NOT_FOUND]: '해당 방을 찾을 수 없습니다.', + [SocketErrorCode.PLAYER_NOT_FOUND]: '플레이어를 찾을 수 없습니다.', + [SocketErrorCode.INSUFFICIENT_PLAYERS]: '게임 시작을 위한 플레이어 수가 부족합니다.', + + // 연결 관련 에러 (7xxx) + [SocketErrorCode.CONNECTION_ERROR]: '연결 오류가 발생했습니다.', + [SocketErrorCode.CONNECTION_TIMEOUT]: '연결 시간이 초과되었습니다.', + [SocketErrorCode.CONNECTION_CLOSED]: '연결이 종료되었습니다.', +} as const; + +export const getErrorTitle = (namespace: SocketNamespace): string => { + switch (namespace) { + case SocketNamespace.GAME: + return '게임 오류'; + case SocketNamespace.DRAWING: + return '드로잉 오류'; + case SocketNamespace.CHAT: + return '채팅 오류'; + default: + return '연결 오류'; + } +}; diff --git a/client/src/hooks/useCreateRoom.ts b/client/src/hooks/useCreateRoom.ts index 9d974950..2c5571c2 100644 --- a/client/src/hooks/useCreateRoom.ts +++ b/client/src/hooks/useCreateRoom.ts @@ -1,6 +1,7 @@ -import { useMutation } from '@tanstack/react-query'; +import { useState } from 'react'; import { ApiError } from '@/api/api.config'; -import { gameApi } from '@/api/gameApi'; +import { CreateRoomResponse, gameApi } from '@/api/gameApi'; +import { useToastStore } from '@/stores/toast.store'; /** * 게임 방 생성을 위한 커스텀 훅입니다. @@ -28,13 +29,37 @@ import { gameApi } from '@/api/gameApi'; * }; */ export const useCreateRoom = () => { - return useMutation({ - mutationFn: gameApi.createRoom, - onError: (error) => { + const { actions } = useToastStore(); + const [isLoading, setIsLoading] = useState(false); + + // 방 생성 함수 + const createRoom = async (): Promise => { + setIsLoading(true); + try { + const response = await gameApi.createRoom(); + + // 성공 토스트 메시지 + // actions.addToast({ + // title: '방 생성 성공', + // description: `방이 생성됐습니다! 초대 버튼을 눌러 초대 후 게임을 즐겨보세요!`, + // variant: 'success', + // }); + + return response; + } catch (error) { if (error instanceof ApiError) { - console.error('API Error:', error.message); - // TODO: 에러 처리 (예: 토스트 메시지) + // 에러 토스트 메시지 + actions.addToast({ + title: '방 생성 실패', + description: error.message, + variant: 'error', + }); + console.error(error); } - }, - }); + } finally { + setIsLoading(false); + } + }; + + return { createRoom, isLoading }; }; diff --git a/client/src/hooks/useDrawing.ts b/client/src/hooks/useDrawing.ts index 3692c28e..1e5e1197 100644 --- a/client/src/hooks/useDrawing.ts +++ b/client/src/hooks/useDrawing.ts @@ -3,6 +3,7 @@ import { Point, DrawingData, LWWMap, CRDTMessage, MapState, RegisterState, Strok import { useParams } from 'react-router-dom'; import type { DrawingMode, RGBA } from '@/types/canvas.types'; import { DEFAULT_MAX_PIXELS, COLORS_INFO, DRAWING_MODE, LINEWIDTH_VARIABLE } from '@/constants/canvasConstants'; +import { useToastStore } from '@/stores/toast.store'; import { getCanvasContext } from '@/utils/getCanvasContext'; import { hexToRGBA } from '@/utils/hexToRGBA'; import { playerIdStorageUtils } from '@/utils/playerIdStorage'; @@ -102,13 +103,15 @@ const checkColorisNotEqual = (pos: number, startColor: RGBA, pixelArray: Uint8Cl */ const useDrawing = (canvasRef: RefObject, options?: DrawingOptions) => { const { roomId } = useParams<{ roomId: string }>(); + const { actions } = useToastStore(); const currentPlayerId = playerIdStorageUtils.getPlayerId(roomId as string); // 기본 상태 관리 const [currentColor, setCurrentColor] = useState(COLORS_INFO[0].backgroundColor); const [brushSize, setBrushSize] = useState(LINEWIDTH_VARIABLE.MIN_WIDTH); const [drawingMode, setDrawingMode] = useState(DRAWING_MODE.PEN); - const [inkRemaining, setInkRemaining] = useState(options?.maxPixels ?? DEFAULT_MAX_PIXELS); + const maxPixels = options?.maxPixels ?? DEFAULT_MAX_PIXELS; + const [inkRemaining, setInkRemaining] = useState(maxPixels); const [canUndo, setCanUndo] = useState(false); const [canRedo, setCanRedo] = useState(false); @@ -239,7 +242,15 @@ const useDrawing = (canvasRef: RefObject, options?: DrawingOp // 드로잉 시작 const startDrawing = useCallback( (point: Point): CRDTMessage | null => { - if (inkRemaining <= 0 || !crdtRef.current) return null; + if (inkRemaining <= 0 || !crdtRef.current) { + actions.addToast({ + title: '잉크 부족', + description: '잉크를 다 써버렸어요 🥲😛😥', + variant: 'error', + duration: 2000, + }); + return null; + } // 새로운 currentStrokeIdsRef 시작 currentStrokeIdsRef.current = []; diff --git a/client/src/pages/MainPage.tsx b/client/src/pages/MainPage.tsx index 836c5b79..0c5cae07 100644 --- a/client/src/pages/MainPage.tsx +++ b/client/src/pages/MainPage.tsx @@ -6,13 +6,15 @@ import { usePageTransition } from '@/hooks/usePageTransition'; import { cn } from '@/utils/cn'; const MainPage = () => { - const createRoomMutation = useCreateRoom(); + const { createRoom, isLoading } = useCreateRoom(); const { isExiting, transitionTo } = usePageTransition(); const handleCreateRoom = async () => { // transitionTo(`/lobby/${roomId}`); - const response = await createRoomMutation.mutateAsync(); - transitionTo(`/lobby/${response.roomId}`); + const response = await createRoom(); + if (response && response.roomId) { + transitionTo(`/lobby/${response.roomId}`); + } }; return ( @@ -29,10 +31,10 @@ const MainPage = () => { diff --git a/client/src/stores/socket/socket.config.ts b/client/src/stores/socket/socket.config.ts index 5d33ddde..23dedf26 100644 --- a/client/src/stores/socket/socket.config.ts +++ b/client/src/stores/socket/socket.config.ts @@ -1,5 +1,7 @@ -import { SocketError } from '@troublepainter/core'; +import { SocketError, SocketErrorCode } from '@troublepainter/core'; import { io } from 'socket.io-client'; +import { ERROR_MESSAGES, getErrorTitle } from '@/constants/socket-error-messages'; +import { useToastStore } from '@/stores/toast.store'; import { ChatSocket, DrawingSocket, GameSocket } from '@/types/socket.types'; /** @@ -111,7 +113,9 @@ type SocketCreator = (auth?: SocketAuth) => T; * @returns 생성된 소켓 인스턴스 */ const createSocket = (namespace: SocketNamespace, auth?: SocketAuth): T => { - const options = auth ? { ...SOCKET_CONFIG.BASE_OPTIONS, auth } : SOCKET_CONFIG.BASE_OPTIONS; + const options = auth + ? { ...SOCKET_CONFIG.BASE_OPTIONS, auth: { roomId: '213', playerId: '123sas' } } + : SOCKET_CONFIG.BASE_OPTIONS; return io(`${SOCKET_CONFIG.URL}${SOCKET_CONFIG.PATHS[namespace]}`, options) as T; }; @@ -133,8 +137,16 @@ export const socketCreators: { * @param error - 소켓 에러 객체 * @param namespace - 에러가 발생한 네임스페이스 */ -export const handleSocketError = (error: SocketError, namespace: string) => { +export const handleSocketError = (error: SocketError, namespace: SocketNamespace) => { console.error(`Socket Error (${namespace}):`, error); - // TODO: 에러 추적 서비스에 로깅 - // TODO: 사용자에게 에러 알림 (토스트 등) + + const { actions } = useToastStore.getState(); + + actions.addToast({ + title: getErrorTitle(namespace), + description: ERROR_MESSAGES[error.code] || '알 수 없는 오류가 발생했습니다.', + variant: 'error', + // 심각한 에러의 경우 더 긴 duration + duration: [SocketErrorCode.CONNECTION_ERROR, SocketErrorCode.UNAUTHORIZED].includes(error.code) ? 5000 : 3000, + }); };