feat: Implement course detail viewing and enrollment functionality with a new useCourse composable.

This commit is contained in:
supalerk-ar66 2026-02-11 17:06:18 +07:00
parent a65ded02f9
commit 23d9e44cc9
4 changed files with 65 additions and 27 deletions

View file

@ -66,25 +66,31 @@ const handleEnroll = () => {
<video
controls
class="w-full h-full object-cover"
:poster="course.cover_image || 'https://placehold.co/800x450?text=Course+Preview'"
:poster="course.thumbnail_url || course.cover_image || 'https://placehold.co/800x450?text=Course+Preview'"
>
<source :src="course.media.video_url" type="video/mp4">
Your browser does not support the video tag.
</video>
</template>
<!-- Placeholder if no video -->
<!-- Beautiful Image Showcase if no video -->
<template v-else>
<div class="w-full h-full bg-gradient-to-br from-slate-200 to-slate-300 dark:from-slate-800 dark:to-slate-900 flex items-center justify-center relative overflow-hidden">
<!-- Subtle background pattern or logo could go here -->
<div class="absolute inset-0 opacity-10 dark:opacity-20">
<div class="absolute top-0 left-0 w-full h-full" style="background-image: radial-gradient(circle at 2px 2px, currentColor 1px, transparent 0); background-size: 24px 24px;"></div>
</div>
<div class="w-full h-full flex items-center justify-center relative overflow-hidden bg-slate-950">
<!-- Show Thumbnail as Background if exists (Blurred background fill) -->
<img
v-if="course.thumbnail_url || course.cover_image"
:src="course.thumbnail_url || course.cover_image"
class="absolute inset-0 w-full h-full object-cover opacity-30 blur-xl scale-110"
/>
<div class="relative z-10 flex flex-col items-center gap-4">
<div class="w-20 h-20 bg-white/30 dark:bg-white/10 backdrop-blur-md rounded-full flex items-center justify-center ring-1 ring-white/50 shadow-xl transition-transform group-hover:scale-110 duration-500">
<q-icon name="play_arrow" size="48px" class="text-white drop-shadow-md" />
</div>
<span class="text-slate-500 dark:text-slate-400 font-bold tracking-wider uppercase text-xs">{{ $t('course.noVideoPreview') || 'Preview Not Available' }}</span>
<!-- Main Sharp Image -->
<img
v-if="course.thumbnail_url || course.cover_image"
:src="course.thumbnail_url || course.cover_image"
class="relative z-10 w-full h-full object-contain shadow-2xl shadow-black/50"
/>
<div v-else class="absolute inset-0 bg-gradient-to-br from-slate-800 to-slate-900 flex items-center justify-center">
<q-icon name="image" size="80px" class="text-slate-700 opacity-50" />
</div>
</div>
</template>

View file

@ -18,6 +18,7 @@ export interface Course {
approved_at?: string
approved_by?: number
rejection_reason?: string
enrolled?: boolean
rating?: string
@ -253,21 +254,20 @@ export const useCourse = () => {
message: data.message
}
} catch (err: any) {
console.error('Enroll course failed:', err)
// เช็ค 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
// เช็ค Error 409 Conflict หรือ 400 Bad Request (กรณีลงทะเบียนไปแล้ว)
// สำหรับกรณีนี้ เราจะไม่ log console.error ให้รกหน้าจอเพราะเป็นเรื่องที่ดักจับได้
if (status === 409 || (status === 400 && errorData?.message?.includes('Already enrolled'))) {
return {
success: false,
error: 'ท่านได้ลงทะเบียนไปแล้ว',
code: 409 // Treat as conflict logic internally
code: 409 // treat internally as conflict
}
}
console.error('Enroll course failed:', err)
return {
success: false,
error: errorData?.message || err.message || 'Error enrolling in course',

View file

@ -36,7 +36,7 @@ export default defineNuxtConfig({
extras: {
fontIcons: ["material-icons"],
},
plugins: ["Notify"], // เปิดใช้ Plugin Notify
plugins: ["Notify", "Dialog"], // เปิดใช้ Plugin Notify และ Dialog
config: {
brand: { // กำหนดชุดสีหลัก (Theme Colors)
primary: "#4b82f7",

View file

@ -31,6 +31,23 @@ const isEnrolling = ref(false)
const handleEnroll = async () => {
if (!course.value) return
if (isEnrolling.value) return
// (Check )
if (course.value.enrolled) {
$q.dialog({
message: `<div class="text-slate-800 text-base leading-relaxed">ท่านเคยลงทะเบียนคอร์ส <b class="text-blue-600">"${getLocalizedText(course.value.title)}"</b> นี้ไปเรียบร้อยแล้ว</div>`,
html: true,
ok: {
label: 'ตกลง',
color: 'primary',
rounded: true,
unelevated: true,
padding: '8px 32px'
}
})
return
}
isEnrolling.value = true
// API
@ -38,7 +55,6 @@ const handleEnroll = async () => {
if (res.success) {
// "" params enrolled=true
// Use object syntax for robust query param handling
const targetId = route.params.id || course.value?.id
return navigateTo({
path: '/dashboard/my-courses',
@ -47,14 +63,30 @@ const handleEnroll = async () => {
course_id: String(targetId)
}
})
// error Toast notification alert
$q.notify({
type: 'negative',
message: res.error || 'Failed to enroll',
position: 'top',
timeout: 3000,
actions: [{ icon: 'close', color: 'white' }]
})
} else {
// API (Code 409)
if (res.code === 409) {
$q.dialog({
message: `<div class="text-slate-800 text-base leading-relaxed">ท่านเคยลงทะเบียนคอร์ส <b class="text-blue-600">"${getLocalizedText(course.value.title)}"</b> นี้ไปเรียบร้อยแล้ว</div>`,
html: true,
ok: {
label: 'ตกลง',
color: 'primary',
rounded: true,
unelevated: true,
padding: '8px 32px'
}
})
} else {
// error Toast notification
$q.notify({
type: 'negative',
message: res.error || 'Failed to enroll',
position: 'top',
timeout: 3000,
actions: [{ icon: 'close', color: 'white' }]
})
}
}
isEnrolling.value = false