diff --git a/src/app/quiz/page.tsx b/src/app/quiz/page.tsx new file mode 100644 index 0000000..033efd2 --- /dev/null +++ b/src/app/quiz/page.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { useState } from "react"; +import CategorySelection from "@/components/quiz/CategorySelection"; +import QuizSession from "@/components/quiz/QuizSession"; +import QuizResult from "@/components/quiz/QuizResult"; +import type { QuizQuestion, QuizResult as QuizResultType } from "@/lib/quiz"; +import type { CategoryType } from "@/components/ui/category/config"; + +type QuizStage = "category" | "quiz" | "result"; + +export default function QuizPage() { + const [stage, setStage] = useState("category"); + const [selectedCategory, setSelectedCategory] = useState( + null + ); + const [questions, setQuestions] = useState([]); + const [result, setResult] = useState(null); + + const handleCategorySelect = ( + category: CategoryType, + quizQuestions: QuizQuestion[] + ) => { + setSelectedCategory(category); + setQuestions(quizQuestions); + setStage("quiz"); + }; + + const handleQuizComplete = (quizResult: QuizResultType) => { + setResult(quizResult); + setStage("result"); + }; + + const handleRestart = () => { + setStage("category"); + setSelectedCategory(null); + setQuestions([]); + setResult(null); + }; + + const handleRetry = (newQuestions: QuizQuestion[]) => { + setQuestions(newQuestions); + setStage("quiz"); + }; + + return ( +
+
+ {stage === "category" && ( + + )} + + {stage === "quiz" && selectedCategory && ( + + )} + + {stage === "result" && result && selectedCategory && ( + + )} +
+
+ ); +} diff --git a/src/components/landing/CTASection.tsx b/src/components/landing/CTASection.tsx index 596be10..cfa5147 100644 --- a/src/components/landing/CTASection.tsx +++ b/src/components/landing/CTASection.tsx @@ -3,6 +3,7 @@ import { useRouter } from "next/navigation"; import { motion } from "framer-motion"; import { SearchIcon, ArrowRightIcon, LogoText } from "@/components/icons"; +import GradientButton from "@/components/ui/buttons/GradientButton"; export function CTASection() { const router = useRouter(); @@ -27,9 +28,10 @@ export function CTASection() { transition={{ duration: 0.6, delay: 0.1 }} className="mt-10" > - + diff --git a/src/components/quiz/CategorySelection.tsx b/src/components/quiz/CategorySelection.tsx new file mode 100644 index 0000000..ebb277e --- /dev/null +++ b/src/components/quiz/CategorySelection.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useState } from "react"; +import { type CategoryType } from "@/components/ui/category/config"; +import CategoryButton from "@/app/onboarding/components/categoruButton"; +import { generateQuizQuestions, type QuizQuestion } from "@/lib/quiz"; +import { ArrowRightIcon } from "@/components/icons/ic_arrow_right"; +import { useToast } from "@/contexts/ToastContext"; +import GradientButton from "@/components/ui/buttons/GradientButton"; + +interface CategorySelectionProps { + onCategorySelect: (category: CategoryType, questions: QuizQuestion[]) => void; +} + +const row1Categories: CategoryType[] = [ + "all", + "frontend", + "backend", + "uxui", + "ai", +]; +const row2Categories: CategoryType[] = [ + "cloud", + "data", + "security", + "devops", + "business", +]; + +export default function CategorySelection({ + onCategorySelect, +}: CategorySelectionProps) { + const [selectedCategory, setSelectedCategory] = useState( + null + ); + const [questionCount, setQuestionCount] = useState(10); + const [isLoading, setIsLoading] = useState(false); + const { showToast } = useToast(); + + const handleSelectCategory = (category: CategoryType) => { + setSelectedCategory(category); + }; + + const handleStartQuiz = async () => { + if (!selectedCategory) { + showToast("카테고리를 선택해주세요!", "error"); + return; + } + + setIsLoading(true); + + try { + const questions = await generateQuizQuestions( + selectedCategory, + questionCount + ); + onCategorySelect(selectedCategory, questions); + } catch (err) { + showToast( + err instanceof Error ? err.message : "퀴즈 생성에 실패했습니다.", + "error" + ); + setIsLoading(false); + } + }; + + return ( +
+ {/* 헤더 */} +
+

IT 용어 퀴즈

+

+ 카테고리를 선택하고 퀴즈를 시작하세요! +

+
+ + {/* 카테고리 선택 */} +
+

카테고리 선택

+
+ {/* 첫 번째 줄 */} +
+ {row1Categories.map((category) => ( + handleSelectCategory(category)} + /> + ))} +
+ + {/* 두 번째 줄 */} +
+ {row2Categories.map((category) => ( + handleSelectCategory(category)} + /> + ))} +
+
+
+ + {/* 문제 수 선택 */} +
+

문제 수

+
+ {[5, 10, 15, 20].map((count) => ( + + ))} +
+
+ + {/* 시작 버튼 */} + + + {isLoading ? "퀴즈 생성 중..." : "퀴즈 시작하기"} + + {!isLoading && ( + + )} + +
+ ); +} diff --git a/src/components/quiz/QuizResult.tsx b/src/components/quiz/QuizResult.tsx new file mode 100644 index 0000000..9eabd9e --- /dev/null +++ b/src/components/quiz/QuizResult.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useAuth } from "@/contexts/AuthContext"; +import { useToast } from "@/contexts/ToastContext"; +import { + generateQuizQuestions, + type QuizResult as QuizResultType, + type QuizQuestion, +} from "@/lib/quiz"; +import { type CategoryType } from "@/components/ui/category/config"; +import GradientButton from "@/components/ui/buttons/GradientButton"; + +interface QuizResultProps { + result: QuizResultType; + category: CategoryType; + onRestart: () => void; + onRetry: (questions: QuizQuestion[]) => void; +} + +export default function QuizResult({ + result, + category, + onRestart, + onRetry, +}: QuizResultProps) { + const { user, toggleScrap, isScraped } = useAuth(); + const { showToast } = useToast(); + const [isRetrying, setIsRetrying] = useState(false); + + const wrongQuestions = result.questions.filter( + (q, idx) => result.userAnswers[idx] !== q.correctAnswer + ); + + const handleScrapWrongTerms = async () => { + if (!user) { + showToast("로그인이 필요합니다.", "error"); + return; + } + + try { + let scrapCount = 0; + for (const question of wrongQuestions) { + const alreadyScraped = isScraped(question.term.id); + if (!alreadyScraped) { + await toggleScrap(question.term.id); + scrapCount++; + } + } + showToast( + scrapCount > 0 + ? `틀린 문제 ${scrapCount}개를 스크랩했습니다!` + : "이미 모든 문제가 스크랩되어 있습니다.", + "success" + ); + } catch { + showToast("스크랩에 실패했습니다.", "error"); + } + }; + + const handleRetry = async () => { + setIsRetrying(true); + try { + const newQuestions = await generateQuizQuestions( + category, + result.totalQuestions + ); + onRetry(newQuestions); + } catch { + showToast("퀴즈 생성에 실패했습니다.", "error"); + setIsRetrying(false); + } + }; + + return ( +
+ {/* 점수 카드 */} +
+
+
+

총점

+

+ {result.score} +

+

+
+
+ +
+
+

전체 문제

+

+ {result.totalQuestions} +

+
+
+

정답

+

+ {result.correctAnswers} +

+
+
+

오답

+

+ {result.wrongAnswers} +

+
+
+
+ + {/* 오답 노트 */} + {wrongQuestions.length > 0 && ( +
+
+

+ 오답 노트 ({wrongQuestions.length}개) +

+ {user && ( + + )} +
+ +
+ {wrongQuestions.map((question, idx) => { + const originalIdx = result.questions.indexOf(question); + const userAnswer = result.userAnswers[originalIdx]; + + return ( +
+
+
+
+ + 오답 + + + {question.term.termKo} + +
+

+ {question.questionType === "summary" + ? question.term.summary + : `"${question.term.termKo}"의 설명`} +

+
+
+ +
+
+ 내 답변: + + {userAnswer || "(미응답)"} + +
+
+ 정답: + + {question.correctAnswer} + +
+
+
+ ); + })} +
+
+ )} + + {/* 액션 버튼 */} +
+ + + {isRetrying ? "생성 중..." : "같은 카테고리로 다시 풀기"} + +
+
+ ); +} diff --git a/src/components/quiz/QuizSession.tsx b/src/components/quiz/QuizSession.tsx new file mode 100644 index 0000000..5b5300c --- /dev/null +++ b/src/components/quiz/QuizSession.tsx @@ -0,0 +1,203 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { + calculateQuizResult, + type QuizQuestion, + type QuizResult, +} from "@/lib/quiz"; +import { + categoryLabels, + type CategoryType, +} from "@/components/ui/category/config"; +import GradientButton from "@/components/ui/buttons/GradientButton"; + +interface QuizSessionProps { + questions: QuizQuestion[]; + category: CategoryType; + onComplete: (result: QuizResult) => void; +} + +export default function QuizSession({ + questions, + category, + onComplete, +}: QuizSessionProps) { + const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); + const [userAnswers, setUserAnswers] = useState<(string | null)[]>( + new Array(questions.length).fill(null) + ); + const [selectedAnswer, setSelectedAnswer] = useState(null); + + const currentQuestion = questions[currentQuestionIndex]; + const progress = ((currentQuestionIndex + 1) / questions.length) * 100; + + const handleAnswerSelect = (answer: string) => { + setSelectedAnswer(answer); + }; + + const handleNext = () => { + // 현재 답변 저장 + const newAnswers = [...userAnswers]; + newAnswers[currentQuestionIndex] = selectedAnswer; + setUserAnswers(newAnswers); + + if (currentQuestionIndex < questions.length - 1) { + // 다음 문제로 + setCurrentQuestionIndex(currentQuestionIndex + 1); + setSelectedAnswer(newAnswers[currentQuestionIndex + 1]); + } else { + // 퀴즈 완료 + const result = calculateQuizResult(questions, newAnswers); + onComplete(result); + } + }; + + const handlePrevious = () => { + if (currentQuestionIndex > 0) { + // 현재 답변 저장 + const newAnswers = [...userAnswers]; + newAnswers[currentQuestionIndex] = selectedAnswer; + setUserAnswers(newAnswers); + + // 이전 문제로 + setCurrentQuestionIndex(currentQuestionIndex - 1); + setSelectedAnswer(newAnswers[currentQuestionIndex - 1]); + } + }; + + return ( +
+ {/* 헤더 */} +
+
+
+

+ {categoryLabels[category]} 퀴즈 +

+

+ 문제 {currentQuestionIndex + 1} / {questions.length} +

+
+
+

답변한 문제

+

+ {userAnswers.filter((a) => a !== null).length} /{" "} + {questions.length} +

+
+
+ + {/* 진행률 바 */} +
+
+
+
+ + {/* 문제 카드 */} +
+ {/* 문제 */} +
+
+ + {currentQuestion.questionType === "summary" + ? "용어 맞추기" + : "설명 맞추기"} + + + 용어 보기 → + +
+
+ {currentQuestion.questionType === "summary" ? ( + <> +

+ 다음 설명에 해당하는 용어는? +

+

+ {currentQuestion.term.summary} +

+ + ) : ( + <> +

+ "{currentQuestion.term.termKo}"의 설명으로 올바른 + 것은? +

+ + )} +
+
+ + {/* 선택지 */} +
+ {currentQuestion.choices.map((choice, index) => { + const isSelected = selectedAnswer === choice; + const choiceLabel = ["A", "B", "C", "D"][index]; + + return ( + + ); + })} +
+
+ + {/* 네비게이션 버튼 */} +
+ {currentQuestionIndex > 0 ? ( + + ) : ( +
+ )} + + + {currentQuestionIndex === questions.length - 1 + ? "결과 보기" + : "다음 문제 →"} + +
+
+ ); +} diff --git a/src/components/search/SearchBar.tsx b/src/components/search/SearchBar.tsx index 4e86eaf..31b807f 100644 --- a/src/components/search/SearchBar.tsx +++ b/src/components/search/SearchBar.tsx @@ -20,7 +20,7 @@ export default function SearchBar({ value, onChange }: SearchBarProps) { {/* 검색 입력창 컨테이너 */}
{/* 1. 아우라 레이어 (opacity 30 + blur + gradient border) */} -
+
{/* 2. 실제 입력창 (내부) */}
diff --git a/src/components/term-detail/TabSection.tsx b/src/components/term-detail/TabSection.tsx index 3dd8a31..b5b9f30 100644 --- a/src/components/term-detail/TabSection.tsx +++ b/src/components/term-detail/TabSection.tsx @@ -25,9 +25,7 @@ export function TabSection({ term, relatedTerms }: TabSectionProps) {
{activeTab === "description" && } {activeTab === "usecase" && } - {activeTab === "related" && ( - - )} + {activeTab === "related" && }
); diff --git a/src/components/ui/buttons/GradientButton.tsx b/src/components/ui/buttons/GradientButton.tsx new file mode 100644 index 0000000..fcf5d3b --- /dev/null +++ b/src/components/ui/buttons/GradientButton.tsx @@ -0,0 +1,32 @@ +import { ButtonHTMLAttributes, ReactNode } from "react"; + +interface GradientButtonProps extends ButtonHTMLAttributes { + children: ReactNode; + isLoading?: boolean; + rounded?: "xl" | "lg" | "full"; +} + +export default function GradientButton({ + children, + isLoading = false, + rounded = "xl", + className = "", + disabled, + ...props +}: GradientButtonProps) { + const roundedClass = { + xl: "rounded-xl", + lg: "rounded-lg", + full: "rounded-full", + }[rounded]; + + return ( + + ); +} diff --git a/src/constants/navigation.ts b/src/constants/navigation.ts index 9f4f9d1..7500cd2 100644 --- a/src/constants/navigation.ts +++ b/src/constants/navigation.ts @@ -1,5 +1,6 @@ export const DEFAULT_NAV_ITEMS = [ { label: "검색", href: "/search" }, + { label: "퀴즈", href: "/quiz" }, { label: "대시보드", href: "/dashboard" }, { label: "챗봇", href: "/chatbot" }, ] as const; diff --git a/src/contexts/ToastContext.tsx b/src/contexts/ToastContext.tsx index 855265e..adc7bbd 100644 --- a/src/contexts/ToastContext.tsx +++ b/src/contexts/ToastContext.tsx @@ -75,7 +75,7 @@ export function ToastProvider({ children }: { children: ReactNode }) { {toast.message} diff --git a/src/lib/quiz.ts b/src/lib/quiz.ts new file mode 100644 index 0000000..3411371 --- /dev/null +++ b/src/lib/quiz.ts @@ -0,0 +1,135 @@ +/** + * 퀴즈 생성 및 관리 헬퍼 + */ + +import { getTermsIndex, getTermsByTag, type TermIndexItem } from "./terms"; +import { + categoryLabels, + type CategoryType, +} from "@/components/ui/category/config"; + +export interface QuizQuestion { + term: TermIndexItem; + correctAnswer: string; + choices: string[]; + questionType: "summary" | "term"; +} + +export interface QuizResult { + totalQuestions: number; + correctAnswers: number; + wrongAnswers: number; + score: number; + questions: QuizQuestion[]; + userAnswers: (string | null)[]; +} + +/** + * 배열을 랜덤하게 섞기 + */ +function shuffleArray(array: T[]): T[] { + const shuffled = [...array]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + return shuffled; +} + +/** + * 카테고리별로 퀴즈 문제 생성 + */ +export async function generateQuizQuestions( + category: CategoryType, + count: number = 10 +): Promise { + // 카테고리에 해당하는 용어들 가져오기 + let terms: TermIndexItem[]; + + if (category === "all") { + const allTerms = await getTermsIndex(); + terms = shuffleArray(allTerms); + } else { + // CategoryType을 한글 이름으로 변환하여 검색 + const categoryLabel = categoryLabels[category]; + terms = await getTermsByTag(categoryLabel); + terms = shuffleArray(terms); + } + + // 문제 수가 용어 수보다 많으면 조정 + const questionCount = Math.min(count, terms.length); + + if (questionCount < 4) { + throw new Error("퀴즈를 생성하기에 용어가 부족합니다."); + } + + const selectedTerms = terms.slice(0, questionCount); + const questions: QuizQuestion[] = []; + + for (let i = 0; i < selectedTerms.length; i++) { + const currentTerm = selectedTerms[i]; + + // 랜덤으로 문제 유형 선택 + const questionType = Math.random() > 0.5 ? "summary" : "term"; + + // 오답 선택지 생성 + const otherTerms = terms.filter((t) => t.id !== currentTerm.id); + const wrongChoices = shuffleArray(otherTerms).slice(0, 3); + + let choices: string[]; + let correctAnswer: string; + + if (questionType === "summary") { + // 설명을 보고 용어 맞추기 + correctAnswer = currentTerm.termKo; + choices = shuffleArray([ + currentTerm.termKo, + ...wrongChoices.map((t) => t.termKo), + ]); + } else { + // 용어를 보고 설명 맞추기 + correctAnswer = currentTerm.summary; + choices = shuffleArray([ + currentTerm.summary, + ...wrongChoices.map((t) => t.summary), + ]); + } + + questions.push({ + term: currentTerm, + correctAnswer, + choices, + questionType, + }); + } + + return questions; +} + +/** + * 퀴즈 결과 계산 + */ +export function calculateQuizResult( + questions: QuizQuestion[], + userAnswers: (string | null)[] +): QuizResult { + let correctCount = 0; + + for (let i = 0; i < questions.length; i++) { + if (userAnswers[i] === questions[i].correctAnswer) { + correctCount++; + } + } + + const wrongCount = questions.length - correctCount; + const score = Math.round((correctCount / questions.length) * 100); + + return { + totalQuestions: questions.length, + correctAnswers: correctCount, + wrongAnswers: wrongCount, + score, + questions, + userAnswers, + }; +}