175 lines
4.7 KiB
TypeScript
175 lines
4.7 KiB
TypeScript
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<QuizQuestion[]>('quiz-questions', () => []);
|
|
const answers = useState<Record<number, AnswerState>>('quiz-answers', () => ({}));
|
|
const currentQuestionIndex = useState<number>('quiz-current-index', () => 0);
|
|
const loading = useState<boolean>('quiz-loading', () => false);
|
|
const lastError = useState<string | null>('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)
|
|
};
|
|
};
|