Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
# .github/pull_request_template.md

## 🔍 작업 유형

Expand Down
44 changes: 43 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 6 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
@@ -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 <RouterProvider router={router} />;
return (
<ToastContextProvider>
<RouterProvider router={router} />
</ToastContextProvider>
);
}
2 changes: 1 addition & 1 deletion src/api/fetchChapters.ts
Original file line number Diff line number Diff line change
@@ -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<Chapter[]> {
const accessToken = localStorage.getItem('accessToken');
Expand Down
10 changes: 10 additions & 0 deletions src/api/fetchUnits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Unit[]> {
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',
Expand Down
22 changes: 22 additions & 0 deletions src/components/@common/auth/ProtectedRoute.tsx
Original file line number Diff line number Diff line change
@@ -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}</>;
}
4 changes: 2 additions & 2 deletions src/components/chapter-page/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ function Tooltip({ chapterName, chapterNumber, xp }: { chapterName: string; chap
return (
<div className="absolute transform top-full left-1/2 -translate-x-1/2 translate-y-3">
{/* 툴팁 꼬리 */}
<div className="w-0 h-0 mx-auto border-l-[12px] border-r-[12px] border-b-[12px] border-transparent border-b-[#FFB608]" />
<div className="absolute top-0 left-1/2 transform -translate-x-1/2 -translate-y-1/2 h-7 w-7 bg-[#FFB608] border-l border-t border-transparent rotate-45 z-40" />
{/* 툴팁 몸통 */}
<div className="bg-[#FFB608] flex flex-col p-4 w-[371px] h-[130px] rounded-2xl justify-between z-50">
<div className="bg-[#FFB608] flex flex-col p-4 w-[371px] h-[130px] rounded-2xl justify-between z-50 transform -translate-x-1/2 left-1/2 relative">
<h2 className="text-2xl font-bold text-white text-start">{`${chapterName}: ${chapterNumber}챕터`}</h2>
<Link
to="/lesson"
Expand Down
12 changes: 12 additions & 0 deletions src/context/ToastContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { createContext } from 'react';
import type { ToastType } from '../types/@common/toast';

type ToastContextType = {
showToast: (toast: ToastType) => void;
};

const ToastContext = createContext<ToastContextType>({
showToast: () => {},
});

export default ToastContext;
53 changes: 53 additions & 0 deletions src/context/ToastContextProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<ToastType | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | 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 (
<ToastContext.Provider value={contextValue}>
{children}
<AnimatePresence>
{toast && (
<motion.div
initial={{ opacity: 0, y: -20, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -20, scale: 0.9 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
className="fixed top-1/6 left-1/2 -translate-x-1/2 z-[1000] bg-main-2 text-white rounded-full px-3 py-2"
>
{toast.message}
</motion.div>
)}
</AnimatePresence>
</ToastContext.Provider>
);
}

export default ToastContextProvider;
34 changes: 34 additions & 0 deletions src/hooks/useAuthHandler.tsx
Original file line number Diff line number Diff line change
@@ -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 };
}
8 changes: 8 additions & 0 deletions src/hooks/useToast.tsx
Original file line number Diff line number Diff line change
@@ -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);
}
37 changes: 23 additions & 14 deletions src/pages/ChapterDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -23,33 +25,40 @@ const positions = [

function ChapterDetailPage() {
const { chapterId } = useParams();
const { handleApiError } = useAuthHandler();
const [tooltip, setTooltip] = useState<number | null>(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,
});
Comment on lines +31 to 40
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Guard query with enabled; prevent NaN requests and add cache window.

When chapterId is undefined or non-numeric, fetchUnits throws INVALID_ARGUMENT. Gate the query and optionally cache results.

   } = useQuery({
     queryKey: ['unit-list', { id: chapterId }],
     queryFn: () => fetchUnits(Number(chapterId)),
-    retry: shouldRetryApiRequest,
+    enabled: Number.isFinite(Number(chapterId)),
+    staleTime: 60_000,
+    retry: shouldRetryApiRequest,
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const {
data: units,
isPending,
isError,
error,
} = useQuery({
queryKey: ['unit-list', { id: chapterId }],
queryFn: () => fetchUnits(Number(chapterId)),
retry: shouldRetryApiRequest,
});
const {
data: units,
isPending,
isError,
error,
} = useQuery({
queryKey: ['unit-list', { id: chapterId }],
queryFn: () => fetchUnits(Number(chapterId)),
enabled: Number.isFinite(Number(chapterId)),
staleTime: 60_000,
retry: shouldRetryApiRequest,
});
🤖 Prompt for AI Agents
In src/pages/ChapterDetailPage.tsx around lines 31 to 40, the useQuery is firing
even when chapterId is undefined or non-numeric which causes fetchUnits to throw
INVALID_ARGUMENT; guard the query with an enabled flag (e.g. enabled:
Boolean(chapterId) && !Number.isNaN(Number(chapterId))) so it only runs for
valid IDs, convert chapterId to a number inside the queryFn (or in the queryKey)
to avoid NaN, and add a cache window (staleTime or cacheTime) to reduce repeated
calls (for example set staleTime/cacheTime to a few minutes) so results are
cached while the page remains active.


// 최대 y 좌표 + 여유 공간으로 실제 높이 계산
const maxY = Math.max(...positions.map((pos) => pos.y));
const contentHeight = maxY + 200; // 여유 공간 추가

if (isPending) {
<div>로딩중</div>;
}
useEffect(() => {
if (isError && error instanceof ApiError) {
handleApiError(error);
}
}, [isError, error, handleApiError]);

if (isError) {
<div>{error.message}</div>;
if (isPending) {
return <div>로딩중</div>;
}

const units = data;

if (!data) {
return <div></div>;
if (!units) {
return <div>유닛 정보가 없습니다.</div>;
}

if (data) {
console.log(data);
if (units) {
console.log(units);
}

return (
Expand Down
20 changes: 14 additions & 6 deletions src/pages/ChapterListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <div>패칭중</div>;
}
useEffect(() => {
if (isError && error instanceof ApiError) {
handleApiError(error);
}
}, [isError, error, handleApiError]);

if (isError) {
return <div>{error.message}</div>;
if (isPending) {
return <div>로딩중</div>;
}

return (
Expand Down
Loading