diff --git a/app/(main)/home/[id]/quiz/result/page.tsx b/app/(main)/home/[id]/quiz/result/page.tsx new file mode 100644 index 0000000..d15df0a --- /dev/null +++ b/app/(main)/home/[id]/quiz/result/page.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { Suspense, useEffect, useState } from "react"; +import { useParams, useSearchParams, useRouter } from "next/navigation"; +import Link from "next/link"; +import { ArrowLeft, Loader2, AlertCircle } from "lucide-react"; +import { QuizResult } from "@/components/features/home/quiz/QuizResult"; +import { fetchMyQuizHistoryDetail } from "@/lib/mock/my-page-wrong-quizzes"; +import type { QuizHistoryDetail } from "@/types/myPage"; + +function QuizResultContent() { + const params = useParams<{ id: string }>(); + const searchParams = useSearchParams(); + const router = useRouter(); + + 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]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError || !detail) { + return ( + <> + + + 본문으로 + +
+
+ +
+
+

+ 결과를 불러오지 못했어요 +

+

+ 퀴즈 기록을 찾을 수 없습니다. +

+
+
+ + ); + } + + return ( + <> + + + 본문으로 + + router.push(`/home/${contentId}`)} + onRetry={() => router.push(`/home/${contentId}/quiz`)} + /> + + ); +} + +export default function QuizResultPage() { + return ( +
+ + +
+ } + > + + + + ); +} diff --git a/components/features/home/quiz/ContentQuizPage.tsx b/components/features/home/quiz/ContentQuizPage.tsx index df364bd..60022b1 100644 --- a/components/features/home/quiz/ContentQuizPage.tsx +++ b/components/features/home/quiz/ContentQuizPage.tsx @@ -13,7 +13,10 @@ import { } from "lucide-react"; import { quizzesEndpoints } from "@/lib/api/endpoints/quizzes"; import { extractApiError } from "@/lib/api/extractApiError"; -import { calculateQuizResult } from "@/lib/quiz/quizResult"; +import { + calculateQuizResult, + isQuestionCorrect, +} from "@/lib/quiz/quizResult"; import { useAuthStore } from "@/store/auth.store"; import type { QuizStage, @@ -145,12 +148,36 @@ export function ContentQuizPage({ contentId }: ContentQuizPageProps) { quiz.passingCount, ); + const submittedAnswers = quiz.questions.map((q) => { + const answer = answers[q.id]; + const correct = isQuestionCorrect(q, answer); + if (q.type === "multiple_choice") { + return { + questionId: q.id, + selectedOptionId: + answer?.type === "multiple_choice" + ? answer.selectedOptionId + : null, + answerText: null, + isCorrect: correct, + }; + } + return { + questionId: q.id, + selectedOptionId: null, + answerText: + answer?.type === "short_answer" ? answer.answerText || null : null, + isCorrect: correct, + }; + }); + try { const res = await quizzesEndpoints.submitQuiz(contentId, { level: quiz.level, score: correctCount, totalQuestions: quiz.questions.length, passed, + answers: submittedAnswers, }); setSubmitResult(res.data); // 인트로로 돌아올 때 hasAttempted 반영되도록 캐시 무효화 diff --git a/components/features/home/quiz/QuizResult.tsx b/components/features/home/quiz/QuizResult.tsx index 388ad59..98eb05e 100644 --- a/components/features/home/quiz/QuizResult.tsx +++ b/components/features/home/quiz/QuizResult.tsx @@ -12,22 +12,192 @@ import type { QuizAnswers, QuizSubmitResult, } from "@/types/quiz"; +import type { QuizHistoryDetail } from "@/types/myPage"; -interface QuizResultProps { +type LiveProps = { + mode?: "live"; quiz: ContentQuiz; answers: QuizAnswers; submitResult: QuizSubmitResult | null; onRetry: () => void; onBack: () => void; -} +}; -export function QuizResult({ - quiz, - answers, - submitResult, - onRetry, +type HistoryProps = { + mode: "history"; + detail: QuizHistoryDetail; + onRetry: () => void; + onBack: () => void; +}; + +export type QuizResultProps = LiveProps | HistoryProps; + +function ResultButtons({ onBack, -}: QuizResultProps) { + onRetry, +}: { + onBack: () => void; + onRetry: () => void; +}) { + return ( +
+ + +
+ ); +} + +export function QuizResult(props: QuizResultProps) { + // ─── 히스토리 모드 ──────────────────────────────────────────────────────────── + if (props.mode === "history") { + const { detail, onRetry, onBack } = props; + const { + score, + passed, + totalQuestions, + pointsEarned, + passingCount, + questions, + myAnswers, + } = detail; + + return ( +
+
+
+ {passed ? ( + + ) : ( + + )} +
+
+

+ {passed ? "통과!" : "아쉽네요"} +

+

+ {totalQuestions}문제 중{" "} + {score}문제{" "} + 정답 +

+ {!passed && ( +

+ 통과 기준: {passingCount}문제 이상 +

+ )} +
+ {pointsEarned > 0 && ( +
+ + + 퀴즈 통과! +{pointsEarned} 포인트 획득 + +
+ )} +
+ + {questions === null ? ( +

+ 문제 정보를 불러올 수 없습니다. +

+ ) : ( +
+ {questions.map((q, idx) => { + const myAnswer = myAnswers.find((a) => a.questionId === q.id); + const correct = myAnswer?.isCorrect ?? false; + + return ( +
+
+ {correct ? ( + + ) : ( + + )} +

+ Q{idx + 1}. {q.question} +

+
+ + {!correct && ( +
+ {q.type === "multiple_choice" ? ( + <> +

+ 내 답:{" "} + {myAnswer?.selectedOptionId + ? (q.options.find( + (o) => o.id === myAnswer.selectedOptionId, + )?.text ?? "미선택") + : "미선택"} +

+

+ 정답:{" "} + {q.options.find((o) => o.id === q.correctOptionId) + ?.text} +

+ + ) : ( + <> +

+ 내 답:{" "} + {myAnswer?.answerText?.trim() || "미입력"} +

+

+ 정답: {q.correctAnswer} +

+ + )} +
+ )} + + {correct && q.type === "short_answer" && ( +
+ 입력한 답: {myAnswer?.answerText?.trim() ?? ""} +
+ )} + +

+ {q.explanation} +

+
+ ); + })} +
+ )} + + +
+ ); + } + + // ─── 라이브 모드 (기존 동작 그대로) ─────────────────────────────────────────── + const { quiz, answers, submitResult, onRetry, onBack } = props; const { correctCount, passed } = calculateQuizResult( quiz.questions, answers, @@ -36,7 +206,6 @@ export function QuizResult({ return (
- {/* 결과 요약 */}
{passed ? ( @@ -69,8 +238,6 @@ export function QuizResult({

)}
- - {/* 포인트 획득 */} {submitResult && submitResult.pointsEarned > 0 && (
@@ -81,7 +248,6 @@ export function QuizResult({ )}
- {/* 문제별 정오답 */}
{quiz.questions.map((q, idx) => { const answer = answers[q.id]; @@ -95,7 +261,6 @@ export function QuizResult({ correct ? "bg-green-500/5" : "bg-red-500/5", )} > - {/* 문제 텍스트 */}
{correct ? ( @@ -107,27 +272,31 @@ export function QuizResult({

- {/* 오답 상세 */} {!correct && (
{q.type === "multiple_choice" ? ( <>

내 답:{" "} - {answer?.type === "multiple_choice" && answer.selectedOptionId - ? q.options.find((o) => o.id === answer.selectedOptionId)?.text ?? "미선택" + {answer?.type === "multiple_choice" && + answer.selectedOptionId + ? (q.options.find( + (o) => o.id === answer.selectedOptionId, + )?.text ?? "미선택") : "미선택"}

정답:{" "} - {q.options.find((o) => o.id === q.correctOptionId)?.text} + {q.options.find((o) => o.id === q.correctOptionId) + ?.text}

) : ( <>

내 답:{" "} - {answer?.type === "short_answer" && answer.answerText.trim() + {answer?.type === "short_answer" && + answer.answerText.trim() ? answer.answerText.trim() : "미입력"}

@@ -139,14 +308,15 @@ export function QuizResult({
)} - {/* 주관식 정답 시에도 정답 표시 */} {correct && q.type === "short_answer" && (
- 입력한 답: {answer?.type === "short_answer" ? answer.answerText.trim() : ""} + 입력한 답:{" "} + {answer?.type === "short_answer" + ? answer.answerText.trim() + : ""}
)} - {/* 해설 */}

{q.explanation}

@@ -155,23 +325,7 @@ export function QuizResult({ })}
- {/* 액션 버튼 */} -
- - -
+
); } diff --git a/components/features/my-page/quizzes/WrongQuizCard.tsx b/components/features/my-page/quizzes/WrongQuizCard.tsx index aa4974c..36f0f96 100644 --- a/components/features/my-page/quizzes/WrongQuizCard.tsx +++ b/components/features/my-page/quizzes/WrongQuizCard.tsx @@ -19,11 +19,11 @@ export function LevelBadge({ level }: { level: QuizLevel }) { } export function WrongQuizCard({ quiz }: { quiz: MyPageQuizHistory }) { - const { contentId, contentTitle, level, score, totalQuestions, attemptedAt } = + const { attemptId, contentId, contentTitle, level, score, totalQuestions, attemptedAt } = quiz; return ( - +

{contentTitle} diff --git a/components/features/my-page/quizzes/WrongQuizList.tsx b/components/features/my-page/quizzes/WrongQuizList.tsx index f2e5353..d7f4908 100644 --- a/components/features/my-page/quizzes/WrongQuizList.tsx +++ b/components/features/my-page/quizzes/WrongQuizList.tsx @@ -1,6 +1,5 @@ "use client"; -import { useState, useMemo } from "react"; import { ChevronDown } from "lucide-react"; import { DropdownMenu, @@ -13,32 +12,31 @@ import { WrongQuizListItem } from "./WrongQuizListItem"; import { MyPagePagination } from "../MyPagePagination"; import type { MyPageQuizHistory } from "@/types/myPage"; -const PAGE_SIZE = 10; - type SortOrder = "newest" | "oldest"; -export function WrongQuizList({ quizzes }: { quizzes: MyPageQuizHistory[] }) { - const [sort, setSort] = useState("newest"); - const [currentPage, setCurrentPage] = useState(1); - - const sorted = useMemo(() => { - return [...quizzes].sort((a, b) => { - const diff = - new Date(a.attemptedAt).getTime() - new Date(b.attemptedAt).getTime(); - return sort === "newest" ? -diff : diff; - }); - }, [quizzes, sort]); - - const totalPages = Math.ceil(sorted.length / PAGE_SIZE); - const pagedItems = sorted.slice( - (currentPage - 1) * PAGE_SIZE, - currentPage * PAGE_SIZE, - ); +interface WrongQuizListProps { + quizzes: MyPageQuizHistory[]; + totalElements: number; + totalPages: number; + sort: SortOrder; + page: number; + onSortChange: (value: SortOrder) => void; + onPageChange: (page: number) => void; +} +export function WrongQuizList({ + quizzes, + totalElements, + totalPages, + sort, + page, + onSortChange, + onPageChange, +}: WrongQuizListProps) { return (

- {sorted.length}개 + {totalElements}개 {sort === "newest" ? "최신순" : "오래된순"} @@ -47,7 +45,7 @@ export function WrongQuizList({ quizzes }: { quizzes: MyPageQuizHistory[] }) { { setSort(v as SortOrder); setCurrentPage(1); }} + onValueChange={(v) => onSortChange(v as SortOrder)} > 최신순 @@ -60,23 +58,25 @@ export function WrongQuizList({ quizzes }: { quizzes: MyPageQuizHistory[] }) {
- {sorted.length === 0 ? ( + {quizzes.length === 0 ? (

- 틀린 퀴즈가 없습니다. + 퀴즈 기록이 없습니다.

) : ( <>
- {pagedItems.map((quiz) => ( + {quizzes.map((quiz) => ( ))}
- + {totalPages > 1 && ( + + )} )}
diff --git a/components/features/my-page/quizzes/WrongQuizListItem.tsx b/components/features/my-page/quizzes/WrongQuizListItem.tsx index a88ef1d..dc8587a 100644 --- a/components/features/my-page/quizzes/WrongQuizListItem.tsx +++ b/components/features/my-page/quizzes/WrongQuizListItem.tsx @@ -13,12 +13,12 @@ const LEVEL_LABEL: Record = { }; export function WrongQuizListItem({ quiz }: { quiz: MyPageQuizHistory }) { - const { contentId, contentTitle, thumbnail, preview, level, score, totalQuestions, attemptedAt } = + const { attemptId, contentId, contentTitle, thumbnail, preview, level, score, totalQuestions, attemptedAt } = quiz; return (
diff --git a/components/features/my-page/quizzes/WrongQuizListWrapper.tsx b/components/features/my-page/quizzes/WrongQuizListWrapper.tsx index 385d7f0..6222f7d 100644 --- a/components/features/my-page/quizzes/WrongQuizListWrapper.tsx +++ b/components/features/my-page/quizzes/WrongQuizListWrapper.tsx @@ -3,26 +3,49 @@ import { useEffect, useState } from "react"; import { WrongQuizList } from "./WrongQuizList"; import { WrongQuizListItemSkeleton } from "./WrongQuizListItemSkeleton"; -import { fetchMyWrongQuizzes } from "@/lib/mock/my-page-wrong-quizzes"; -import type { MyPageQuizHistory } from "@/types/myPage"; +import { fetchMyQuizHistory } from "@/lib/mock/my-page-wrong-quizzes"; +import type { MyPageQuizHistoryResponse } from "@/types/myPage"; + +type SortOrder = "newest" | "oldest"; export function WrongQuizListWrapper() { - const [quizzes, setQuizzes] = useState([]); + 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(() => { - fetchMyWrongQuizzes() - .then((data) => { - setQuizzes(data); + let cancelled = false; + + fetchMyQuizHistory({ sort, page, size: 10, wrongOnly: true }) + .then((res) => { + if (!cancelled) setData(res); }) .catch(() => { - setIsError(true); + if (!cancelled) setIsError(true); }) .finally(() => { - setIsLoading(false); + if (!cancelled) setIsLoading(false); }); - }, []); + + return () => { + cancelled = true; + }; + }, [sort, page]); + + 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 ( @@ -42,5 +65,15 @@ export function WrongQuizListWrapper() { ); } - return ; + return ( + + ); } diff --git a/components/features/my-page/scraps/ScrappedPostsList.tsx b/components/features/my-page/scraps/ScrappedPostsList.tsx index e4af0f5..7d6f046 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, useReducer, useState } from "react"; +import { useEffect, useState } from "react"; import { ChevronDown, Search } from "lucide-react"; import { Skeleton } from "@/components/ui/skeleton"; import { @@ -17,28 +17,6 @@ import type { MyPageScrapResponse } from "@/types/myPage"; type SortOrder = "newest" | "oldest"; -type FetchState = { - data: MyPageScrapResponse | null; - isLoading: boolean; - isError: boolean; -}; - -type FetchAction = - | { type: "start" } - | { type: "success"; payload: MyPageScrapResponse } - | { type: "error" }; - -function fetchReducer(state: FetchState, action: FetchAction): FetchState { - switch (action.type) { - case "start": - return { ...state, isLoading: true, isError: false }; - case "success": - return { data: action.payload, isLoading: false, isError: false }; - case "error": - return { ...state, isLoading: false, isError: true }; - } -} - function ListItemSkeleton() { return (
@@ -54,11 +32,9 @@ function ListItemSkeleton() { } export function ScrappedPostsList() { - const [{ data, isLoading, isError }, dispatch] = useReducer(fetchReducer, { - data: null, - isLoading: true, - isError: false, - }); + 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); @@ -66,14 +42,15 @@ export function ScrappedPostsList() { useEffect(() => { let cancelled = false; - dispatch({ type: "start" }); - fetchMyScraps({ q: query || undefined, sort, page, size: 10 }) .then((res) => { - if (!cancelled) dispatch({ type: "success", payload: res }); + if (!cancelled) setData(res); }) .catch(() => { - if (!cancelled) dispatch({ type: "error" }); + if (!cancelled) setIsError(true); + }) + .finally(() => { + if (!cancelled) setIsLoading(false); }); return () => { @@ -81,6 +58,26 @@ export function ScrappedPostsList() { }; }, [query, sort, page]); + 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) { return (
@@ -112,10 +109,7 @@ export function ScrappedPostsList() { type="text" placeholder="검색" value={query} - onChange={(e) => { - setQuery(e.target.value); - setPage(0); - }} + onChange={(e) => handleQueryChange(e.target.value)} className="h-8 w-40 rounded-md border border-border bg-background pl-7 pr-3 text-sm placeholder:text-muted-foreground focus:outline-none" />
@@ -127,10 +121,7 @@ export function ScrappedPostsList() { { - setSort(v as SortOrder); - setPage(0); - }} + onValueChange={handleSortChange} > 최신순 @@ -162,7 +153,7 @@ export function ScrappedPostsList() { setPage(nextPage - 1)} + onPageChange={handlePageChange} className="mt-8 mb-12" /> )} diff --git a/lib/mock/my-page-wrong-quizzes.ts b/lib/mock/my-page-wrong-quizzes.ts index 1cb290d..337bb10 100644 --- a/lib/mock/my-page-wrong-quizzes.ts +++ b/lib/mock/my-page-wrong-quizzes.ts @@ -1,4 +1,8 @@ -import type { MyPageQuizHistory } from "@/types/myPage"; +import type { + MyPageQuizHistory, + MyPageQuizHistoryResponse, + QuizHistoryDetail, +} from "@/types/myPage"; export const MOCK_QUIZ_HISTORIES: MyPageQuizHistory[] = [ { @@ -142,17 +146,139 @@ export const MOCK_QUIZ_HISTORIES: MyPageQuizHistory[] = [ }, ]; +// ─── 퀴즈 히스토리 상세 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.score < q.totalQuestions); + return MOCK_QUIZ_HISTORIES.filter((q) => !q.passed); } export async function fetchMyWrongQuizzesPreview( count = 4, ): Promise { await new Promise((resolve) => setTimeout(resolve, 400)); - const wrongQuizzes = MOCK_QUIZ_HISTORIES.filter( - (q) => q.score < q.totalQuestions, - ); - return wrongQuizzes.slice(0, count); + return MOCK_QUIZ_HISTORIES.filter((q) => !q.passed).slice(0, count); } diff --git a/types/myPage.ts b/types/myPage.ts index 618afe8..0a345c4 100644 --- a/types/myPage.ts +++ b/types/myPage.ts @@ -30,6 +30,43 @@ export interface MyPageScrapResponse { totalPages: number; } +export interface MyPageQuizHistoryResponse { + content: MyPageQuizHistory[]; + page: number; + size: number; + totalElements: number; + totalPages: number; +} + +export interface QuizHistoryDetailQuestion { + id: string; + type: "multiple_choice" | "short_answer"; + question: string; + options: { id: string; text: string }[]; + correctOptionId: string; + explanation: string; + correctAnswer: string; +} + +export interface QuizHistoryMyAnswer { + questionId: string; + selectedOptionId: string | null; + answerText: string | null; + isCorrect: boolean; +} + +export interface QuizHistoryDetail { + attemptId: string; + contentId: string; + score: number; + totalQuestions: number; + passed: boolean; + pointsEarned: number; + questions: QuizHistoryDetailQuestion[] | null; + passingCount: number; + myAnswers: QuizHistoryMyAnswer[]; +} + export interface MyPageRecommendHomePost { contentId: string; title: string; diff --git a/types/quiz.ts b/types/quiz.ts index a278af2..b8824c1 100644 --- a/types/quiz.ts +++ b/types/quiz.ts @@ -47,11 +47,19 @@ export type ContentQuizResponse = ApiResponse; // ─── Submit ─────────────────────────────────────────────────────────────────── +export interface QuizSubmitAnswer { + questionId: string; + selectedOptionId: string | null; + answerText: string | null; + isCorrect: boolean; +} + export interface QuizSubmitRequest { level: QuizLevel; score: number; totalQuestions: number; passed: boolean; + answers: QuizSubmitAnswer[]; } export interface QuizSubmitResult {