diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 6e73917..8330a7b 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,4 +1,3 @@ -# .github/pull_request_template.md ## πŸ” μž‘μ—… μœ ν˜• diff --git a/package-lock.json b/package-lock.json index 30534de..b56e06a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "axios": "^1.11.0", "browser-image-compression": "^2.0.2", "env": "^0.0.2", + "framer-motion": "^12.23.12", "imagemin": "^9.0.1", "imagemin-webp": "^8.0.0", "react": "^19.1.0", @@ -6113,6 +6114,33 @@ "node": ">= 6" } }, + "node_modules/framer-motion": { + "version": "12.23.12", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.12.tgz", + "integrity": "sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.23.12", + "motion-utils": "^12.23.6", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/from2": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", @@ -7394,6 +7422,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/motion-dom": { + "version": "12.23.12", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.12.tgz", + "integrity": "sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.23.6" + } + }, + "node_modules/motion-utils": { + "version": "12.23.6", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", + "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -8914,7 +8957,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "devOptional": true, "license": "0BSD" }, "node_modules/tunnel-agent": { diff --git a/package.json b/package.json index 1de4c03..a08bc0e 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "axios": "^1.11.0", "browser-image-compression": "^2.0.2", "env": "^0.0.2", + "framer-motion": "^12.23.12", "imagemin": "^9.0.1", "imagemin-webp": "^8.0.0", "react": "^19.1.0", diff --git a/src/App.tsx b/src/App.tsx index 0f2ded1..61b2267 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,11 @@ import { RouterProvider } from 'react-router-dom'; import router from './routes/router'; +import ToastContextProvider from './context/ToastContextProvider'; export default function App() { - return ; + return ( + + + + ); } diff --git a/src/api/fetchChapters.ts b/src/api/fetchChapters.ts index 00a5b7c..73c9a6b 100644 --- a/src/api/fetchChapters.ts +++ b/src/api/fetchChapters.ts @@ -1,6 +1,6 @@ import { ApiError } from '../types/@common/api'; import type { Chapter } from '../types/@common/chapter'; -import { transformChapters } from '../utils/transformChapter'; +import { transformChapters } from '../types/transformers/chapters'; export default async function fetchChapters(): Promise { const accessToken = localStorage.getItem('accessToken'); diff --git a/src/api/fetchUnits.ts b/src/api/fetchUnits.ts index 984bc4b..0c8fd09 100644 --- a/src/api/fetchUnits.ts +++ b/src/api/fetchUnits.ts @@ -3,7 +3,17 @@ import type { Unit } from '../types/@common/unit'; import transformUnits from '../types/transformers/units'; export default async function fetchUnits(id: number): Promise { + if (!Number.isFinite(id)) { + throw new ApiError('μœ νš¨ν•˜μ§€ μ•Šμ€ ν•™μŠ΅ IDμž…λ‹ˆλ‹€.', 'INVALID_ARGUMENT'); + } + const accessToken = localStorage.getItem('accessToken'); + + if (!accessToken) { + console.log('둜그인이 ν•„μš”ν•©λ‹ˆλ‹€.', 'UNAUTHORIZED'); + throw new ApiError('둜그인이 ν•„μš”ν•©λ‹ˆλ‹€.', 'UNAUTHORIZED'); + } + try { const response = await fetch(`https://grav-it.inuappcenter.kr/api/v1/learning/${id}/units`, { method: 'GET', diff --git a/src/components/@common/auth/ProtectedRoute.tsx b/src/components/@common/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..a6567fb --- /dev/null +++ b/src/components/@common/auth/ProtectedRoute.tsx @@ -0,0 +1,22 @@ +import { useEffect } from 'react'; +import { useAuthHandler } from '../../../hooks/useAuthHandler'; + +interface ProtectedRouteProps { + children: React.ReactNode; +} + +export default function ProtectedRoute({ children }: ProtectedRouteProps) { + const { checkAuthToken, redirectToLogin } = useAuthHandler(); + + useEffect(() => { + if (!checkAuthToken()) { + redirectToLogin(); + } + }, [checkAuthToken, redirectToLogin]); + + if (!checkAuthToken()) { + return null; + } + + return <>{children}; +} diff --git a/src/components/chapter-page/Tooltip.tsx b/src/components/chapter-page/Tooltip.tsx index ab0becd..695f5d7 100644 --- a/src/components/chapter-page/Tooltip.tsx +++ b/src/components/chapter-page/Tooltip.tsx @@ -4,9 +4,9 @@ function Tooltip({ chapterName, chapterNumber, xp }: { chapterName: string; chap return (
{/* 툴팁 꼬리 */} -
+
{/* 툴팁 λͺΈν†΅ */} -
+

{`${chapterName}: ${chapterNumber}챕터`}

void; +}; + +const ToastContext = createContext({ + showToast: () => {}, +}); + +export default ToastContext; diff --git a/src/context/ToastContextProvider.tsx b/src/context/ToastContextProvider.tsx new file mode 100644 index 0000000..8cf7a9c --- /dev/null +++ b/src/context/ToastContextProvider.tsx @@ -0,0 +1,53 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type PropsWithChildren } from 'react'; +import type { ToastType } from '../types/@common/toast'; +import ToastContext from './ToastContext'; +import { AnimatePresence, motion } from 'framer-motion'; + +function ToastContextProvider({ children }: PropsWithChildren) { + const [toast, setToast] = useState(null); + const timeoutRef = useRef | null>(null); + + const showToast = useCallback(({ message, duration = 2 }: ToastType) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + setToast({ message, duration }); + + timeoutRef.current = setTimeout(() => { + setToast(null); + timeoutRef.current = null; + }, duration * 1000); + }, []); + + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + const contextValue = useMemo(() => ({ showToast }), [showToast]); + + return ( + + {children} + + {toast && ( + + {toast.message} + + )} + + + ); +} + +export default ToastContextProvider; diff --git a/src/hooks/useAuthHandler.tsx b/src/hooks/useAuthHandler.tsx new file mode 100644 index 0000000..d11d2af --- /dev/null +++ b/src/hooks/useAuthHandler.tsx @@ -0,0 +1,34 @@ +import { useNavigate } from 'react-router-dom'; +import useToast from './useToast'; +import { useCallback } from 'react'; +import { ApiError } from '../types/@common/api'; + +export function useAuthHandler() { + const navigate = useNavigate(); + const showToast = useToast(); + + const redirectToLogin = useCallback(() => { + showToast({ message: '둜그인 ν›„ μ΄μš©ν•  수 μžˆλŠ” μ„œλΉ„μŠ€μž…λ‹ˆλ‹€.' }); + navigate('/', { replace: true }); + }, [navigate, showToast]); + + const handleApiError = useCallback( + (error: unknown) => { + if (error instanceof ApiError) { + if (['UNAUTHORIZED', 'FORBIDDEN'].includes(error.error)) { + redirectToLogin(); + return true; + } + return false; + } + return false; + }, + [redirectToLogin] + ); + + const checkAuthToken = useCallback(() => { + return !!localStorage.getItem('accessToken'); + }, []); + + return { handleApiError, checkAuthToken, redirectToLogin }; +} diff --git a/src/hooks/useToast.tsx b/src/hooks/useToast.tsx new file mode 100644 index 0000000..87fdf1d --- /dev/null +++ b/src/hooks/useToast.tsx @@ -0,0 +1,8 @@ +import { useContext } from 'react'; +import ToastContext from '../context/ToastContext'; +import type { ToastType } from '../types/@common/toast'; + +export default function useToast() { + const { showToast } = useContext(ToastContext); + return (toast: ToastType) => showToast(toast); +} diff --git a/src/pages/ChapterDetailPage.tsx b/src/pages/ChapterDetailPage.tsx index 5dd16cf..3246098 100644 --- a/src/pages/ChapterDetailPage.tsx +++ b/src/pages/ChapterDetailPage.tsx @@ -2,14 +2,16 @@ import backgroundImg from '@/assets/images/background.webp'; import defaultPlanet from '@/assets/images/planets/Moon.png'; import { useParams } from 'react-router-dom'; import { getPlanetImage } from '../constants/planet-image'; - import MascotSvg from '@/assets/mascot/mascot-space-suit.svg?react'; import Line from '@/assets/mascot/line.svg?react'; import CircularSegmentIndicator from '../components/chapter-page/CircularSegmentIndicator'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import Tooltip from '../components/chapter-page/Tooltip'; import fetchUnits from '../api/fetchUnits'; import { useQuery } from '@tanstack/react-query'; +import shouldRetryApiRequest from '../utils/api-retry'; +import { useAuthHandler } from '../hooks/useAuthHandler'; +import { ApiError } from '../types/@common/api'; const positions = [ { x: 70, y: 170 }, @@ -23,33 +25,40 @@ const positions = [ function ChapterDetailPage() { const { chapterId } = useParams(); + const { handleApiError } = useAuthHandler(); const [tooltip, setTooltip] = useState(null); - const { data, isPending, isError, error } = useQuery({ + const { + data: units, + isPending, + isError, + error, + } = useQuery({ queryKey: ['unit-list', { id: chapterId }], queryFn: () => fetchUnits(Number(chapterId)), + retry: shouldRetryApiRequest, }); // μ΅œλŒ€ y μ’Œν‘œ + μ—¬μœ  κ³΅κ°„μœΌλ‘œ μ‹€μ œ 높이 계산 const maxY = Math.max(...positions.map((pos) => pos.y)); const contentHeight = maxY + 200; // μ—¬μœ  곡간 μΆ”κ°€ - if (isPending) { -
λ‘œλ”©μ€‘
; - } + useEffect(() => { + if (isError && error instanceof ApiError) { + handleApiError(error); + } + }, [isError, error, handleApiError]); - if (isError) { -
{error.message}
; + if (isPending) { + return
λ‘œλ”©μ€‘
; } - const units = data; - - if (!data) { - return
; + if (!units) { + return
μœ λ‹› 정보가 μ—†μŠ΅λ‹ˆλ‹€.
; } - if (data) { - console.log(data); + if (units) { + console.log(units); } return ( diff --git a/src/pages/ChapterListPage.tsx b/src/pages/ChapterListPage.tsx index c423085..4d36180 100644 --- a/src/pages/ChapterListPage.tsx +++ b/src/pages/ChapterListPage.tsx @@ -3,19 +3,27 @@ import Banner from '../components/@common/banner/Banner'; import ChapterCard from '../components/study-page/ChapterCard'; import fetchChapters from '../api/fetchChapters'; import { Link } from 'react-router-dom'; +import shouldRetryApiRequest from '../utils/api-retry'; +import { useAuthHandler } from '../hooks/useAuthHandler'; +import { useEffect } from 'react'; +import { ApiError } from '../types/@common/api'; function ChapterListPage() { + const { handleApiError } = useAuthHandler(); const { data, isPending, isError, error } = useQuery({ - queryKey: ['learning'], + queryKey: ['chapter-list'], queryFn: fetchChapters, + retry: shouldRetryApiRequest, }); - if (isPending) { - return
νŒ¨μΉ­μ€‘
; - } + useEffect(() => { + if (isError && error instanceof ApiError) { + handleApiError(error); + } + }, [isError, error, handleApiError]); - if (isError) { - return
{error.message}
; + if (isPending) { + return
λ‘œλ”©μ€‘
; } return ( diff --git a/src/pages/Error.tsx b/src/pages/Error.tsx new file mode 100644 index 0000000..d6ea0ed --- /dev/null +++ b/src/pages/Error.tsx @@ -0,0 +1,195 @@ +import { useRouteError, isRouteErrorResponse } from 'react-router-dom'; +import { ApiError } from '../types/@common/api'; + +// 404 μ—λŸ¬ μ»΄ν¬λ„ŒνŠΈ +const Error404Component = () => ( +
+
+
+ 4 + 😡 + 4 +
+

νŽ˜μ΄μ§€λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.

+

μž…λ ₯ν•œ μ£Όμ†Œκ°€ μ •ν™•ν•œμ§€, νŽ˜μ΄μ§€κ°€ μ΄λ™ν•˜κ±°λ‚˜ μ‚­μ œλ˜μ§€λŠ” μ•Šμ•˜λ‚˜μš”?

+

+ μ£Όμ†Œλ₯Ό λ‹€μ‹œ ν™•μΈν•˜κ±°λ‚˜, ν™ˆμœΌλ‘œ λŒμ•„κ°€ μ„œλΉ„μŠ€λ₯Ό λ‹€μ‹œ μ΄μš©ν•΄ μ£Όμ„Έμš”. +

+ +
+ + +
+
+
+); + +// 401 μ—λŸ¬ μ»΄ν¬λ„ŒνŠΈ +const Error401Component = () => ( +
+
+
+ 4 + πŸ”’ + 1 +
+

μ ‘κ·Ό κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€.

+

이 νŽ˜μ΄μ§€λŠ” λ‘œκ·ΈμΈν•œ μƒνƒœμ—μ„œλ§Œ μ΄μš©ν•  수 μžˆμŠ΅λ‹ˆλ‹€.

+

κ³„μ†ν•˜μ‹œλ €λ©΄ 둜그인 ν›„ λ‹€μ‹œ μ‹œλ„ν•΄ μ£Όμ„Έμš”.

+ +
+ + +
+
+
+); + +// API μ—λŸ¬ μ»΄ν¬λ„ŒνŠΈ (μƒˆλ‘œ μΆ”κ°€) +const ApiErrorComponent = ({ apiError }: { apiError: ApiError }) => ( +
+
+
πŸ”Œ
+

API μ—°κ²° 였λ₯˜

+

{apiError.message}

+ {apiError.error && ( +
+

{apiError.error}

+
+ )} +

λ„€νŠΈμ›Œν¬ μƒνƒœλ₯Ό ν™•μΈν•˜κ±°λ‚˜ μž μ‹œ ν›„ λ‹€μ‹œ μ‹œλ„ν•΄ μ£Όμ„Έμš”.

+ +
+ + +
+
+
+); + +// κΈ°λ³Έ μ—λŸ¬ μ»΄ν¬λ„ŒνŠΈ (κ°œμ„ ) +const DefaultErrorComponent = ({ + title, + message, + errorDetails, +}: { + title: string; + message: string; + errorDetails?: string; +}) => ( +
+
+
⚠️
+

{title}

+

{message}

+ + {errorDetails && ( +
+

{errorDetails}

+
+ )} + +
+ + +
+
+
+); + +// ApiError인지 ν™•μΈν•˜λŠ” νƒ€μž… κ°€λ“œ +function isApiError(error: unknown): error is ApiError { + return error instanceof ApiError || (error instanceof Error && error.name === 'ApiError'); +} + +export default function ErrorPage() { + const error = useRouteError(); + + console.error('Route Error:', error); + + // 1. RouteErrorResponse 처리 (HTTP μƒνƒœ μ½”λ“œ) + if (isRouteErrorResponse(error)) { + switch (error.status) { + case 404: + return ; + case 401: + return ; + case 500: { + const serverMessage = error.statusText ? error.statusText : 'Internal server error'; + return ( + + ); + } + default: + return ( + + ); + } + } + + // 2. ApiError 처리 (μ»€μŠ€ν…€ API μ—λŸ¬) + if (isApiError(error)) { + return ; + } + + // 3. 일반 Error 처리 + if (error instanceof Error) { + return ( + + ); + } + + // 4. μ•Œ 수 μ—†λŠ” μ—λŸ¬ + return ; +} diff --git a/src/pages/MainPage.tsx b/src/pages/MainPage.tsx index d6f56ad..7de48af 100644 --- a/src/pages/MainPage.tsx +++ b/src/pages/MainPage.tsx @@ -7,20 +7,32 @@ import UserStats from '../components/@common/level-info/UserStats'; import { useQuery } from '@tanstack/react-query'; import fetchMainInfo from '../api/fetchMainInfo'; import RecentLearningSection from '../components/main-page/RecentLearningSection'; +import shouldRetryApiRequest from '../utils/api-retry'; +import { useAuthHandler } from '../hooks/useAuthHandler'; +import { useEffect } from 'react'; +import { ApiError } from '../types/@common/api'; function MainPage() { + const { handleApiError } = useAuthHandler(); + const { data, isPending, isError, error } = useQuery({ queryKey: ['main-info'], queryFn: fetchMainInfo, + retry: shouldRetryApiRequest, }); + useEffect(() => { + if (isError && error instanceof ApiError) { + handleApiError(error); + } + }, [isError, error, handleApiError]); + if (isPending) { return
λ‘œλ”©μ€‘
; } - if (isError) { -
; - return
{error.message}
; + if (!data) { + return
데이터가 μ—†μŠ΅λ‹ˆλ‹€.
; } const { nickname, level, xp, league, recentLearningSummaryResponse: recentData } = data; diff --git a/src/routes/router.tsx b/src/routes/router.tsx index 655ffe6..5784177 100644 --- a/src/routes/router.tsx +++ b/src/routes/router.tsx @@ -2,6 +2,7 @@ import { createBrowserRouter } from 'react-router-dom'; import WithHeaderLayout from '../layouts/WithHeaderLayout'; import NoHeaderLayout from '../layouts/NoHeaderLayout'; import LoginPage from '../pages/LoginPage'; // 첫 νŽ˜μ΄μ§€λ§Œ μ¦‰μ‹œ λ‘œλ”© +import ProtectedRoute from '../components/@common/auth/ProtectedRoute'; import { lazy, Suspense } from 'react'; // λͺ¨λ“  νŽ˜μ΄μ§€λ₯Ό lazy loading으둜 λ³€κ²½ @@ -14,6 +15,7 @@ const SetInfoPage = lazy(() => import('../pages/SetInfoPage')); const SuccessPage = lazy(() => import('../pages/SuccessPage')); const UserPage = lazy(() => import('../pages/UserPage')); const PostOAuth = lazy(() => import('../api/PostOAuth')); +const ErrorPage = lazy(() => import('../pages/Error')); // 곡톡 λ‘œλ”© μ»΄ν¬λ„ŒνŠΈ const PageLoader = () => ( @@ -33,6 +35,11 @@ const LazyPage = ({ children }: { children: React.ReactNode }) => ( const router = createBrowserRouter([ { path: '/', + errorElement: ( + + + + ), children: [ { element: , @@ -68,7 +75,9 @@ const router = createBrowserRouter([ path: 'study/:chapterId', element: ( - + + + ), }, @@ -81,7 +90,9 @@ const router = createBrowserRouter([ path: 'main', element: ( - + + + ), }, @@ -89,7 +100,9 @@ const router = createBrowserRouter([ path: 'study', element: ( - + + + ), }, @@ -118,7 +131,9 @@ const router = createBrowserRouter([ path: 'lesson', element: ( - + + + ), }, diff --git a/src/types/@common/toast.ts b/src/types/@common/toast.ts new file mode 100644 index 0000000..100661f --- /dev/null +++ b/src/types/@common/toast.ts @@ -0,0 +1,4 @@ +export type ToastType = { + message: string; + duration?: number; +}; diff --git a/src/utils/transformChapter.ts b/src/types/transformers/chapters.ts similarity index 68% rename from src/utils/transformChapter.ts rename to src/types/transformers/chapters.ts index 1d56e80..401001b 100644 --- a/src/utils/transformChapter.ts +++ b/src/types/transformers/chapters.ts @@ -1,6 +1,6 @@ -import type { PlanetId } from '../constants/planet-image'; -import type { Chapter } from '../types/@common/chapter'; -import type { ChapterResponse } from '../types/api/chapter'; +import type { PlanetId } from '../../constants/planet-image'; +import type { Chapter } from '../@common/chapter'; +import type { ChapterResponse } from '../api/chapter'; export const transformChapter = (response: ChapterResponse): Chapter => ({ id: response.chapterId as PlanetId, diff --git a/src/utils/api-retry.ts b/src/utils/api-retry.ts new file mode 100644 index 0000000..7160866 --- /dev/null +++ b/src/utils/api-retry.ts @@ -0,0 +1,13 @@ +import { ApiError } from '../types/@common/api'; + +export default function shouldRetryApiRequest(failureCount: number, error: Error) { + if (error instanceof ApiError) { + if (['UNAUTHORIZED', 'FORBIDDEN'].includes(error.error)) { + return false; + } + if (error.error === 'NETWORK_ERROR') { + return failureCount < 2; + } + } + return false; +}