From 67f10c4287d60efe26c0975134ca3767aa651d0b Mon Sep 17 00:00:00 2001 From: supalerk-ar66 Date: Wed, 4 Feb 2026 14:16:02 +0700 Subject: [PATCH] feat: Implement quiz runner functionality with dedicated pages, composable logic, and i18n support. --- Frontend-Learner/composables/useQuizRunner.ts | 255 +++++++++++++++ Frontend-Learner/i18n/locales/en.json | 9 +- Frontend-Learner/i18n/locales/th.json | 9 +- Frontend-Learner/pages/classroom/quiz.vue | 196 +++++++++++- Frontend-Learner/pages/quiz/[id].vue | 301 ++++++++++++++++++ 5 files changed, 759 insertions(+), 11 deletions(-) create mode 100644 Frontend-Learner/composables/useQuizRunner.ts create mode 100644 Frontend-Learner/pages/quiz/[id].vue diff --git a/Frontend-Learner/composables/useQuizRunner.ts b/Frontend-Learner/composables/useQuizRunner.ts new file mode 100644 index 00000000..1ef68b4c --- /dev/null +++ b/Frontend-Learner/composables/useQuizRunner.ts @@ -0,0 +1,255 @@ +export type QuestionStatus = 'not_started' | 'in_progress' | 'completed' | 'skipped'; + +export interface QuizQuestion { + id: number; + title: string; + is_skippable: boolean; + type: 'single' | 'multiple' | 'text'; + options?: { id: string; label: string }[]; +} + +export interface AnswerState { + questionId: number; + value: string | string[] | null; + is_saved: boolean; + status: QuestionStatus; + touched: boolean; + last_saved_at?: string; +} + +// Mock Data +const MOCK_QUESTIONS: QuizQuestion[] = [ + { + id: 1, + title: 'What is the capital of France?', + is_skippable: true, + type: 'single', + options: [ + { id: 'london', label: 'London' }, + { id: 'paris', label: 'Paris' }, + { id: 'berlin', label: 'Berlin' }, + ], + }, + { + id: 2, + title: 'Explain the concept of closure in JavaScript.', + is_skippable: false, + type: 'text', + }, + { + id: 3, + title: 'Which of the following are Vue lifecycle hooks? (Select all that apply)', + is_skippable: true, + type: 'multiple', + options: [ + { id: 'created', label: 'created' }, + { id: 'mounted', label: 'mounted' }, + { id: 'render', label: 'render' }, + { id: 'compute', label: 'compute' }, + ], + }, + { + id: 4, + title: 'What is 2 + 2?', + is_skippable: false, + type: 'single', + options: [ + { id: '3', label: '3' }, + { id: '4', label: '4' }, + { id: '5', label: '5' }, + ], + } +]; + +export const useQuizRunner = () => { + // State (using useState for Nuxt SSR safety and persistence across component reloads if needed) + const questions = useState('quiz-questions', () => []); + const answers = useState>('quiz-answers', () => ({})); + const currentQuestionIndex = useState('quiz-current-index', () => 0); + const loading = useState('quiz-loading', () => false); + const lastError = useState('quiz-error', () => null); + + // Getters + const currentQuestion = computed(() => questions.value[currentQuestionIndex.value]); + + const currentAnswer = computed(() => { + if (!currentQuestion.value) return null; + return answers.value[currentQuestion.value.id]; + }); + + const totalQuestions = computed(() => questions.value.length); + const isLastQuestion = computed(() => currentQuestionIndex.value === questions.value.length - 1); + const isFirstQuestion = computed(() => currentQuestionIndex.value === 0); + + // Actions + function initQuiz(quizId: string) { + questions.value = [...MOCK_QUESTIONS]; + currentQuestionIndex.value = 0; + answers.value = {}; + lastError.value = null; + + questions.value.forEach(q => { + answers.value[q.id] = { + questionId: q.id, + value: q.type === 'multiple' ? [] : null, + is_saved: false, + status: 'not_started', + touched: false, + }; + }); + + if (questions.value.length > 0) { + enterQuestion(questions.value[0].id); + } + } + + function enterQuestion(qId: number) { + const ans = answers.value[qId]; + if (ans) { + ans.touched = true; + // Mark as in_progress if not final state + if (ans.status === 'not_started' || ans.status === 'skipped') { + ans.status = 'in_progress'; + } + } + } + + function canLeaveCurrent(): { allowed: boolean; reason?: string } { + if (!currentQuestion.value) return { allowed: true }; + const q = currentQuestion.value; + const a = answers.value[q.id]; + + if (a.status === 'completed' || a.is_saved) return { allowed: true }; + if (q.is_skippable) return { allowed: true }; + + // Required and unsaved + if (!a.is_saved) { + return { allowed: false, reason: 'This question is required and must be saved.' }; + } + + return { allowed: true }; + } + + function updateAnswer(val: string | string[] | null) { + if (!currentQuestion.value) return; + const qId = currentQuestion.value.id; + answers.value[qId].value = val; + + // If modifying a completed answer, revert to in_progress until saved again? + // Yes, to enforce "Green = Saved Successfully" implies current state matches saved state. + if (answers.value[qId].is_saved) { + answers.value[qId].is_saved = false; + answers.value[qId].status = 'in_progress'; + } + } + + async function saveCurrentAnswer() { + if (!currentQuestion.value) return; + const qId = currentQuestion.value.id; + const ans = answers.value[qId]; + + // Validation + if (currentQuestion.value.type === 'multiple') { + if (!ans.value || (ans.value as string[]).length === 0) { + lastError.value = "Please select at least one option."; + return false; + } + } else { + if (!ans.value || (ans.value as string).trim() === '') { + lastError.value = "Please provide an answer."; + return false; + } + } + + loading.value = true; + lastError.value = null; + + try { + await new Promise(resolve => setTimeout(resolve, 800)); + ans.is_saved = true; + ans.status = 'completed'; + ans.last_saved_at = new Date().toISOString(); + return true; + } catch (e) { + lastError.value = "Failed to save answer."; + return false; + } finally { + loading.value = false; + } + } + + function handleLeaveLogic(targetIndex: number) { + if (targetIndex === currentQuestionIndex.value) return; + + const check = canLeaveCurrent(); + if (!check.allowed) { + lastError.value = check.reason || "Required question."; + return false; + } + + // Mark current as skipped if leaving without completion + const currQ = currentQuestion.value; + const currAns = answers.value[currQ.id]; + // If we leave and it is NOT completed (and implicit skippable check passed), set SKIPPED + if (currAns.status !== 'completed' && !currAns.is_saved) { + currAns.status = 'skipped'; + } + + currentQuestionIndex.value = targetIndex; + lastError.value = null; + + if (questions.value[targetIndex]) { + enterQuestion(questions.value[targetIndex].id); + } + + return true; + } + + async function nextQuestion() { + if (isLastQuestion.value) return; + handleLeaveLogic(currentQuestionIndex.value + 1); + } + + async function prevQuestion() { + if (isFirstQuestion.value) return; + handleLeaveLogic(currentQuestionIndex.value - 1); + } + + async function goToQuestion(index: number) { + if (index < 0 || index >= questions.value.length) return; + handleLeaveLogic(index); + } + + async function skipQuestion() { + if (isLastQuestion.value) { + // If last question and skip... maybe just mark skipped? + const currQ = currentQuestion.value; + const currAns = answers.value[currQ.id]; + currAns.status = 'skipped'; + return; + } + handleLeaveLogic(currentQuestionIndex.value + 1); + } + + return { + questions, + answers, + currentQuestionIndex, + loading, + lastError, + + currentQuestion, + currentAnswer, + totalQuestions, + isFirstQuestion, + isLastQuestion, + + initQuiz, + updateAnswer, + saveCurrentAnswer, + nextQuestion, + prevQuestion, + goToQuestion, + skipQuestion + }; +}; diff --git a/Frontend-Learner/i18n/locales/en.json b/Frontend-Learner/i18n/locales/en.json index 3d77afe6..acc7ecaa 100644 --- a/Frontend-Learner/i18n/locales/en.json +++ b/Frontend-Learner/i18n/locales/en.json @@ -206,6 +206,13 @@ "scoreLabel": "Score", "correctLabel": "Correct", "retryBtn": "Retry Quiz", - "pleaseSelectAnswer": "Please select an answer" + "pleaseSelectAnswer": "Please select an answer", + "questionMap": "Question Map", + "statusLabel": "Question Status", + "statusCurrent": "Current", + "statusCompleted": "Completed", + "statusSkipped": "Skipped", + "statusNotStarted": "Not Started", + "alertIncomplete": "Please answer all questions" } } diff --git a/Frontend-Learner/i18n/locales/th.json b/Frontend-Learner/i18n/locales/th.json index 1e296681..acacd23b 100644 --- a/Frontend-Learner/i18n/locales/th.json +++ b/Frontend-Learner/i18n/locales/th.json @@ -207,6 +207,13 @@ "nextBtn": "ข้อถัดไป", "prevBtn": "ข้อย้อนกลับ", "submitBtn": "ส่งคำตอบ", - "reviewAnswers": "ทบทวนคำตอบ" + "reviewAnswers": "เฉลยคำตอบ", + "questionMap": "ข้อที่", + "statusLabel": "สถานะข้อสอบ", + "statusCurrent": "ข้อปัจจุบัน", + "statusCompleted": "ทำแล้ว", + "statusSkipped": "ข้าม", + "statusNotStarted": "ยังไม่ทำ", + "alertIncomplete": "กรุณาเลือกคำตอบให้ครบทุกข้อ" } } diff --git a/Frontend-Learner/pages/classroom/quiz.vue b/Frontend-Learner/pages/classroom/quiz.vue index bc140094..44ce4cda 100644 --- a/Frontend-Learner/pages/classroom/quiz.vue +++ b/Frontend-Learner/pages/classroom/quiz.vue @@ -33,8 +33,69 @@ const isSubmitting = ref(false) // Quiz Taking State const currentQuestionIndex = ref(0) const userAnswers = ref>({}) // questionId -> choiceId +const visitedQuestions = ref>(new Set()) // Track visited indices const quizResult = ref(null) +// Tracking visited questions +watch(currentQuestionIndex, (newVal) => { + visitedQuestions.value.add(newVal) +}, { immediate: true }) + +// Helper: Get Status Color Class +const getQuestionStatusClass = (index: number, questionId: number) => { + // 1. Current = Blue + if (index === currentQuestionIndex.value) { + return 'bg-blue-500 text-white border-blue-600 ring-2 ring-blue-200 dark:ring-blue-900' + } + + const hasAnswer = userAnswers.value[questionId] !== undefined + const isVisited = visitedQuestions.value.has(index) + + // 2. Completed = Green + if (hasAnswer) { + return 'bg-emerald-500 text-white border-emerald-600' + } + + // 3. Skipped = Orange (Visited but no answer) + // Note: If we are strictly following "Skipped" definition: + // "user pressed Skip or moved forward on a skippable question without saving an answer" + // In this linear flow, merely visiting and leaving empty counts as skipped. + if (isVisited && !hasAnswer) { + return 'bg-orange-500 text-white border-orange-600' + } + + // 4. Not Started = Grey + return 'bg-slate-200 text-slate-400 border-slate-300 dark:bg-white/5 dark:border-white/10 dark:text-slate-500 hover:bg-slate-300 dark:hover:bg-white/10' +} + +const jumpToQuestion = (targetIndex: number) => { + if (targetIndex === currentQuestionIndex.value) return + + // Validation before leaving current (same logic as Next) + if (targetIndex > currentQuestionIndex.value) { + // If jumping forward, we must validate the CURRENT question requirements + // unless we treat grid jumps as free navigation? + // Req: "user cannot go Next until the question is answered and saved" (if not skippable). + // So we must check restriction on the current spot before leaving. + const isAnswered = userAnswers.value[currentQuestion.value.id] !== undefined + const isSkippable = quizData.value?.is_skippable + + if (!isAnswered && !isSkippable) { + $q.notify({ + type: 'warning', + message: t('quiz.pleaseSelectAnswer', 'กรุณาเลือกคำตอบ'), + position: 'top', + timeout: 2000 + }) + return + } + } + + // If jumping backward? Usually allowed freely. + + currentQuestionIndex.value = targetIndex +} + // Computed const currentQuestion = computed(() => { if (!quizData.value || !quizData.value.questions) return null @@ -45,6 +106,8 @@ const totalQuestions = computed(() => { return quizData.value?.questions?.length || 0 }) +const showQuestionMap = computed(() => $q.screen.gt.sm) + const timerDisplay = computed(() => { const minutes = Math.floor(timeLeft.value / 60) const seconds = timeLeft.value % 60 @@ -129,6 +192,9 @@ const startQuiz = () => { else submitQuiz(true) }, 1000) } + + // Mark first as visited + visitedQuestions.value = new Set([0]) } const selectAnswer = (choiceId: number) => { @@ -140,8 +206,11 @@ const selectAnswer = (choiceId: number) => { const nextQuestion = () => { if (!currentQuestion.value) return - // Check if answered - if (!userAnswers.value[currentQuestion.value.id]) { + // Allow skip if quiz is skippable or question is answered + const isAnswered = userAnswers.value[currentQuestion.value.id] !== undefined + const isSkippable = quizData.value?.is_skippable + + if (!isAnswered && !isSkippable) { // Show warning $q.notify({ type: 'warning', @@ -177,6 +246,29 @@ const submitQuiz = async (auto = false) => { currentScreen.value = 'result' // Switch to result screen immediately to show loader try { + // Validate completion (User Request: Must answer ALL questions) + if (!auto) { + const answeredCount = Object.keys(userAnswers.value).length + if (answeredCount < totalQuestions.value) { + $q.notify({ + type: 'warning', + message: t('quiz.alertIncomplete', 'กรุณาเลือกคำตอบให้ครบทุกข้อ'), + position: 'top', + timeout: 2000 + }) + isSubmitting.value = false + currentScreen.value = 'taking' + return + } + + // Confirm submission only if manual + if (!confirm(t('quiz.submitConfirm', 'ยืนยันการส่งคำตอบ?'))) { + isSubmitting.value = false + currentScreen.value = 'taking' + return + } + } + // Prepare Payload const answersPayload = Object.entries(userAnswers.value).map(([qId, cId]) => ({ question_id: Number(qId), @@ -189,7 +281,7 @@ const submitQuiz = async (auto = false) => { // Call API const res = await apiSubmitQuiz(courseId, lessonId, answersPayload, alreadyPassed) - if (res.success) { + if (res.success && res.data) { quizResult.value = res.data // Update local progress if passed and not previously passed if (res.data.is_passed && !alreadyPassed) { @@ -244,7 +336,10 @@ const getCorrectChoiceId = (questionId: number) => { - + + + + + + + + +
+

+ {{ $t('quiz.questionMap', 'Question Map') }} +

+ +
+ +
+ + +
+
+
Current +
+
+
Completed +
+
+
Skipped +
+
+
Not Started +
+
+
+
+ + + +