From 7adaf15cff745df3b43a5978ce63b703039dc17b Mon Sep 17 00:00:00 2001 From: uiuuoq Date: Thu, 30 Apr 2026 19:51:36 +0900 Subject: [PATCH] =?UTF-8?q?DP-420:=20=EB=A7=88=EC=9D=B4=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=8A=A4=ED=81=AC=EB=9E=A9/=ED=80=B4=EC=A6=88=20?= =?UTF-8?q?=ED=9E=88=EC=8A=A4=ED=86=A0=EB=A6=AC=20mock=20=E2=86=92=20?= =?UTF-8?q?=EC=8B=A4=EC=A0=9C=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/home/[id]/quiz/result/page.tsx | 22 +- .../my-page/quizzes/WrongQuizListWrapper.tsx | 57 ++-- .../my-page/quizzes/WrongQuizSection.tsx | 41 ++- .../my-page/scraps/ScrappedPostsList.tsx | 43 +-- .../my-page/scraps/ScrappedPostsSection.tsx | 25 +- lib/api/endpoints/quizzes.ts | 8 + lib/api/endpoints/users.ts | 21 ++ lib/mock/my-page-scraps.ts | 152 ---------- lib/mock/my-page-wrong-quizzes.ts | 284 ------------------ 9 files changed, 103 insertions(+), 550 deletions(-) delete mode 100644 lib/mock/my-page-scraps.ts delete mode 100644 lib/mock/my-page-wrong-quizzes.ts diff --git a/app/(main)/home/[id]/quiz/result/page.tsx b/app/(main)/home/[id]/quiz/result/page.tsx index d15df0a..f60df96 100644 --- a/app/(main)/home/[id]/quiz/result/page.tsx +++ b/app/(main)/home/[id]/quiz/result/page.tsx @@ -1,12 +1,12 @@ "use client"; -import { Suspense, useEffect, useState } from "react"; +import { Suspense } from "react"; import { useParams, useSearchParams, useRouter } from "next/navigation"; import Link from "next/link"; import { ArrowLeft, Loader2, AlertCircle } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; import { QuizResult } from "@/components/features/home/quiz/QuizResult"; -import { fetchMyQuizHistoryDetail } from "@/lib/mock/my-page-wrong-quizzes"; -import type { QuizHistoryDetail } from "@/types/myPage"; +import { getQuizHistoryDetail } from "@/lib/api/endpoints/quizzes"; function QuizResultContent() { const params = useParams<{ id: string }>(); @@ -16,17 +16,11 @@ function QuizResultContent() { const contentId = params.id; const attemptId = searchParams.get("attemptId"); - const [detail, setDetail] = useState(null); - const [isLoading, setIsLoading] = useState(!!attemptId); - const [isError, setIsError] = useState(!attemptId); - - useEffect(() => { - if (!attemptId) return; - fetchMyQuizHistoryDetail(attemptId) - .then(setDetail) - .catch(() => setIsError(true)) - .finally(() => setIsLoading(false)); - }, [attemptId]); + const { data: detail, isLoading, isError } = useQuery({ + queryKey: ["quizHistoryDetail", attemptId], + queryFn: () => getQuizHistoryDetail(attemptId!), + enabled: !!attemptId, + }); if (isLoading) { return ( diff --git a/components/features/my-page/quizzes/WrongQuizListWrapper.tsx b/components/features/my-page/quizzes/WrongQuizListWrapper.tsx index 6222f7d..f44564f 100644 --- a/components/features/my-page/quizzes/WrongQuizListWrapper.tsx +++ b/components/features/my-page/quizzes/WrongQuizListWrapper.tsx @@ -1,60 +1,37 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { WrongQuizList } from "./WrongQuizList"; import { WrongQuizListItemSkeleton } from "./WrongQuizListItemSkeleton"; -import { fetchMyQuizHistory } from "@/lib/mock/my-page-wrong-quizzes"; -import type { MyPageQuizHistoryResponse } from "@/types/myPage"; +import { getMyQuizHistory } from "@/lib/api/endpoints/users"; type SortOrder = "newest" | "oldest"; export function WrongQuizListWrapper() { - const [data, setData] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [isError, setIsError] = useState(false); const [sort, setSort] = useState("newest"); const [page, setPage] = useState(0); - useEffect(() => { - let cancelled = false; - - fetchMyQuizHistory({ sort, page, size: 10, wrongOnly: true }) - .then((res) => { - if (!cancelled) setData(res); - }) - .catch(() => { - if (!cancelled) setIsError(true); - }) - .finally(() => { - if (!cancelled) setIsLoading(false); - }); - - return () => { - cancelled = true; - }; - }, [sort, page]); + const { data, isLoading, isError } = useQuery({ + queryKey: ["myQuizHistory", sort, page], + queryFn: () => + getMyQuizHistory({ + passed: false, + sort, + page, + size: 10, + }), + }); const handleSortChange = (value: SortOrder) => { setSort(value); setPage(0); - setIsLoading(true); - setIsError(false); }; const handlePageChange = (nextPage: number) => { setPage(nextPage - 1); - setIsLoading(true); - setIsError(false); }; - if (isError) { - return ( -

- 불러오는 중 오류가 발생했습니다. -

- ); - } - if (isLoading) { return (
@@ -65,6 +42,14 @@ export function WrongQuizListWrapper() { ); } + if (isError) { + return ( +

+ 불러오는 중 오류가 발생했습니다. +

+ ); + } + return ( ([]); - const [isLoading, setIsLoading] = useState(true); - const [isError, setIsError] = useState(false); + const { data, isLoading, isError } = useQuery({ + queryKey: ["myQuizHistoryPreview"], + queryFn: () => + getMyQuizHistory({ + passed: false, + page: 0, + size: 4, + sort: "newest", + }), + }); - useEffect(() => { - fetchMyWrongQuizzesPreview(4) - .then((data) => { - setQuizzes(data); - }) - .catch(() => { - setIsError(true); - }) - .finally(() => { - setIsLoading(false); - }); - }, []); + const quizzes = data?.content ?? []; return (
@@ -39,11 +34,7 @@ export function WrongQuizSection() {
- {isError ? ( -

- 불러오는 중 오류가 발생했습니다. -

- ) : isLoading ? ( + {isLoading ? (
{Array.from({ length: 4 }).map((_, i) => (
))}
+ ) : isError ? ( +

+ 불러오는 중 오류가 발생했습니다. +

) : quizzes.length === 0 ? (

틀린 퀴즈가 없습니다.

) : ( diff --git a/components/features/my-page/scraps/ScrappedPostsList.tsx b/components/features/my-page/scraps/ScrappedPostsList.tsx index 7d6f046..f6cbb8c 100644 --- a/components/features/my-page/scraps/ScrappedPostsList.tsx +++ b/components/features/my-page/scraps/ScrappedPostsList.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { ChevronDown, Search } from "lucide-react"; import { Skeleton } from "@/components/ui/skeleton"; import { @@ -10,10 +10,10 @@ import { DropdownMenuRadioItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { useQuery } from "@tanstack/react-query"; import { ScrappedPostListItem } from "./ScrappedPostListItem"; import { MyPagePagination } from "../MyPagePagination"; -import { fetchMyScraps } from "@/lib/mock/my-page-scraps"; -import type { MyPageScrapResponse } from "@/types/myPage"; +import { getMyScraps } from "@/lib/api/endpoints/users"; type SortOrder = "newest" | "oldest"; @@ -32,50 +32,33 @@ function ListItemSkeleton() { } export function ScrappedPostsList() { - const [data, setData] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [isError, setIsError] = useState(false); const [query, setQuery] = useState(""); const [sort, setSort] = useState("newest"); const [page, setPage] = useState(0); - useEffect(() => { - let cancelled = false; - - fetchMyScraps({ q: query || undefined, sort, page, size: 10 }) - .then((res) => { - if (!cancelled) setData(res); - }) - .catch(() => { - if (!cancelled) setIsError(true); - }) - .finally(() => { - if (!cancelled) setIsLoading(false); - }); - - return () => { - cancelled = true; - }; - }, [query, sort, page]); + const { data, isLoading, isError } = useQuery({ + queryKey: ["myScraps", query.trim(), sort, page], + queryFn: () => + getMyScraps({ + q: query.trim() || undefined, + sort, + page, + size: 10, + }), + }); const handleQueryChange = (value: string) => { setQuery(value); setPage(0); - setIsLoading(true); - setIsError(false); }; const handleSortChange = (value: string) => { setSort(value as SortOrder); setPage(0); - setIsLoading(true); - setIsError(false); }; const handlePageChange = (nextPage: number) => { setPage(nextPage - 1); - setIsLoading(true); - setIsError(false); }; if (isLoading) { diff --git a/components/features/my-page/scraps/ScrappedPostsSection.tsx b/components/features/my-page/scraps/ScrappedPostsSection.tsx index 0e1bba1..74685c3 100644 --- a/components/features/my-page/scraps/ScrappedPostsSection.tsx +++ b/components/features/my-page/scraps/ScrappedPostsSection.tsx @@ -1,23 +1,24 @@ "use client"; -import { useEffect, useState } from "react"; import Link from "next/link"; import { ArrowRight } from "lucide-react"; import { Skeleton } from "@/components/ui/skeleton"; +import { useQuery } from "@tanstack/react-query"; import { ScrappedPostCard } from "./ScrappedPostCard"; -import { fetchMyScrapsPreview } from "@/lib/mock/my-page-scraps"; -import type { MyPageScrap } from "@/types/myPage"; +import { getMyScraps } from "@/lib/api/endpoints/users"; export function ScrappedPostsSection() { - const [scraps, setScraps] = useState([]); - const [isLoading, setIsLoading] = useState(true); + const { data, isLoading, isError } = useQuery({ + queryKey: ["myScrapsPreview"], + queryFn: () => + getMyScraps({ + page: 0, + size: 4, + sort: "newest", + }), + }); - useEffect(() => { - fetchMyScrapsPreview(4).then((res) => { - setScraps(res.content); - setIsLoading(false); - }); - }, []); + const scraps = data?.content ?? []; return (
@@ -51,6 +52,8 @@ export function ScrappedPostsSection() {
))} + ) : isError ? ( +

불러오는 중 오류가 발생했습니다.

) : scraps.length === 0 ? (

스크랩한 글이 없습니다.

) : ( diff --git a/lib/api/endpoints/quizzes.ts b/lib/api/endpoints/quizzes.ts index 85637f0..9888b4a 100644 --- a/lib/api/endpoints/quizzes.ts +++ b/lib/api/endpoints/quizzes.ts @@ -7,6 +7,7 @@ import type { QuizSubmitResponse, QuizLevel, } from "@/types/quiz"; +import type { QuizHistoryDetail } from "@/types/myPage"; export const quizzesEndpoints = { /** @@ -49,3 +50,10 @@ export const quizzesEndpoints = { .then((r) => r.data); }, }; + +export async function getQuizHistoryDetail( + attemptId: string, +): Promise { + const res = await apiClient.get(`/quiz-history/${attemptId}`); + return res.data.data; +} diff --git a/lib/api/endpoints/users.ts b/lib/api/endpoints/users.ts index d61da4c..7c0a06e 100644 --- a/lib/api/endpoints/users.ts +++ b/lib/api/endpoints/users.ts @@ -1,6 +1,7 @@ import { apiClient } from "../client"; import type { ApiResponse } from "@/types/api"; import type { UserProfileResponse } from "@/types/userProfile"; +import type { MyPageScrapResponse, MyPageQuizHistoryResponse } from "@/types/myPage"; export interface UpdateMeRequest { nickname?: string; @@ -47,3 +48,23 @@ export const usersEndpoints = { .then((r) => r.data); }, }; + +export async function getMyQuizHistory(params?: { + sort?: "newest" | "oldest"; + page?: number; + size?: number; + passed?: boolean; +}): Promise { + const res = await apiClient.get("/users/me/quiz-history", { params }); + return res.data.data; +} + +export async function getMyScraps(params?: { + q?: string; + sort?: "newest" | "oldest"; + page?: number; + size?: number; +}): Promise { + const res = await apiClient.get("/users/me/scraps", { params }); + return res.data.data; +} diff --git a/lib/mock/my-page-scraps.ts b/lib/mock/my-page-scraps.ts deleted file mode 100644 index 2c137e4..0000000 --- a/lib/mock/my-page-scraps.ts +++ /dev/null @@ -1,152 +0,0 @@ -import type { MyPageScrap, MyPageScrapResponse } from "@/types/myPage"; - -export const MOCK_SCRAPS: MyPageScrap[] = [ - { - contentId: "content-101", - title: "React 19의 새로운 기능들: useActionState, useFormStatus 완벽 정리", - sourceName: "medium", - thumbnail: "https://picsum.photos/seed/scrap1/400/240", - summary: - "React 19에서 도입된 훅들과 Server Actions 통합 방식을 예제 코드와 함께 살펴봅니다.", - createdAt: "2026-04-20T09:15:00Z", - }, - { - contentId: "content-102", - title: "TypeScript 5.5 주요 변경사항 — infer 키워드 개선과 strictness 강화", - sourceName: "naver_d2", - thumbnail: "https://picsum.photos/seed/scrap2/400/240", - summary: - "TypeScript 5.5의 핵심 업데이트를 마이그레이션 관점에서 정리한 글입니다.", - createdAt: "2026-04-18T14:30:00Z", - }, - { - contentId: "content-103", - title: - "Next.js App Router에서 Server Component와 Client Component 경계 설계하기", - sourceName: "kakao_tech", - thumbnail: "https://picsum.photos/seed/scrap3/400/240", - summary: null, - createdAt: "2026-04-15T11:00:00Z", - }, - { - contentId: "content-104", - title: "PostgreSQL EXPLAIN ANALYZE 읽는 법 — 쿼리 최적화 실전 가이드", - sourceName: "stack overflow", - thumbnail: null, - summary: "슬로우 쿼리를 분석하고 인덱스 전략을 수립하는 방법을 다룹니다.", - createdAt: "2026-04-13T08:45:00Z", - }, - { - contentId: "content-105", - title: "Docker Compose로 로컬 개발 환경 구성하기 — 실전 템플릿 공개", - sourceName: "velog", - thumbnail: "https://picsum.photos/seed/scrap5/400/240", - summary: null, - createdAt: "2026-04-10T16:20:00Z", - }, - { - contentId: "content-106", - title: "REST API 설계 원칙 — 버저닝 전략과 에러 응답 포맷 표준화", - sourceName: "medium", - thumbnail: "https://picsum.photos/seed/scrap6/400/240", - summary: - "실무에서 자주 마주치는 API 설계 결정들을 사례 중심으로 설명합니다.", - createdAt: "2026-04-08T10:00:00Z", - }, - { - contentId: "content-107", - title: "Redis 캐싱 전략 비교 — Cache-Aside, Write-Through, Write-Behind", - sourceName: "toss_tech", - thumbnail: null, - summary: null, - createdAt: "2026-04-05T13:10:00Z", - }, - { - contentId: "content-108", - title: "Zustand v5 마이그레이션 가이드 — 스토어 구조와 미들웨어 변경점", - sourceName: "velog", - thumbnail: "https://picsum.photos/seed/scrap8/400/240", - summary: - "Zustand v4에서 v5로 업그레이드할 때 꼭 확인해야 할 변경사항들을 정리했습니다.", - createdAt: "2026-04-02T09:30:00Z", - }, - { - contentId: "content-109", - title: "Tailwind CSS v4 마이그레이션 — @theme 토큰과 CSS 변수 전환", - sourceName: "kakao_tech", - thumbnail: "https://picsum.photos/seed/scrap9/400/240", - summary: - "v3에서 v4로 넘어갈 때 바뀐 설정 방식과 토큰 시스템을 정리합니다.", - createdAt: "2026-03-30T10:00:00Z", - }, - { - contentId: "content-110", - title: "Kubernetes 입문 — Pod, Service, Deployment 핵심 개념 정리", - sourceName: "naver_d2", - thumbnail: null, - summary: "컨테이너 오케스트레이션의 핵심 리소스를 예제와 함께 설명합니다.", - createdAt: "2026-03-27T14:00:00Z", - }, - { - contentId: "content-111", - title: "웹 성능 최적화 — Core Web Vitals 개선 실전 사례", - sourceName: "toss_tech", - thumbnail: "https://picsum.photos/seed/scrap11/400/240", - summary: "LCP, CLS, INP 지표를 실제 서비스에서 개선한 경험을 공유합니다.", - createdAt: "2026-03-24T09:00:00Z", - }, - { - contentId: "content-112", - title: "Git 브랜치 전략 — Trunk Based Development vs Git Flow 비교", - sourceName: "medium", - thumbnail: "https://picsum.photos/seed/scrap12/400/240", - summary: null, - createdAt: "2026-03-20T11:30:00Z", - }, -]; - -type FetchMyScrapsParams = { - q?: string; - sort?: "newest" | "oldest"; - page?: number; - size?: number; -}; - -export async function fetchMyScraps( - params?: FetchMyScrapsParams, -): Promise { - await new Promise((resolve) => setTimeout(resolve, 400)); - - const { q, sort = "newest", page = 0, size = 10 } = params ?? {}; - - let processed = [...MOCK_SCRAPS]; - - if (q) { - const keyword = q.trim().toLowerCase(); - processed = processed.filter( - (s) => - s.title.toLowerCase().includes(keyword) || - s.sourceName.toLowerCase().includes(keyword) || - (s.summary?.toLowerCase().includes(keyword) ?? false), - ); - } - - processed.sort((a, b) => { - const diff = - new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); - return sort === "newest" ? -diff : diff; - }); - - const totalElements = processed.length; - const totalPages = Math.ceil(totalElements / size); - const start = page * size; - const content = processed.slice(start, start + size); - - return { content, page, size, totalElements, totalPages }; -} - -export async function fetchMyScrapsPreview( - count = 4, -): Promise { - return fetchMyScraps({ page: 0, size: count, sort: "newest" }); -} diff --git a/lib/mock/my-page-wrong-quizzes.ts b/lib/mock/my-page-wrong-quizzes.ts deleted file mode 100644 index 337bb10..0000000 --- a/lib/mock/my-page-wrong-quizzes.ts +++ /dev/null @@ -1,284 +0,0 @@ -import type { - MyPageQuizHistory, - MyPageQuizHistoryResponse, - QuizHistoryDetail, -} from "@/types/myPage"; - -export const MOCK_QUIZ_HISTORIES: MyPageQuizHistory[] = [ - { - attemptId: "a1", - contentId: "content-201", - contentTitle: "React 19의 새로운 기능들: useActionState, useFormStatus 완벽 정리", - thumbnail: "https://picsum.photos/seed/quiz1/400/240", - preview: "React 19에서 useActionState 훅의 주요 역할은 무엇인가요?", - level: "MIDDLE", - score: 2, - totalQuestions: 5, - passed: false, - attemptedAt: "2026-04-20T10:30:00Z", - }, - { - attemptId: "a2", - contentId: "content-202", - contentTitle: "TypeScript 5.5 주요 변경사항 — infer 키워드 개선과 strictness 강화", - thumbnail: "https://picsum.photos/seed/quiz2/400/240", - preview: "TypeScript 5.5에서 infer 키워드가 개선된 주된 이유는 무엇인가요?", - level: "JUNIOR", - score: 3, - totalQuestions: 5, - passed: false, - attemptedAt: "2026-04-18T14:00:00Z", - }, - { - attemptId: "a3", - contentId: "content-203", - contentTitle: "PostgreSQL EXPLAIN ANALYZE 읽는 법 — 쿼리 최적화 실전 가이드", - thumbnail: null, - level: "SENIOR", - score: 1, - totalQuestions: 5, - passed: false, - attemptedAt: "2026-04-15T09:00:00Z", - }, - { - attemptId: "a4", - contentId: "content-204", - contentTitle: "Docker Compose로 로컬 개발 환경 구성하기 — 실전 템플릿 공개", - thumbnail: "https://picsum.photos/seed/quiz4/400/240", - level: "BEGINNER", - score: 5, - totalQuestions: 5, - passed: true, - attemptedAt: "2026-04-13T11:20:00Z", - }, - { - attemptId: "a5", - contentId: "content-205", - contentTitle: "Next.js App Router에서 Server Component와 Client Component 경계 설계하기", - thumbnail: "https://picsum.photos/seed/quiz5/400/240", - preview: "Next.js App Router에서 Server Component가 Client Component를 import할 수 없는 이유는?", - level: "MIDDLE", - score: 2, - totalQuestions: 5, - passed: false, - attemptedAt: "2026-04-10T16:45:00Z", - }, - { - attemptId: "a6", - contentId: "content-206", - contentTitle: "Redis 캐싱 전략 비교 — Cache-Aside, Write-Through, Write-Behind", - thumbnail: null, - level: "JUNIOR", - score: 5, - totalQuestions: 5, - passed: true, - attemptedAt: "2026-04-08T13:00:00Z", - }, - { - attemptId: "a7", - contentId: "content-207", - contentTitle: "REST API 설계 원칙 — 버저닝 전략과 에러 응답 포맷 표준화", - thumbnail: "https://picsum.photos/seed/quiz7/400/240", - preview: "REST API에서 버저닝 전략 중 URL 버저닝의 단점은 무엇인가요?", - level: "JUNIOR", - score: 3, - totalQuestions: 5, - passed: false, - attemptedAt: "2026-04-05T10:10:00Z", - }, - { - attemptId: "a8", - contentId: "content-208", - contentTitle: "Zustand v5 마이그레이션 가이드 — 스토어 구조와 미들웨어 변경점", - thumbnail: null, - level: "MIDDLE", - score: 5, - totalQuestions: 5, - passed: true, - attemptedAt: "2026-04-03T09:30:00Z", - }, - { - attemptId: "a9", - contentId: "content-209", - contentTitle: "CSS Container Queries 실전 활용 — 반응형 컴포넌트 설계", - thumbnail: null, - level: "BEGINNER", - score: 4, - totalQuestions: 5, - passed: false, - attemptedAt: "2026-04-01T15:00:00Z", - }, - { - attemptId: "a10", - contentId: "content-210", - contentTitle: "Kubernetes Pod 스케줄링 전략 — Affinity와 Taint/Toleration", - thumbnail: "https://picsum.photos/seed/quiz10/400/240", - preview: "Kubernetes에서 Node Affinity와 Pod Affinity의 차이점은?", - level: "SENIOR", - score: 2, - totalQuestions: 5, - passed: false, - attemptedAt: "2026-03-29T11:00:00Z", - }, - { - attemptId: "a11", - contentId: "content-211", - contentTitle: "웹 성능 최적화 — Core Web Vitals 개선 실전 사례", - thumbnail: "https://picsum.photos/seed/quiz11/400/240", - preview: "LCP 지표를 개선하기 위한 가장 효과적인 방법은 무엇인가요?", - level: "MIDDLE", - score: 3, - totalQuestions: 5, - passed: false, - attemptedAt: "2026-03-26T09:30:00Z", - }, - { - attemptId: "a12", - contentId: "content-212", - contentTitle: "GraphQL vs REST — 실무에서 선택 기준과 트레이드오프", - thumbnail: null, - preview: "GraphQL의 N+1 문제를 해결하는 일반적인 방법은?", - level: "JUNIOR", - score: 1, - totalQuestions: 5, - passed: false, - attemptedAt: "2026-03-22T14:00:00Z", - }, -]; - -// ─── 퀴즈 히스토리 상세 mock ────────────────────────────────────────────────── - -const MOCK_DETAIL_QUESTIONS: QuizHistoryDetail["questions"] = [ - { - id: "q-1", - type: "multiple_choice", - question: "React의 useState 훅은 무엇을 반환하나요?", - options: [ - { id: "opt-1", text: "상태값과 상태를 업데이트하는 함수" }, - { id: "opt-2", text: "컴포넌트 렌더링 결과" }, - { id: "opt-3", text: "이펙트 클린업 함수" }, - ], - correctOptionId: "opt-1", - explanation: "useState는 [state, setState] 형태의 배열을 반환합니다.", - correctAnswer: "", - }, - { - id: "q-2", - type: "multiple_choice", - question: "useEffect의 두 번째 인자(의존성 배열)를 빈 배열로 전달하면?", - options: [ - { id: "opt-1", text: "컴포넌트가 언마운트될 때만 실행" }, - { id: "opt-2", text: "마운트 시 한 번만 실행" }, - { id: "opt-3", text: "모든 렌더링마다 실행" }, - ], - correctOptionId: "opt-2", - explanation: "빈 배열을 전달하면 마운트 시 한 번만 실행됩니다.", - correctAnswer: "", - }, - { - id: "q-3", - type: "short_answer", - question: "React에서 컴포넌트 간 상태를 공유하는 패턴의 이름은?", - options: [], - correctOptionId: "", - explanation: "상태를 공통 부모로 끌어올리는 패턴을 state lifting이라고 합니다.", - correctAnswer: "state lifting", - }, -]; - -// ─── 히스토리 목록 ───────────────────────────────────────────────────────────── - -type FetchMyQuizHistoryParams = { - sort?: "newest" | "oldest"; - page?: number; - size?: number; - wrongOnly?: boolean; -}; - -export async function fetchMyQuizHistory( - params?: FetchMyQuizHistoryParams, -): Promise { - await new Promise((resolve) => setTimeout(resolve, 400)); - - const { sort = "newest", page = 0, size = 10, wrongOnly = false } = - params ?? {}; - - const source = wrongOnly - ? MOCK_QUIZ_HISTORIES.filter((q) => !q.passed) - : MOCK_QUIZ_HISTORIES; - - const processed = [...source].sort((a, b) => { - const diff = - new Date(a.attemptedAt).getTime() - new Date(b.attemptedAt).getTime(); - return sort === "newest" ? -diff : diff; - }); - - const totalElements = processed.length; - const totalPages = Math.ceil(totalElements / size); - const start = page * size; - const content = processed.slice(start, start + size); - - return { content, page, size, totalElements, totalPages }; -} - -export async function fetchMyQuizHistoryPreview( - count = 4, -): Promise { - return fetchMyQuizHistory({ page: 0, size: count, sort: "newest" }); -} - -// ─── 히스토리 상세 ───────────────────────────────────────────────────────────── - -export async function fetchMyQuizHistoryDetail( - attemptId: string, -): Promise { - await new Promise((resolve) => setTimeout(resolve, 400)); - - const attempt = MOCK_QUIZ_HISTORIES.find((q) => q.attemptId === attemptId); - if (!attempt) throw new Error("Quiz history not found"); - - return { - attemptId: attempt.attemptId, - contentId: attempt.contentId, - score: attempt.score, - totalQuestions: attempt.totalQuestions, - passed: attempt.passed, - pointsEarned: attempt.passed ? 50 : 0, - questions: MOCK_DETAIL_QUESTIONS, - passingCount: 2, - myAnswers: [ - { - questionId: "q-1", - selectedOptionId: attempt.passed ? "opt-1" : "opt-2", - answerText: null, - isCorrect: attempt.passed, - }, - { - questionId: "q-2", - selectedOptionId: "opt-2", - answerText: null, - isCorrect: true, - }, - { - questionId: "q-3", - selectedOptionId: null, - answerText: attempt.passed ? "state lifting" : "props drilling", - isCorrect: attempt.passed, - }, - ], - }; -} - -// ─── 기존 함수 유지 (WrongQuizSection preview용) ────────────────────────────── - -export async function fetchMyWrongQuizzes(): Promise { - await new Promise((resolve) => setTimeout(resolve, 400)); - return MOCK_QUIZ_HISTORIES.filter((q) => !q.passed); -} - -export async function fetchMyWrongQuizzesPreview( - count = 4, -): Promise { - await new Promise((resolve) => setTimeout(resolve, 400)); - return MOCK_QUIZ_HISTORIES.filter((q) => !q.passed).slice(0, count); -}