export type QuestionStatus = 'not_started' | 'in_progress' | 'completed' | 'skipped'; export interface QuizQuestion { id: number; question: string | { th: string; en: string }; is_skippable: boolean; type: string; choices?: { id: number; text: string | { th: string; en: string } }[]; } export interface AnswerState { questionId: number; value: any; is_saved: boolean; status: QuestionStatus; touched: boolean; last_saved_at?: string; } /** * @composable useQuizRunner * @description Manages the state and logic for running a quiz activity. */ export const useQuizRunner = () => { // State 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(quizData: any) { if (!quizData || !quizData.questions) return; questions.value = quizData.questions; currentQuestionIndex.value = 0; answers.value = {}; lastError.value = null; questions.value.forEach(q => { answers.value[q.id] = { questionId: q.id, value: 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; 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 }; if (!a.is_saved && a.value === null) { return { allowed: false, reason: 'This question is required.' }; } return { allowed: true }; } function updateAnswer(val: any) { if (!currentQuestion.value) return; const qId = currentQuestion.value.id; answers.value[qId].value = val; 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]; if (ans.value === null) { lastError.value = "Please provide an answer."; return false; } loading.value = true; lastError.value = null; try { 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; } const currQ = currentQuestion.value; if (currQ) { const currAns = answers.value[currQ.id]; 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; } return { questions, answers, currentQuestionIndex, loading, lastError, currentQuestion, currentAnswer, totalQuestions, isFirstQuestion, isLastQuestion, initQuiz, updateAnswer, saveCurrentAnswer, nextQuestion: () => handleLeaveLogic(currentQuestionIndex.value + 1), prevQuestion: () => handleLeaveLogic(currentQuestionIndex.value - 1), goToQuestion: (index: number) => handleLeaveLogic(index) }; };