Skip to content

Commit

Permalink
feat: �Connect error handling with Toast �system for multiple error s…
Browse files Browse the repository at this point in the history
…cenarios (#122)

* feat: Implement Toast component with headless pattern

- Add base Toast UI component with variant styles
- Create cva variants for different toast types (default, error, success, warning)
- Add close button with hover effect
- Keep component headless by separating styling from logic

* feat: Add ToastContainer with slide animation

- Create ToastContainer to manage toast stack
- Implement smooth slide animations for toast enter/exit
- Add state management for animation timing
- Handle multiple toasts with proper stacking

* feat: Add toast store using Zustand for global state access

- Use global state management for showing toasts from anywhere in app
- Implement toast CRUD operations
- Add auto-dismiss with configurable duration

* feat: Integrate toast system into app root

- Add ToastContainer to App component

* feat: Enhance Toast components accessibility

- Add ARIA roles and live regions for screen reader support
- Implement keyboard navigation and focus management
 - Add ESC key to dismiss toasts
 - Add focus styles consistent with design system
- Connect aria-labelledby/describedby with unique IDs
- Improve close button accessibility with contextual labels

* feat: Implement max toast limit and FIFO behavior

- Add MAX_TOASTS constant to limit the number of toasts to 5

* feat: Refactor room creation logic in useCreateRoom hook and Add toast for error

- Changed the implementation of the useCreateRoom hook to manage loading state and error handling more effectively.
- Removed the useMutation from React Query and implemented a custom async function for room creation.
- Added toast notifications for success and error handling (commented out for future implementation).

* feat: Implement Toast system for WebSocket and API error handling

- Define detailed messages for each error code
  - Client errors (4xxx)
  - Server errors (5xxx)
  - Game logic errors (6xxx)
  - Connection errors (7xxx)
- Distinguish error titles by namespace
  - Separate areas for game/drawing/chat
  - Display appropriate error messages for each function
- Differentiate Toast UI based on error situations
  - Adjust display time according to severity
  - Apply error variant styles

* feat: Add Toast message for ink depletion

- Check ink levels at the start of drawing
- Provide visual feedback when ink is low
  - Display error toast message
  - Implement friendly UX with emoji
  - Automatically dismiss after 2 seconds

* design: Enhance adative Toast design
  • Loading branch information
rhino-ty authored Nov 25, 2024
1 parent 6a94d34 commit 74ef94d
Show file tree
Hide file tree
Showing 8 changed files with 149 additions and 35 deletions.
38 changes: 29 additions & 9 deletions client/src/api/api.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -62,14 +62,34 @@ export class ApiError extends Error {
* }
* );
*
* // 에러 처리
* try {
* const data = await fetchApi<ApiResponse<CreateRoomResponse>>(endpoint);
* } catch (error) {
* if (error instanceof ApiError) {
* console.error(`API Error ${error.status}: ${error.message}`);
* // 에러 처리: 방 생성 함수
* const createRoom = async (): Promise<CreateRoomResponse | undefined> => {
* 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<T>(endpoint: string, options?: RequestInit): Promise<T> {
const response = await fetch(`${API_CONFIG.BASE_URL}${endpoint}`, {
Expand All @@ -84,7 +104,7 @@ export async function fetchApi<T>(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;
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/toast/ToastContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export const ToastContainer = () => {
<div
key={toast.id}
className={cn(
'w-full max-w-80 transform transition-all duration-300 ease-out',
'w-full max-w-80 transform transition-all duration-300 ease-out lg:max-w-96',
// 초기 상태 (마운트 전)
'translate-y-3 opacity-0',
// 진입 애니메이션 (마운트)
Expand Down
8 changes: 4 additions & 4 deletions client/src/components/ui/Toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,12 @@ const Toast = forwardRef<HTMLDivElement, ToastProps>(
{/* Content */}
<div className="flex flex-1 flex-col gap-1">
{title && (
<div className="text-base font-semibold" id={`toast-title-${props.id}`}>
<div className="text-base font-semibold lg:text-lg" id={`toast-title-${props.id}`}>
{title}
</div>
)}
{description && (
<div className="text-sm opacity-90" id={`toast-description-${props.id}`}>
<div className="text-sm opacity-90 lg:text-base" id={`toast-description-${props.id}`}>
{description}
</div>
)}
Expand All @@ -57,10 +57,10 @@ const Toast = forwardRef<HTMLDivElement, ToastProps>(
{onClose && (
<button
onClick={onClose}
className="ml-4 flex h-6 w-6 items-center justify-center rounded-full transition-colors hover:bg-black/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
className="ml-4 flex h-6 w-6 items-center justify-center rounded-full transition-colors hover:bg-black/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 lg:h-10 lg:w-10"
aria-label="닫기"
>
<span className="text-xl leading-none" aria-hidden={false}>
<span className="text-xl leading-none lg:text-3xl" aria-hidden={false}>
×
</span>
</button>
Expand Down
44 changes: 44 additions & 0 deletions client/src/constants/socket-error-messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { SocketErrorCode } from '@troublepainter/core';
import { SocketNamespace } from '@/stores/socket/socket.config';

export const ERROR_MESSAGES: Record<SocketErrorCode, string> = {
// 클라이언트 에러 (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 '연결 오류';
}
};
43 changes: 34 additions & 9 deletions client/src/hooks/useCreateRoom.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
* 게임 방 생성을 위한 커스텀 훅입니다.
Expand Down Expand Up @@ -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<CreateRoomResponse | undefined> => {
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 };
};
15 changes: 13 additions & 2 deletions client/src/hooks/useDrawing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -102,13 +103,15 @@ const checkColorisNotEqual = (pos: number, startColor: RGBA, pixelArray: Uint8Cl
*/
const useDrawing = (canvasRef: RefObject<HTMLCanvasElement>, 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<DrawingMode>(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);

Expand Down Expand Up @@ -239,7 +242,15 @@ const useDrawing = (canvasRef: RefObject<HTMLCanvasElement>, 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 = [];
Expand Down
12 changes: 7 additions & 5 deletions client/src/pages/MainPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -29,10 +31,10 @@ const MainPage = () => {

<Button
onClick={() => void handleCreateRoom()}
disabled={createRoomMutation.isPending || isExiting}
disabled={isLoading || isExiting}
className="h-12 max-w-72 animate-pulse"
>
{createRoomMutation.isPending || isExiting ? '방 생성중...' : '방 만들기'}
{isLoading || isExiting ? '방 생성중...' : '방 만들기'}
</Button>
</main>
</PixelTransitionContainer>
Expand Down
22 changes: 17 additions & 5 deletions client/src/stores/socket/socket.config.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -111,7 +113,9 @@ type SocketCreator<T extends SocketType> = (auth?: SocketAuth) => T;
* @returns 생성된 소켓 인스턴스
*/
const createSocket = <T extends SocketType>(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;
};

Expand All @@ -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,
});
};

0 comments on commit 74ef94d

Please sign in to comment.