feat: Implement dashboard with recommended courses and dedicated course detail pages using a new useCourse composable.

This commit is contained in:
supalerk-ar66 2026-01-22 10:46:44 +07:00
parent 3c9703ebfa
commit ffd2d55e33
3 changed files with 134 additions and 68 deletions

View file

@ -24,6 +24,17 @@ export interface Course {
rating?: string
lessons?: number | string
levelType?: 'neutral' | 'warning' | 'success' // For UI badging
chapters?: {
id: number
title: string | { th: string; en: string }
lessons: {
id: number
title: string | { th: string; en: string }
duration_minutes: number
video_url?: string
}[]
}[]
}
interface CourseResponse {

View file

@ -5,28 +5,52 @@
* Displays detailed information about a specific course based on the ID.
*/
definePageMeta({
layout: 'default',
middleware: 'auth'
})
const route = useRoute()
const courseId = computed(() => parseInt(route.params.id as string))
const { fetchCourseById } = useCourse()
const { fetchCourseById, enrollCourse } = useCourse()
// Fetch course data
const { data: courseData, error } = await useAsyncData(`course-${courseId.value}`, () => fetchCourseById(courseId.value))
const { data: courseData, error, refresh } = await useAsyncData(`course-${courseId.value}`, () => fetchCourseById(courseId.value))
const course = computed(() => {
return courseData.value?.success ? courseData.value.data : null
})
// Enroll State
const isEnrolling = ref(false)
const handleEnroll = async () => {
if (!course.value) return
if (isEnrolling.value) return
isEnrolling.value = true
const res = await enrollCourse(course.value.id)
if (res.success) {
// Navigate to my-courses
return navigateTo('/dashboard/my-courses?enrolled=true')
} else {
// Handle error (alert for now, could be toast)
alert(res.error || 'Failed to enroll')
}
isEnrolling.value = false
}
// Helper for localization
const getLocalizedText = (text: string | { th: string; en: string } | undefined | null) => {
if (!text) return ''
if (typeof text === 'string') return text
// @ts-ignore
return text.th || text.en || ''
}
definePageMeta({
layout: 'landing',
auth: false // Explicitly public? Or check logic.
})
useHead({
title: computed(() => course.value ? `${getLocalizedText(course.value.title)} - E-Learning` : 'Course Details')
@ -34,102 +58,133 @@ useHead({
</script>
<template>
<div class="relative min-h-screen text-slate-200 bg-slate-900 transition-colors pt-32 pb-20">
<!-- Background Effects -->
<div class="fixed inset-0 overflow-hidden pointer-events-none -z-10">
<div class="absolute top-[-20%] right-[-10%] w-[60%] h-[60%] rounded-full bg-blue-600/10 blur-[140px] animate-pulse-slow"/>
<div class="absolute bottom-[-20%] left-[-10%] w-[60%] h-[60%] rounded-full bg-indigo-600/10 blur-[140px] animate-pulse-slow" style="animation-delay: 3s;"/>
</div>
<div class="container mx-auto max-w-6xl px-6">
<div class="container mx-auto max-w-7xl px-4 py-8">
<!-- Back Button -->
<NuxtLink to="/browse" class="inline-flex items-center gap-2 text-slate-400 hover:text-white mb-8 transition-colors">
<NuxtLink to="/browse/discovery" class="inline-flex items-center gap-2 text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white mb-6 transition-colors font-medium">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
กลบหนารายการคอร
{{ $t('discovery.backToCatalog') }}
</NuxtLink>
<div v-if="course" class="grid grid-cols-1 lg:grid-cols-12 gap-10">
<div v-if="course" class="grid grid-cols-1 lg:grid-cols-12 gap-8">
<!-- Main Content (Left Column) -->
<div class="lg:col-span-8">
<!-- Hero Video Placeholder / Thumbnail -->
<div class="relative aspect-video bg-slate-800 rounded-3xl overflow-hidden border border-white/5 shadow-2xl mb-8 group cursor-pointer">
<div class="relative aspect-video bg-slate-900 rounded-2xl overflow-hidden shadow-lg mb-8 group cursor-pointer border border-slate-200 dark:border-slate-700">
<div v-if="course.thumbnail_url" class="absolute inset-0">
<img :src="course.thumbnail_url" class="w-full h-full object-cover opacity-80 group-hover:opacity-60 transition-opacity" />
<img :src="course.thumbnail_url" class="w-full h-full object-cover opacity-90 group-hover:opacity-75 transition-opacity" />
</div>
<div class="absolute inset-0 bg-gradient-to-br from-slate-800/50 to-slate-900/50 flex items-center justify-center">
<div class="w-20 h-20 rounded-full bg-blue-600/80 backdrop-blur flex items-center justify-center shadow-[0_0_30px_rgba(37,99,235,0.5)] group-hover:scale-110 transition-transform duration-300">
<div class="ml-1 w-0 h-0 border-t-[12px] border-t-transparent border-l-[20px] border-l-white border-b-[12px] border-b-transparent"/>
<!-- Play Icon Overlay -->
<div class="absolute inset-0 flex items-center justify-center">
<div class="w-16 h-16 md:w-20 md:h-20 bg-blue-600 rounded-full flex items-center justify-center shadow-lg shadow-blue-600/40 group-hover:scale-110 transition-transform duration-300">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 md:h-10 md:w-10 text-white ml-1" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
</svg>
</div>
</div>
</div>
<h1 class="text-3xl md:text-4xl font-bold text-slate-900 dark:text-white mb-4 leading-tight">{{ getLocalizedText(course.title) }}</h1>
<div class="prose prose-slate dark:prose-invert max-w-none mb-10 text-slate-600 dark:text-slate-300">
<p class="text-lg leading-relaxed">{{ getLocalizedText(course.description) }}</p>
</div>
<!-- Course Syllabus (Dynamic) -->
<div class="bg-white dark:bg-slate-800 rounded-3xl p-6 md:p-8 shadow-sm border border-slate-100 dark:border-slate-700/50">
<h3 class="font-bold text-xl text-slate-900 dark:text-white mb-6 flex items-center gap-2">
<span class="w-1 h-6 bg-blue-500 rounded-full"></span>
{{ $t('course.courseContent') }}
</h3>
<div v-if="course.chapters && course.chapters.length > 0">
<div v-for="(chapter, cIdx) in course.chapters" :key="cIdx" class="mb-6 last:mb-0">
<div class="flex items-center justify-between p-4 bg-slate-50 dark:bg-slate-700/50 rounded-xl mb-2 border border-slate-100 dark:border-slate-600">
<span class="font-bold text-slate-800 dark:text-slate-100">{{ getLocalizedText(chapter.title) }}</span>
<span class="text-xs font-semibold px-2 py-1 bg-white dark:bg-slate-600 rounded text-slate-500 dark:text-slate-300 border border-slate-200 dark:border-slate-500">
{{ chapter.lessons ? chapter.lessons.length : 0 }} {{ $t('course.lessons') }}
</span>
</div>
<div class="pl-4 pr-2 space-y-1">
<div v-for="(lesson, lIdx) in chapter.lessons" :key="lIdx" class="flex items-center justify-between py-2.5 px-3 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors group">
<div class="flex items-center gap-3 overflow-hidden">
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-slate-100 dark:bg-slate-700 text-slate-400 dark:text-slate-500 text-[10px] flex items-center justify-center font-bold">
{{ Number(lIdx) + 1 }}
</span>
<span class="text-sm text-slate-600 dark:text-slate-300 truncate group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">{{ getLocalizedText(lesson.title) }}</span>
</div>
<span class="text-xs text-slate-400 flex-shrink-0 font-mono">{{ lesson.duration_minutes || 0 }}:00</span>
</div>
</div>
</div>
</div>
</div>
<h1 class="text-4xl font-black text-white mb-6 leading-tight">{{ getLocalizedText(course.title) }}</h1>
<p class="text-slate-400 text-lg leading-relaxed mb-10">
{{ getLocalizedText(course.description) }}
</p>
<!-- Learning Objectives (Placeholder if not in API) -->
<!--
<div class="p-8 rounded-3xl bg-white/5 border border-white/5 mb-10">
<h3 class="font-bold text-xl text-white mb-6">งทณจะไดเรยนร</h3>
<ul class="grid grid-cols-1 md:grid-cols-2 gap-4">
...
</ul>
</div>
-->
<!-- Course Syllabus (Placeholder if not in API) -->
<div class="p-8 rounded-3xl bg-white/5 border border-white/5">
<h3 class="font-bold text-xl text-white mb-6">เนอหาในคอร</h3>
<p class="text-slate-500">
{{ course.lessons ? `${course.lessons} บทเรียน` : 'ยังไม่มีเนื้อหาแสดง' }}
</p>
<div v-else class="text-center py-8 text-slate-400">
<p>{{ $t('course.noContent') }}</p>
</div>
</div>
</div>
<!-- Sidebar (Right Column): Sticky CTA -->
<div class="lg:col-span-4">
<div class="sticky top-28 p-8 rounded-3xl bg-slate-800/80 backdrop-blur-xl border border-white/10 shadow-2xl">
<div class="mb-8 text-center lg:text-left">
<span class="text-lg text-slate-500 line-through mr-4" v-if="course.original_price">{{ course.original_price }}</span>
<span class="text-5xl font-black text-white tracking-tight">{{ course.is_free ? 'ฟรี' : course.price }}</span>
<div class="sticky top-24 bg-white dark:bg-slate-800 rounded-3xl p-6 md:p-8 shadow-lg border border-slate-100 dark:border-slate-700/50">
<div class="mb-8">
<span class="text-sm text-slate-400 line-through mr-3" v-if="course.original_price">{{ course.original_price }}</span>
<div class="flex items-center gap-2">
<h2 class="text-4xl font-black text-blue-600 dark:text-blue-400 tracking-tight">{{ course.is_free ? 'ฟรี' : course.price }}</h2>
</div>
</div>
<NuxtLink to="/auth/register" class="flex items-center justify-center w-full py-4 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold text-lg shadow-lg shadow-blue-600/30 transition-all hover:-translate-y-1 mb-6">
ลงทะเบยนเรยนทนท
</NuxtLink>
<!-- Enroll Button -->
<button
@click="handleEnroll"
:disabled="isEnrolling"
class="w-full py-4 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 text-white rounded-xl font-bold text-lg shadow-lg shadow-blue-600/30 transition-all hover:translate-y-[-2px] mb-6 flex items-center justify-center disabled:opacity-70 disabled:cursor-not-allowed disabled:transform-none"
>
<span v-if="isEnrolling" class="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"></span>
{{ isEnrolling ? 'กำลังลงทะเบียน...' : $t('course.enrollNow') }}
</button>
<div class="space-y-4 text-sm text-slate-400">
<div class="flex justify-between py-3 border-b border-white/5">
<span>จำนวนบทเรยน</span>
<span class="font-bold text-white">{{ course.lessons || 0 }} บท</span>
<div class="space-y-4 text-sm text-slate-600 dark:text-slate-400 pt-6 border-t border-slate-100 dark:border-slate-700">
<div class="flex justify-between py-2">
<span>{{ $t('course.certificate') }}</span>
<span class="font-bold text-slate-900 dark:text-white">{{ course.have_certificate ? $t('course.available') : '-' }}</span>
</div>
<div class="flex justify-between py-3 border-b border-white/5">
<span>ใบประกาศนยบตร</span>
<span class="font-bold text-white">{{ course.have_certificate ? 'มี' : 'ไม่มี' }}</span>
</div>
<div class="flex justify-between py-3 border-b border-white/5">
<div class="flex justify-between py-2">
<span>ระด</span>
<span class="font-bold text-white">งหมด</span>
<span class="font-bold text-slate-900 dark:text-white">งหมด</span>
</div>
<div class="flex justify-between py-2">
<span>การเขาถ</span>
<span class="font-bold text-slate-900 dark:text-white">ตลอดช</span>
</div>
</div>
<!-- Share / Wishlist placeholder -->
<!-- <div class="mt-6 flex gap-3">
<button class="flex-1 py-2 rounded-lg border border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-400 font-bold hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors">แชร</button>
</div> -->
</div>
</div>
</div>
<!-- Loading / Error State -->
<div v-else class="text-center py-20 text-slate-400">
<p v-if="error">เกิดข้อผิดพลาดในการโหลดข้อมูล</p>
<p v-else>กำลังโหลด...</p>
<div v-else class="text-center py-20">
<div v-if="error" class="text-red-500 mb-4">
<p class="font-bold">เกดขอผดพลาดในการโหลดขอม</p>
<p class="text-sm opacity-80">{{ error.message }}</p>
</div>
<div v-else class="flex flex-col items-center gap-4 text-slate-400">
<div class="w-10 h-10 border-4 border-blue-500/30 border-t-blue-500 rounded-full animate-spin"/>
<p>กำลงโหลด...</p>
</div>
</div>
</div>
</div>
</template>

View file

@ -85,7 +85,7 @@ onMounted(async () => {
<!-- Recommended Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<NuxtLink v-for="(course, idx) in recommendedCourses" :key="course.id" to="/browse/discovery" class="p-0 overflow-hidden group border border-slate-200 dark:border-white/5 rounded-3xl shadow-sm dark:shadow-xl transition-all hover:-translate-y-1 dark:hover:-translate-y-1 block" style="background-color: var(--bg-surface);">
<NuxtLink v-for="(course, idx) in recommendedCourses" :key="course.id" :to="`/course/${course.id}`" class="p-0 overflow-hidden group border border-slate-200 dark:border-white/5 rounded-3xl shadow-sm dark:shadow-xl transition-all hover:-translate-y-1 dark:hover:-translate-y-1 block" style="background-color: var(--bg-surface);">
<div class="h-48 overflow-hidden relative rounded-t-3xl">
<img v-if="course.image" :src="course.image" :alt="course.title" class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700" >
<div v-else class="w-full h-full bg-slate-200 dark:bg-slate-700 flex items-center justify-center">