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 <video
controls controls
class="w-full h-full object-cover" 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"> <source :src="course.media.video_url" type="video/mp4">
Your browser does not support the video tag. Your browser does not support the video tag.
</video> </video>
</template> </template>
<!-- Placeholder if no video --> <!-- Beautiful Image Showcase if no video -->
<template v-else> <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"> <div class="w-full h-full flex items-center justify-center relative overflow-hidden bg-slate-950">
<!-- Subtle background pattern or logo could go here --> <!-- Show Thumbnail as Background if exists (Blurred background fill) -->
<div class="absolute inset-0 opacity-10 dark:opacity-20"> <img
<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> v-if="course.thumbnail_url || course.cover_image"
</div> :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"> <!-- Main Sharp Image -->
<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"> <img
<q-icon name="play_arrow" size="48px" class="text-white drop-shadow-md" /> v-if="course.thumbnail_url || course.cover_image"
</div> :src="course.thumbnail_url || course.cover_image"
<span class="text-slate-500 dark:text-slate-400 font-bold tracking-wider uppercase text-xs">{{ $t('course.noVideoPreview') || 'Preview Not Available' }}</span> 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>
</div> </div>
</template> </template>

View file

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

View file

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

View file

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