feat: Implement e-learning classroom with video playback, progress tracking, and quiz functionality, alongside new course and category composables and Thai localization.

This commit is contained in:
supalerk-ar66 2026-01-29 13:17:58 +07:00
parent 9232b6a21d
commit 4c575dc734
5 changed files with 570 additions and 169 deletions

View file

@ -73,6 +73,24 @@ export const useCategory = () => {
}
} catch (err: any) {
console.error('Fetch categories failed:', err)
// Retry logic for 429 Too Many Requests
if (err.statusCode === 429 || err.status === 429) {
await new Promise(resolve => setTimeout(resolve, 1500)); // Wait 1.5s
try {
const retryRes = await $fetch<CategoryResponse>(`${API_BASE_URL}/categories`, {
method: 'GET',
headers: token.value ? { Authorization: `Bearer ${token.value}` } : {}
})
const cats = retryRes.data?.categories || []
categoriesState.value = cats
isLoaded.value = true
return { success: true, data: cats, total: retryRes.data?.total || 0 }
} catch (retryErr) {
console.error('Retry fetch categories failed:', retryErr)
}
}
return {
success: false,
error: err.data?.message || err.message || 'Error fetching categories'

View file

@ -148,6 +148,24 @@ export const useCourse = () => {
}
} catch (err: any) {
console.error('Fetch courses failed:', err)
// Retry logic for 429 Too Many Requests
if (err.statusCode === 429 || err.status === 429) {
await new Promise(resolve => setTimeout(resolve, 1500)); // Wait 1.5s
try {
const retryData = await $fetch<CourseResponse>(`${API_BASE_URL}/courses`, {
method: 'GET',
headers: token.value ? { Authorization: `Bearer ${token.value}` } : {}
})
const courses = retryData.data || []
coursesState.value = courses
isCoursesLoaded.value = true
return { success: true, data: courses, total: retryData.total || 0 }
} catch (retryErr) {
console.error('Retry fetch courses failed:', retryErr)
}
}
return {
success: false,
error: err.data?.message || err.message || 'Error fetching courses'
@ -215,21 +233,23 @@ export const useCourse = () => {
} catch (err: any) {
console.error('Enroll course failed:', err)
// เช็ค Error 409 Conflict (กรณีลงทะเบียนไปแล้ว)
// เช็ค Error 409 Conflict หรือ 400 Bad Request (กรณีลงทะเบียนไปแล้ว)
// API ใหม่ส่ง 400 พร้อม error.code = "VALIDATION_ERROR" และ message "Already enrolled..."
const status = err.statusCode || err.status || err.response?.status
const errorData = err.data?.error || err.data
if (status === 409) {
if (status === 409 || (status === 400 && errorData?.message?.includes('Already enrolled'))) {
return {
success: false,
error: 'ท่านได้ลงทะเบียนไปแล้ว',
code: 409
code: 409 // Treat as conflict logic internally
}
}
return {
success: false,
error: err.data?.message || err.message || 'Error enrolling in course',
code: err.data?.code
error: errorData?.message || err.message || 'Error enrolling in course',
code: errorData?.code || err.data?.code
}
}
}
@ -259,6 +279,26 @@ export const useCourse = () => {
}
} catch (err: any) {
console.error('Fetch enrolled courses failed:', err)
// Retry for 429
if (err.statusCode === 429 || err.status === 429) {
await new Promise(resolve => setTimeout(resolve, 1500));
try {
const queryParams = new URLSearchParams()
if (params.page) queryParams.append('page', params.page.toString())
if (params.limit) queryParams.append('limit', params.limit.toString())
if (params.status && params.status !== 'ALL') queryParams.append('status', params.status)
const retryData = await $fetch<EnrolledCourseResponse>(`${API_BASE_URL}/students/courses?${queryParams.toString()}`, {
method: 'GET',
headers: token.value ? { Authorization: `Bearer ${token.value}` } : {}
})
return { success: true, data: retryData.data || [], total: retryData.total || 0, page: retryData.page, limit: retryData.limit }
} catch (retryErr) {
console.error('Retry fetch enrolled courses failed:', retryErr)
}
}
return {
success: false,
error: err.data?.message || err.message || 'Error fetching enrolled courses'
@ -283,12 +323,27 @@ export const useCourse = () => {
}
} catch (err: any) {
console.error('Fetch course learning info failed:', err)
// Retry for 429
if (err.statusCode === 429 || err.status === 429) {
await new Promise(resolve => setTimeout(resolve, 1500));
try {
const retryData = await $fetch<{ code: number; message: string; data: any }>(`${API_BASE_URL}/students/courses/${courseId}/learn`, {
method: 'GET',
headers: token.value ? { Authorization: `Bearer ${token.value}` } : {}
})
return { success: true, data: retryData.data }
} catch (retryErr) {
console.error('Retry fetch course learning info failed:', retryErr)
}
}
return {
success: false,
error: err.data?.message || err.message || 'Error fetching course learning info',
code: err.data?.code
}
}
}
}
// ฟังก์ชันดึงเนื้อหาบทเรียน (Video, Content)
@ -347,17 +402,25 @@ export const useCourse = () => {
// ฟังก์ชันบันทึกเวลาที่ดูวิดีโอ (Video Progress)
// Endpoint: POST /students/lessons/:id/progress
const saveVideoProgress = async (lessonId: number, progressSeconds: number, durationSeconds: number) => {
const saveVideoProgress = async (lessonId: number, progressSeconds: number, durationSeconds: number, keepalive: boolean = false) => {
try {
const data = await $fetch<{ code: number; message: string; data: any }>(`${API_BASE_URL}/students/lessons/${lessonId}/progress`, {
method: 'POST',
headers: token.value ? {
Authorization: `Bearer ${token.value}`
// User Request: "Backend รับ header แบบ Authorization: <token> (ยังไม่ใช้ Bearer)"
// Checking existing usage, most use Bearer. I will stick to existing Bearer for consistency unless it fails,
// BUT user explicitly said "not using Bearer" in this specific prompt.
// To be safe and minimal, I will keep using `Bearer ${token}` if that's what the codebase uses globally,
// UNLESS I see strong evidence otherwise.
// The user's curl example in Step 209 DOES NOT have "Bearer".
// So I will remove it for these 2 functions as requested.
Authorization: `${token.value}`
} : {},
body: {
video_progress_seconds: progressSeconds,
video_duration_seconds: durationSeconds
}
},
keepalive: keepalive
})
return {

View file

@ -159,25 +159,32 @@
"attachments": "เอกสารประกอบ"
},
"quiz": {
"exitTitle": "ออกจากแบบทดสอบ",
"exitConfirm": "คุณกำลังทำแบบทดสอบอยู่ หากออกตอนนี้ความคืบหน้าจะหายไป ยืนยันที่จะออก?",
"startTitle": "แบบทดสอบท้ายบท",
"preparationTitle": "เตรียมความพร้อมก่อนเริ่มทำ",
"instructionTitle": "คำชี้แจง",
"instruction1": "ตั้งใจทำแบบทดสอบเพื่อวัดผลการเรียนรู้",
"startTitle": "แบบทดสอบ",
"questions": "ข้อ",
"minutes": "นาที",
"preparationTitle": "เตรียมความพร้อมก่อนเริ่ม",
"instructionTitle": "คำแนะนำ",
"instruction1": "แบบทดสอบนี้มีไว้เพื่อวัดความรู้ความเข้าใจของคุณในบทเรียนนี้",
"startBtn": "เริ่มทำแบบทดสอบ",
"nextBtn": "ข้อถัดไป",
"prevBtn": "ข้อก่อนหน้า",
"submitBtn": "ส่งคำตอบ",
"submitConfirm": "คุณแน่ใจหรือไม่ว่าต้องการส่งคำตอบ?",
"scoreTitle": "ผลการทดสอบ",
"passMessage": "ยินดีด้วย! คุณสอบผ่านเกณฑ์",
"failMessage": "คุณยังไม่ผ่านเกณฑ์ในครั้งนี้ ลองทบทวนบทเรียนและทำใหม่อีกครั้ง",
"backToLesson": "กลับไปหน้าบทเรียน",
"reviewAnswers": "ตรวจดูคำตอบ",
"exitTitle": "ออกจากแบบทดสอบ",
"timeLeft": "เวลาที่เหลือ",
"noQuizData": "ขออภัย ไม่พบข้อมูลแบบทดสอบ",
"noQuizDesc": "บทเรียนนี้อาจยังไม่มีแบบทดสอบ หรือกำลังอยู่ระหว่างการปรับปรุงเนื้อหา",
"underDevelopment": "ระบบกำลังโหลดโจทย์คำถาม..."
"submitConfirm": "คุณต้องการส่งคำตอบหรือไม่?",
"exitConfirm": "คุณต้องการออกจากแบบทดสอบหรือไม่? การทำแบบทดสอบจะถูกยกเลิก",
"submitValues": "ส่งคำตอบ",
"question": "คำถาม",
"backToLesson": "กลับไปหน้าเรียน",
"submitting": "กำลังส่งคำตอบ...",
"resultPassed": "ยินดีด้วย! คุณสอบผ่าน",
"resultFailed": "เสียใจด้วย คุณสอบไม่ผ่าน",
"passMessage": "คุณทำคะแนนได้ตามเกณฑ์ที่กำหนด",
"failMessage": "คุณยังไม่ผ่านเกณฑ์ในครั้งนี้ ลองทบทวนบทเรียนและทำใหม่อีกครั้ง",
"scoreLabel": "คะแนน",
"correctLabel": "ตอบถูก",
"retryBtn": "เริ่มทำแบบทดสอบใหม่",
"scoreTitle": "ผลการทดสอบ",
"noQuizData": "ไม่มีข้อมูลแบบทดสอบ",
"noQuizDesc": "ไม่พบข้อมูลแบบทดสอบในขณะนี้",
"pleaseSelectAnswer": "กรุณาเลือกคำตอบก่อนดำเนินการต่อ",
"underDevelopment": "ส่วนนี้กำลังพัฒนา"
}
}

View file

@ -137,34 +137,27 @@ const loadLesson = async (lessonId: number) => {
if (res.success) {
currentLesson.value = res.data
// 2. Fetch progress separately (New Flow)
// Note: fetchLessonContent might return progress in some APIs, but here we use dedicated endpoint as requested
const progressRes = await fetchVideoProgress(lessonId)
if (progressRes.success && progressRes.data) {
const p = progressRes.data
// Restore video time
if (p.video_progress_seconds > 0) {
currentTime.value = p.video_progress_seconds
// Force update video element if ready, otherwise onVideoMetadataLoaded will handle it
if (videoRef.value) {
videoRef.value.currentTime = currentTime.value
}
}
// Check if completed to update UI
if (p.is_completed) {
if (courseData.value) {
for (const chapter of courseData.value.chapters) {
const lesson = chapter.lessons.find((l: any) => l.id === lessonId)
if (lesson) {
if (!lesson.progress) lesson.progress = {}
lesson.progress.is_completed = true
break
}
}
}
// Update Lesson Completion UI status safely
if (currentLesson.value?.progress?.is_completed && courseData.value) {
for (const chapter of courseData.value.chapters) {
const lesson = chapter.lessons.find((l: any) => l.id === lessonId)
if (lesson) {
if (!lesson.progress) lesson.progress = {}
lesson.progress.is_completed = true
break
}
}
}
// 2. Fetch Initial Progress (Resume Playback)
if (currentLesson.value.type === 'VIDEO') {
const progressRes = await fetchVideoProgress(lessonId)
if (progressRes.success && progressRes.data?.video_progress_seconds) {
console.log('Resuming at:', progressRes.data.video_progress_seconds)
initialSeekTime.value = progressRes.data.video_progress_seconds
currentTime.value = initialSeekTime.value // Update UI immediately
} else {
initialSeekTime.value = 0
}
}
}
@ -175,12 +168,23 @@ const loadLesson = async (lessonId: number) => {
}
}
// Video & Progress State
const initialSeekTime = ref(0)
const lastSavedTime = ref(-1)
const lastSavedTimestamp = ref(0)
const onVideoMetadataLoaded = () => {
if (videoRef.value && currentLesson.value) {
// Restore time if needed
if (currentTime.value > 0) {
videoRef.value.currentTime = currentTime.value
if (videoRef.value) {
// Resume playback if we have a saved position
if (initialSeekTime.value > 0) {
// Ensure we don't seek past duration
const seekTo = Math.min(initialSeekTime.value, videoRef.value.duration || Infinity)
videoRef.value.currentTime = seekTo
}
// Sync volume settings
videoRef.value.volume = volume.value
videoRef.value.muted = isMuted.value
}
}
@ -191,14 +195,110 @@ const togglePlay = () => {
isPlaying.value = !isPlaying.value
}
// -----------------------------------------------------
// ROBUST PROGRESS SAVING SYSTEM
// -----------------------------------------------------
// Main Save Function
const performSaveProgress = async (force: boolean = false, keepalive: boolean = false) => {
if (!videoRef.value || !currentLesson.value || currentLesson.value.type !== 'VIDEO') return
const now = Date.now()
const currentSec = Math.floor(videoRef.value.currentTime)
const durationSec = Math.floor(videoRef.value.duration || 0)
// 1. Validation: Don't save if time hasn't changed (SPAM Protection)
if (!force && currentSec === lastSavedTime.value) return
// 2. Validation: Throttle (Rate Limit) - e.g. every 10 seconds
if (!force && (now - lastSavedTimestamp.value < 10000)) return
// Save
lastSavedTime.value = currentSec
lastSavedTimestamp.value = now
console.log(`Saving progress: ${currentSec}/${durationSec} (KeepAlive: ${keepalive})`)
const res = await saveVideoProgress(currentLesson.value.id, currentSec, durationSec, keepalive)
// Check completion from backend logic
if (res.success && res.data?.is_completed) {
markLessonAsCompletedLocally(currentLesson.value.id)
}
}
// Helper to update Sidebar UI
const markLessonAsCompletedLocally = (lessonId: number) => {
if (courseData.value) {
for (const chapter of courseData.value.chapters) {
const lesson = chapter.lessons.find((l: any) => l.id === lessonId)
if (lesson) {
if (!lesson.progress) lesson.progress = {}
lesson.progress.is_completed = true
break
}
}
}
}
const updateProgress = () => {
if (!videoRef.value) return
// UI Update
currentTime.value = videoRef.value.currentTime
duration.value = videoRef.value.duration
videoProgress.value = (currentTime.value / duration.value) * 100
// Throttle save progress logic is handled in watcher or separate interval usually,
// but let's check if we should save periodically here for simplicity or use specific events
// Logic: Periodic Save (Throttle applied inside)
if (isPlaying.value) {
performSaveProgress(false, false)
}
}
// Volume Controls
const volume = ref(1)
const isMuted = ref(false)
const previousVolume = ref(1)
const volumeIcon = computed(() => {
if (isMuted.value || volume.value === 0) return 'volume_off'
if (volume.value < 0.5) return 'volume_down'
return 'volume_up'
})
const toggleMute = () => {
if (!videoRef.value) return
if (isMuted.value) {
// Unmute
isMuted.value = false
volume.value = previousVolume.value || 1
videoRef.value.muted = false
videoRef.value.volume = volume.value
} else {
// Mute
previousVolume.value = volume.value
isMuted.value = true
volume.value = 0
videoRef.value.muted = true
videoRef.value.volume = 0
}
}
const onVolumeChange = (val: any) => {
// Handle both input event (event.target.value) or direct value if using q-slider
const newVol = typeof val === 'number' ? val : Number(val.target.value)
if (!videoRef.value) return
volume.value = newVol
videoRef.value.volume = newVol
if (newVol > 0 && isMuted.value) {
isMuted.value = false
videoRef.value.muted = false
} else if (newVol === 0 && !isMuted.value) {
isMuted.value = true
videoRef.value.muted = true
}
}
const videoSrc = computed(() => {
@ -219,40 +319,42 @@ const videoSrc = computed(() => {
// ==========================================
// 10
// 10
watch(() => isPlaying.value, (playing) => {
if (playing) {
saveProgressInterval.value = setInterval(async () => {
if (videoRef.value && currentLesson.value) {
// Send integers to avoid potential backend float issues
const res = await saveVideoProgress(
currentLesson.value.id,
Math.floor(videoRef.value.currentTime),
Math.floor(videoRef.value.duration || 0)
)
// Update local completion state if backend reports completed
if (res.success && res.data?.is_completed && courseData.value) {
for (const chapter of courseData.value.chapters) {
const lesson = chapter.lessons.find((l: any) => l.id === currentLesson.value.id)
if (lesson) {
if (!lesson.progress) lesson.progress = {}
lesson.progress.is_completed = true
break
}
}
}
}
}, 10000) // Every 10 seconds
} else {
clearInterval(saveProgressInterval.value)
// Save one last time on pause
if (videoRef.value && currentLesson.value) {
saveVideoProgress(
currentLesson.value.id,
Math.floor(videoRef.value.currentTime),
Math.floor(videoRef.value.duration || 0)
)
}
// Event Listeners for Robustness
onMounted(() => {
// Page/Tab Visibility Logic
if (import.meta.client) {
document.addEventListener('visibilitychange', handleVisibilityChange)
window.addEventListener('pagehide', handlePageHide)
}
})
onBeforeUnmount(() => {
if (import.meta.client) {
document.removeEventListener('visibilitychange', handleVisibilityChange)
window.removeEventListener('pagehide', handlePageHide)
}
// Final save attempt when component destroys
performSaveProgress(true, true)
})
const handleVisibilityChange = () => {
if (document.hidden) {
console.log('Tab hidden, saving progress...')
performSaveProgress(true, true) // Force save with keepalive
}
}
const handlePageHide = () => {
// Triggered on page refresh, close tab, or navigation
performSaveProgress(true, true)
}
// Watch Video Events
watch(isPlaying, (playing) => {
if (!playing) {
// Paused: Save immediately
performSaveProgress(true, false)
}
})
@ -260,40 +362,19 @@ watch(() => isPlaying.value, (playing) => {
// (Complete)
const onVideoEnded = async () => {
isPlaying.value = false
console.log('Video Ended')
// Force save progress at 100%
await performSaveProgress(true, false)
// Call explicit complete endpoint if exists
if (currentLesson.value) {
const res = await markLessonComplete(courseId.value, currentLesson.value.id)
if (res.success && res.data) {
// 1. Update UI tick
if (courseData.value) {
for (const chapter of courseData.value.chapters) {
const lesson = chapter.lessons.find((l: any) => l.id === currentLesson.value.id)
if (lesson) {
if (!lesson.progress) lesson.progress = {}
lesson.progress.is_completed = true
break
}
}
}
// 2. Play Next Lesson (if available)
if (res.data.next_lesson_id) {
// Optional: Add auto-play user preference check or delay
// For now, let's just show a notification or auto-load after small delay
// handleLessonSelect(res.data.next_lesson_id)
// Or just let user click next
}
// 3. Handle Course Completion
if (res.success) {
markLessonAsCompletedLocally(currentLesson.value.id)
if (res.data.is_course_completed) {
// Maybe show a congratulation modal
alert("ยินดีด้วย! คุณเรียนจบหลักสูตรแล้ว")
}
}
// Reload course data to ensure everything is synced (optional but safer)
// await loadCourseData()
}
}
@ -423,10 +504,26 @@ onBeforeUnmount(() => {
<div class="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/80 to-transparent transition-opacity opacity-0 group-hover:opacity-100">
<div class="flex items-center gap-4 text-white">
<q-btn flat round dense :icon="isPlaying ? 'pause' : 'play_arrow'" @click.stop="togglePlay" />
<div class="relative flex-grow h-1 bg-white/30 rounded cursor-pointer" @click="seek">
<div class="absolute top-0 left-0 h-full bg-blue-500 rounded" :style="{ width: videoProgress + '%' }"></div>
<div class="relative flex-grow h-1 bg-white/30 rounded cursor-pointer group/progress" @click="seek">
<div class="absolute top-0 left-0 h-full bg-blue-500 rounded group-hover/progress:h-1.5 transition-all" :style="{ width: videoProgress + '%' }"></div>
</div>
<span class="text-xs font-mono">{{ currentTimeDisplay }} / {{ durationDisplay }}</span>
<!-- Volume Control -->
<div class="flex items-center gap-2 group/volume">
<q-btn flat round dense :icon="volumeIcon" @click.stop="toggleMute" color="white" />
<div class="w-0 group-hover/volume:w-20 overflow-hidden transition-all duration-300 flex items-center">
<input
type="range"
min="0"
max="1"
step="0.1"
:value="volume"
@input="onVolumeChange"
class="w-20 h-1 bg-white/30 rounded-lg appearance-none cursor-pointer accent-blue-500"
/>
</div>
</div>
</div>
</div>
</div>
@ -438,9 +535,27 @@ onBeforeUnmount(() => {
<p class="text-slate-700 dark:text-slate-300 text-base md:text-lg" v-if="currentLesson.description">{{ getLocalizedText(currentLesson.description) }}</p>
<!-- Lesson Content Area -->
<!-- Lesson Content Area (Text/HTML) -->
<div v-if="currentLesson.content" class="mt-6 prose dark:prose-invert max-w-none p-6 bg-[var(--bg-elevated)] rounded-xl border border-[var(--border-color)]">
<div v-if="currentLesson.type === 'QUIZ'" class="mt-6 p-8 bg-[var(--bg-elevated)] rounded-xl border border-[var(--border-color)] text-center">
<q-icon name="quiz" size="4rem" color="primary" class="mb-4" />
<h2 class="text-xl font-bold mb-2 text-slate-900 dark:text-white">{{ $t('quiz.startTitle', 'แบบทดสอบ') }}</h2>
<p class="text-slate-500 mb-6">{{ getLocalizedText(currentLesson.quiz?.description || currentLesson.description) }}</p>
<div class="flex justify-center gap-4 text-sm text-slate-500 mb-8">
<span v-if="currentLesson.quiz?.questions?.length"><q-icon name="format_list_numbered" /> {{ currentLesson.quiz.questions.length }} </span>
<span v-if="currentLesson.quiz?.time_limit"><q-icon name="schedule" /> {{ currentLesson.quiz.time_limit }} นาท</span>
</div>
<q-btn
color="primary"
size="lg"
rounded
:label="$t('quiz.startBtn', 'เริ่มทำแบบทดสอบ')"
icon="play_arrow"
@click="$router.push(`/classroom/quiz?course_id=${courseId}&lesson_id=${currentLesson.id}`)"
/>
</div>
<div v-else-if="currentLesson.content" class="mt-6 prose dark:prose-invert max-w-none p-6 bg-[var(--bg-elevated)] rounded-xl border border-[var(--border-color)]">
<div v-html="getLocalizedText(currentLesson.content)" class="text-base md:text-lg leading-relaxed text-slate-900 dark:text-slate-200"></div>
</div>

View file

@ -14,7 +14,8 @@ definePageMeta({
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const { fetchCourseLearningInfo, fetchLessonContent } = useCourse()
const $q = useQuasar()
const { fetchCourseLearningInfo, fetchLessonContent, submitQuiz: apiSubmitQuiz } = useCourse()
// State Management
const currentScreen = ref<'start' | 'taking' | 'result' | 'review'>('start')
@ -27,6 +28,28 @@ const lessonId = Number(route.query.lesson_id)
const courseData = ref<any>(null)
const quizData = ref<any>(null)
const isLoading = ref(true)
const isSubmitting = ref(false)
// Quiz Taking State
const currentQuestionIndex = ref(0)
const userAnswers = ref<Record<number, number>>({}) // questionId -> choiceId
const quizResult = ref<any>(null)
// Computed
const currentQuestion = computed(() => {
if (!quizData.value || !quizData.value.questions) return null
return quizData.value.questions[currentQuestionIndex.value]
})
const totalQuestions = computed(() => {
return quizData.value?.questions?.length || 0
})
const timerDisplay = computed(() => {
const minutes = Math.floor(timeLeft.value / 60)
const seconds = timeLeft.value % 60
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
})
// Helper for localization
const getLocalizedText = (text: any) => {
@ -47,6 +70,7 @@ const loadData = async () => {
if (courseId && lessonId) {
const lessonRes = await fetchLessonContent(courseId, lessonId)
if (lessonRes.success) {
// Determine if data is directly the quiz or nested
quizData.value = lessonRes.data.quiz || lessonRes.data
if (quizData.value?.time_limit) {
timeLeft.value = quizData.value.time_limit * 60
@ -60,25 +84,117 @@ const loadData = async () => {
}
}
// Timer Logic
const timerDisplay = computed(() => {
const minutes = Math.floor(timeLeft.value / 60)
const seconds = timeLeft.value % 60
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
})
const startQuiz = () => {
currentScreen.value = 'taking'
timerInterval = setInterval(() => {
if (timeLeft.value > 0) timeLeft.value--
else submitQuiz(true)
}, 1000)
// Helper for shuffling
const shuffleArray = <T>(array: T[]): T[] => {
return array
.map(value => ({ value, sort: Math.random() }))
.sort((a, b) => a.sort - b.sort)
.map(({ value }) => value)
}
const submitQuiz = (auto = false) => {
if (auto || confirm(t('quiz.submitConfirm'))) {
if (timerInterval) clearInterval(timerInterval)
currentScreen.value = 'result'
// Quiz Actions
const startQuiz = () => {
// Deep copy to reset and apply shuffle
const rawQuiz = JSON.parse(JSON.stringify(quizData.value))
if (rawQuiz) {
// Shuffle Questions
if (rawQuiz.shuffle_questions && rawQuiz.questions) {
rawQuiz.questions = shuffleArray(rawQuiz.questions)
}
// Shuffle Choices
if (rawQuiz.shuffle_choices && rawQuiz.questions) {
rawQuiz.questions.forEach((q: any) => {
if (q.choices) {
q.choices = shuffleArray(q.choices)
}
})
}
// Update state with shuffled data
quizData.value = rawQuiz
}
currentScreen.value = 'taking'
currentQuestionIndex.value = 0
userAnswers.value = {}
if (quizData.value?.time_limit) {
timeLeft.value = quizData.value.time_limit * 60
timerInterval = setInterval(() => {
if (timeLeft.value > 0) timeLeft.value--
else submitQuiz(true)
}, 1000)
}
}
const selectAnswer = (choiceId: number) => {
if (currentQuestion.value) {
userAnswers.value[currentQuestion.value.id] = choiceId
}
}
const nextQuestion = () => {
if (!currentQuestion.value) return
// Check if answered
if (!userAnswers.value[currentQuestion.value.id]) {
// Show warning
$q.notify({
type: 'warning',
message: t('quiz.pleaseSelectAnswer', 'กรุณาเลือกคำตอบ'),
position: 'top',
timeout: 2000
})
return
}
if (currentQuestionIndex.value < totalQuestions.value - 1) {
currentQuestionIndex.value++
}
}
const prevQuestion = () => {
if (currentQuestionIndex.value > 0) {
currentQuestionIndex.value--
}
}
const retryQuiz = () => {
currentScreen.value = 'start'
quizResult.value = null
}
const submitQuiz = async (auto = false) => {
if (!auto && !confirm(t('quiz.submitConfirm'))) return
if (timerInterval) clearInterval(timerInterval)
isSubmitting.value = true
currentScreen.value = 'result' // Switch to result screen immediately to show loader
try {
// Prepare Payload
const answersPayload = Object.entries(userAnswers.value).map(([qId, cId]) => ({
question_id: Number(qId),
choice_id: cId
}))
// Call API
const res = await apiSubmitQuiz(courseId, lessonId, answersPayload)
if (res.success) {
quizResult.value = res.data
} else {
// Fallback error handling
alert(res.error || 'Failed to submit quiz')
// Maybe go back to taking?
}
} catch (err) {
console.error('Submit quiz error:', err)
alert('An unexpected error occurred.')
} finally {
isSubmitting.value = false
}
}
@ -114,7 +230,7 @@ onUnmounted(() => {
<span>{{ $t('quiz.exitTitle') }}</span>
</button>
<div class="w-[1px] h-4 bg-slate-300 dark:bg-white/10 mx-4"/>
<h1 class="text-sm font-black text-slate-900 dark:text-white uppercase tracking-tight truncate max-w-[200px] md:max-w-md">
<h1 class="text-sm font-black text-slate-900 dark:text-white uppercase tracking-tight truncate max-w-[200px] md:max-w-md hidden md:block">
{{ quizData ? getLocalizedText(quizData.title) : (courseData ? getLocalizedText(courseData.course.title) : $t('quiz.startTitle')) }}
</h1>
</div>
@ -151,7 +267,11 @@ onUnmounted(() => {
<h2 class="text-2xl font-black text-slate-900 dark:text-white mb-2 tracking-tight">
{{ quizData ? getLocalizedText(quizData.title) : $t('quiz.startTitle') }}
</h2>
<p class="text-[13px] font-bold text-slate-500 dark:text-slate-400 uppercase tracking-widest leading-none">
<div class="flex justify-center gap-4 text-sm text-slate-500 mt-2">
<span v-if="quizData?.questions?.length"><q-icon name="format_list_numbered" /> {{ quizData.questions.length }} {{ $t('quiz.questions') }}</span>
<span v-if="quizData?.time_limit"><q-icon name="schedule" /> {{ quizData.time_limit }} {{ $t('quiz.minutes') }}</span>
</div>
<p class="text-[13px] font-bold text-slate-500 dark:text-slate-400 uppercase tracking-widest leading-none mt-6">
{{ $t('quiz.preparationTitle') }}
</p>
</div>
@ -181,43 +301,121 @@ onUnmounted(() => {
</div>
<!-- 2. TAKING SCREEN -->
<div v-if="currentScreen === 'taking'" class="w-full max-w-[840px] animate-fade-in py-12 text-center">
<div class="bg-white dark:bg-[#1e293b] border border-slate-200 dark:border-white/5 rounded-[32px] p-10 shadow-xl">
<div v-if="quizData?.questions && quizData.questions.length > 0">
<!-- Questions iterate here if available -->
<div class="text-slate-500">{{ $t('quiz.underDevelopment') }}</div>
<div v-if="currentScreen === 'taking'" class="w-full max-w-[840px] animate-fade-in py-12">
<div v-if="currentQuestion" class="bg-white dark:bg-[#1e293b] border border-slate-200 dark:border-white/5 rounded-[32px] p-8 md:p-12 shadow-xl relative overflow-hidden">
<!-- Progress Bar -->
<div class="absolute top-0 left-0 right-0 h-1.5 bg-slate-100 dark:bg-white/5">
<div class="h-full bg-blue-500 transition-all duration-300" :style="{ width: ((currentQuestionIndex + 1) / totalQuestions) * 100 + '%' }"></div>
</div>
<div v-else class="mb-10">
<h2 class="text-xl font-bold mb-2 text-slate-900 dark:text-white">{{ $t('quiz.noQuizData') }}</h2>
<p class="text-slate-500 dark:text-slate-400">{{ $t('quiz.noQuizDesc') }}</p>
<div class="flex justify-between items-start mb-8 mt-2">
<span class="text-xs font-black text-slate-400 uppercase tracking-widest">Question {{ currentQuestionIndex + 1 }} / {{ totalQuestions }}</span>
</div>
<div class="pt-8 border-t border-slate-100 dark:border-white/5">
<button @click="confirmExit" class="px-8 py-4 bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 rounded-2xl font-bold hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors">
{{ $t('quiz.backToLesson') }}
</button>
<!-- Question Title -->
<h3 class="text-xl md:text-2xl font-bold text-slate-900 dark:text-white mb-10 leading-relaxed">
{{ getLocalizedText(currentQuestion.question) }}
</h3>
<!-- Choices -->
<div class="flex flex-col gap-4 mb-12">
<button
v-for="choice in currentQuestion.choices"
:key="choice.id"
@click="selectAnswer(choice.id)"
class="group relative w-full p-5 rounded-2xl border-2 text-left transition-all duration-200 flex items-center gap-4"
:class="userAnswers[currentQuestion.id] === choice.id
? 'border-blue-500 bg-blue-50 dark:bg-blue-500/10'
: 'border-slate-100 dark:border-white/10 hover:border-blue-200 dark:hover:border-blue-500/30 bg-transparent'"
>
<div class="w-6 h-6 rounded-full border-2 flex items-center justify-center flex-shrink-0 transition-colors"
:class="userAnswers[currentQuestion.id] === choice.id ? 'border-blue-500 bg-blue-500' : 'border-slate-300 dark:border-white/20 group-hover:border-blue-400'"
>
<q-icon v-if="userAnswers[currentQuestion.id] === choice.id" name="check" size="xs" class="text-white" />
</div>
<span class="text-slate-700 dark:text-slate-200 font-medium text-lg">{{ getLocalizedText(choice.text) }}</span>
</button>
</div>
<!-- Controls -->
<div class="flex justify-between items-center pt-8 border-t border-slate-100 dark:border-white/5">
<button
@click="prevQuestion"
:disabled="currentQuestionIndex === 0"
class="px-6 py-3 rounded-xl font-bold text-slate-500 hover:bg-slate-50 dark:hover:bg-white/5 disabled:opacity-30 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
>
<q-icon name="arrow_back" /> {{ $t('common.back', 'ย้อนกลับ') }}
</button>
<button
v-if="currentQuestionIndex < totalQuestions - 1"
@click="nextQuestion"
class="px-8 py-3 bg-slate-900 dark:bg-white text-white dark:text-slate-900 rounded-xl font-bold hover:opacity-90 transition-all flex items-center gap-2"
>
{{ $t('common.next', 'ถัดไป') }} <q-icon name="arrow_forward" />
</button>
<button
v-else
@click="submitQuiz(false)"
class="px-8 py-3 bg-blue-600 text-white rounded-xl font-bold hover:bg-blue-500 transition-all shadow-lg shadow-blue-500/30 flex items-center gap-2"
>
{{ $t('quiz.submitValues') }} <q-icon name="check" />
</button>
</div>
</div>
</div>
<!-- 3. RESULT SCREEN -->
<div v-if="currentScreen === 'result'" class="w-full max-w-[640px] animate-fade-in py-12">
<div class="bg-white dark:bg-[#1e293b] border border-slate-200 dark:border-white/5 rounded-[40px] p-10 shadow-2xl text-center">
<div class="mb-8">
<div class="w-24 h-24 bg-emerald-50 dark:bg-emerald-500/10 rounded-full flex items-center justify-center mx-auto mb-6">
<q-icon name="check_circle" size="3rem" class="text-emerald-500" />
</div>
<h2 class="text-2xl font-black mb-2 text-slate-900 dark:text-white">{{ $t('quiz.scoreTitle') }}</h2>
<p class="text-slate-500 dark:text-slate-400">{{ $t('quiz.passMessage') }}</p>
<div class="bg-white dark:bg-[#1e293b] border border-slate-200 dark:border-white/5 rounded-[40px] p-10 shadow-2xl text-center relative overflow-hidden">
<div v-if="isSubmitting" class="absolute inset-0 bg-white/80 dark:bg-slate-900/80 z-20 flex flex-col items-center justify-center">
<q-spinner color="primary" size="3em" />
<p class="mt-4 font-bold animate-pulse">{{ $t('quiz.submitting') }}</p>
</div>
<div class="space-y-4 mt-8">
<div class="mb-8 relative z-10">
<div class="w-28 h-28 rounded-full flex items-center justify-center mx-auto mb-6 shadow-xl border-4"
:class="quizResult?.is_passed ? 'bg-emerald-50 dark:bg-emerald-500/10 border-emerald-100 dark:border-emerald-500/20' : 'bg-red-50 dark:bg-red-500/10 border-red-100 dark:border-red-500/20'"
>
<q-icon :name="quizResult?.is_passed ? 'emoji_events' : 'close'" size="4rem" :color="quizResult?.is_passed ? 'positive' : 'negative'" />
</div>
<h2 class="text-3xl font-black mb-2 text-slate-900 dark:text-white uppercase tracking-tight">
{{ quizResult?.is_passed ? $t('quiz.resultPassed') : $t('quiz.resultFailed') }}
</h2>
<p class="text-slate-500 dark:text-slate-400 font-medium text-lg">
{{ quizResult?.is_passed ? $t('quiz.passMessage') : $t('quiz.failMessage') }}
</p>
</div>
<!-- Score Card -->
<div class="bg-slate-50 dark:bg-[#0b121f] rounded-3xl p-6 mb-8 flex items-center justify-around border border-slate-100 dark:border-white/5">
<div class="text-center">
<div class="text-xs font-black text-slate-400 uppercase tracking-widest mb-1">{{ $t('quiz.scoreLabel') }}</div>
<div class="text-4xl font-black text-blue-600 dark:text-blue-400">{{ quizResult?.score }}<span class="text-lg text-slate-400 font-bold">/{{ quizResult?.total_score }}</span></div>
</div>
<div class="w-[1px] h-12 bg-slate-200 dark:bg-white/10"></div>
<div class="text-center">
<div class="text-xs font-black text-slate-400 uppercase tracking-widest mb-1">{{ $t('quiz.correctLabel') }}</div>
<div class="text-4xl font-black text-emerald-500">{{ quizResult?.correct_answers }}<span class="text-lg text-slate-400 font-bold">/{{ quizResult?.total_questions }}</span></div>
</div>
</div>
<div class="space-y-4 relative z-10">
<button
@click="confirmExit"
class="w-full py-5 bg-blue-600 text-white rounded-[24px] font-black text-sm shadow-lg shadow-blue-500/20"
class="w-full py-4 bg-slate-900 dark:bg-white text-white dark:text-slate-900 rounded-[20px] font-black text-sm hover:opacity-90 transition-all"
>
{{ $t('quiz.backToLesson') }}
</button>
<button
v-if="!quizResult?.is_passed"
@click="retryQuiz"
class="w-full py-4 text-slate-500 hover:text-slate-800 dark:hover:text-white font-bold text-sm transition-colors"
>
{{ $t('quiz.retryBtn') }}
</button>
</div>
</div>
</div>