feat: Add course detail page, authentication composable, and course browsing page.

This commit is contained in:
supalerk-ar66 2026-01-21 10:02:37 +07:00
parent 1e06041769
commit 327f6ec7b5
3 changed files with 230 additions and 115 deletions

View file

@ -69,26 +69,22 @@ export const useAuth = () => {
// Login
const login = async (credentials: { email: string; password: string }) => {
try {
const { data, error } = await useFetch<loginResponse>(`${API_BASE_URL}/auth/login`, {
const data = await $fetch<loginResponse>(`${API_BASE_URL}/auth/login`, {
method: 'POST',
body: credentials
})
if (error.value) {
throw error.value
}
if (data.value) {
if (data) {
// Validation: Only allow STUDENT role to login
if (data.value.user.role.code !== 'STUDENT') {
if (data.user.role.code !== 'STUDENT') {
return { success: false, error: 'Email ไม่ถูกต้อง' }
}
token.value = data.value.token
refreshToken.value = data.value.refreshToken // Save refresh token
token.value = data.token
refreshToken.value = data.refreshToken // Save refresh token
// The API returns the profile nested inside the user object
user.value = data.value.user
user.value = data.user
return { success: true }
}
@ -105,16 +101,12 @@ export const useAuth = () => {
// Register
const register = async (payload: RegisterPayload) => {
try {
const { data, error } = await useFetch(`${API_BASE_URL}/auth/register`, {
const data = await $fetch(`${API_BASE_URL}/auth/register`, {
method: 'POST',
body: payload
})
if (error.value) {
throw error.value
}
return { success: true, data: data.value }
return { success: true, data }
} catch (err: any) {
console.error('Register failed:', err)
@ -130,42 +122,41 @@ export const useAuth = () => {
if (!token.value) return
try {
const { data, error } = await useFetch<User>(`${API_BASE_URL}/user/me`, {
const data = await $fetch<User>(`${API_BASE_URL}/user/me`, {
headers: {
Authorization: `Bearer ${token.value}`
}
})
if (error.value) {
if (error.value.statusCode === 401) {
// Try to refresh token
const refreshed = await refreshAccessToken()
if (refreshed) {
// Retry fetch with new token
const { data: retryData, error: retryError } = await useFetch<User>(`${API_BASE_URL}/user/me`, {
if (data) {
user.value = data
}
} catch (error: any) {
if (error.statusCode === 401) {
// Try to refresh token
const refreshed = await refreshAccessToken()
if (refreshed) {
// Retry fetch with new token
try {
const retryData = await $fetch<User>(`${API_BASE_URL}/user/me`, {
headers: {
Authorization: `Bearer ${token.value}`
}
})
if (retryData.value) {
user.value = retryData.value
if (retryData) {
user.value = retryData
return
}
if (retryError.value) throw retryError.value
} else {
logout()
}
}
throw error.value
} catch (retryErr) {
console.error('Failed to fetch user profile after refresh:', retryErr)
}
} else {
logout()
}
} else {
console.error('Failed to fetch user profile:', error)
}
if (data.value) {
user.value = data.value
}
} catch (err) {
console.error('Failed to fetch user profile:', err)
}
}
@ -179,7 +170,7 @@ export const useAuth = () => {
if (!token.value) return
try {
const { error } = await useFetch(`${API_BASE_URL}/user/me`, {
await $fetch(`${API_BASE_URL}/user/me`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${token.value}`
@ -187,8 +178,6 @@ export const useAuth = () => {
body: payload
})
if (error.value) throw error.value
// If successful, refresh the local user data
await fetchUserProfile()
@ -202,12 +191,11 @@ export const useAuth = () => {
// Request Password Reset
const requestPasswordReset = async (email: string) => {
try {
const { error } = await useFetch(`${API_BASE_URL}/auth/reset-request`, {
await $fetch(`${API_BASE_URL}/auth/reset-request`, {
method: 'POST',
body: { email }
})
if (error.value) throw error.value
return { success: true }
} catch (err: any) {
return { success: false, error: err.data?.message || 'ส่งคำขอไม่สำเร็จ' }
@ -217,12 +205,11 @@ export const useAuth = () => {
// Confirm Reset Password
const confirmResetPassword = async (payload: { token: string; password: string }) => {
try {
const { error } = await useFetch(`${API_BASE_URL}/auth/reset-password`, {
await $fetch(`${API_BASE_URL}/auth/reset-password`, {
method: 'POST',
body: payload
})
if (error.value) throw error.value
return { success: true }
} catch (err: any) {
return { success: false, error: err.data?.message || 'รีเซ็ตรหัสผ่านไม่สำเร็จ' }
@ -234,7 +221,7 @@ export const useAuth = () => {
if (!token.value) return { success: false, error: 'ไม่พบ Token การใช้งาน' }
try {
const { data, error } = await useFetch(`${API_BASE_URL}/user/change-password`, {
await $fetch(`${API_BASE_URL}/user/change-password`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token.value}`
@ -242,7 +229,6 @@ export const useAuth = () => {
body: payload
})
if (error.value) throw error.value
return { success: true }
} catch (err: any) {
return { success: false, error: err.data?.message || 'เปลี่ยนรหัสผ่านไม่สำเร็จ' }
@ -254,16 +240,14 @@ export const useAuth = () => {
if (!refreshToken.value) return false
try {
const { data, error } = await useFetch<{ token: string; refreshToken: string }>(`${API_BASE_URL}/auth/refresh`, {
const data = await $fetch<{ token: string; refreshToken: string }>(`${API_BASE_URL}/auth/refresh`, {
method: 'POST',
body: { refreshToken: refreshToken.value }
})
if (error.value) throw error.value
if (data.value) {
token.value = data.value.token
refreshToken.value = data.value.refreshToken
if (data) {
token.value = data.token
refreshToken.value = data.refreshToken
return true
}
} catch (err) {

View file

@ -17,54 +17,25 @@ useHead({
// Reactive state for the search input
const searchQuery = ref('')
const { fetchCourses } = useCourse()
/**
* @const courses
* @description Mock data for available courses. In a real app, this would be fetched from an API.
* Each course object contains: id, title, level, levelType (for badge color), price, description, rating, lessons.
*/
const courses = [
{
id: 1,
title: 'เบื้องต้นการออกแบบ UX/UI',
price: 'ฟรี',
description: 'เรียนรู้พื้นฐานการวาดโครงร่างและการใช้งานเครื่องมือออกแบบยุคใหม่',
rating: '4.8',
lessons: '12'
},
{
id: 2,
title: 'รูปแบบ React ขั้นสูง',
price: 'ฟรี',
description: 'เจาะลึก HOC, Hooks และการจัดการ State ขนาดใหญ่ในแอปพลิเคชัน',
rating: '4.9',
lessons: '24'
},
{
id: 3,
title: 'การตลาดดิจิทัล 101',
price: 'ฟรี',
description: 'คู่มือสมบูรณ์สำหรับการทำ SEO/SEM และการวิเคราะห์ข้อมูลผู้ใช้',
rating: '4.7',
lessons: '18'
},
{
id: 4,
title: 'Full-stack Node.js Developer',
price: 'ฟรี',
description: 'สร้าง API ที่ยืดหยุ่นและมีประสิทธิภาพด้วย Node.js และ Express',
rating: '4.9',
lessons: '32'
},
{
id: 4,
title: 'Full-stack Node.js Developer',
price: 'ฟรี',
description: 'สร้าง API ที่ยืดหยุ่นและมีประสิทธิภาพด้วย Node.js และ Express',
rating: '4.9',
lessons: '32'
// Helper to handle localized text
const getLocalizedText = (text: string | { th: string; en: string } | undefined) => {
if (!text) return ''
if (typeof text === 'string') return text
return text.th || text.en || ''
}
// Fetch courses from API
const { data: coursesResponse, error } = await useAsyncData('courses-list', () => fetchCourses())
// Computed property for courses list from API response
const courses = computed(() => {
if (coursesResponse.value?.success) {
return coursesResponse.value.data
}
]
return []
})
/**
* @computed filteredCourses
@ -72,12 +43,15 @@ const courses = [
* Checks both the course title and description (case-insensitive).
*/
const filteredCourses = computed(() => {
if (!searchQuery.value) return courses
const list = courses.value || []
if (!searchQuery.value) return list
const query = searchQuery.value.toLowerCase()
return courses.filter(c =>
c.title.toLowerCase().includes(query) ||
c.description.toLowerCase().includes(query)
)
return list.filter(c => {
const title = getLocalizedText(c.title).toLowerCase()
const desc = getLocalizedText(c.description).toLowerCase()
return title.includes(query) || desc.includes(query)
})
})
</script>
@ -146,46 +120,58 @@ const filteredCourses = computed(() => {
class="glass-card group flex flex-col h-full hover:-translate-y-2 transition-transform duration-500 slide-up"
:style="{ animationDelay: `${0.1 * (index + 1)}s` }"
>
<!-- Card Image / Placeholder Area -->
<!-- Card Image -->
<div class="h-56 bg-gradient-to-br from-slate-800 to-slate-900 relative overflow-hidden group-hover:opacity-90 transition-opacity">
<div class="absolute inset-0 flex items-center justify-center">
<img
v-if="course.thumbnail_url"
:src="course.thumbnail_url"
:alt="getLocalizedText(course.title)"
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
/>
<div
v-else-if="!course.thumbnail_url || true"
class="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-slate-800 to-slate-900"
>
<div class="w-20 h-20 rounded-2xl bg-white/5 flex items-center justify-center text-5xl group-hover:scale-110 transition-transform duration-500 shadow-inner border border-white/5">
📚
</div>
</div>
<!-- Level Badge (Neutral/Warning/Success variants) -->
<!-- Level Badge (Neutral/Warning/Success variants) - Optional based on data -->
<div v-if="course.levelType" class="absolute top-4 right-4">
<!-- Add badge logic if needed -->
</div>
</div>
<!-- Card Content Body -->
<div class="p-8 flex-1 flex flex-col border-t border-white/5">
<h3 class="text-2xl font-bold text-white mb-3 leading-tight group-hover:text-blue-400 transition-colors">
{{ course.title }}
{{ getLocalizedText(course.title) }}
</h3>
<p class="text-slate-400 text-sm mb-8 line-clamp-2 leading-relaxed flex-1">
{{ course.description }}
{{ getLocalizedText(course.description) }}
</p>
<!-- Meta Information (Rating, Lessons) -->
<div class="flex items-center gap-6 mb-8 text-sm font-medium text-slate-500">
<div class="flex items-center gap-2">
<span class="text-amber-400"></span>
<span class="text-slate-300">{{ course.rating }}</span>
<span class="text-slate-300">{{ course.rating || 'N/A' }}</span>
</div>
<div class="w-1 h-1 rounded-full bg-slate-700" />
<div class="flex items-center gap-2">
<span class="opacity-70">📖</span>
<span class="text-slate-300">{{ course.lessons }} บทเรยน</span>
<span class="text-slate-300">{{ course.lessons || 0 }} บทเรยน</span>
</div>
</div>
<!-- Card Footer (Price & Action) -->
<div class="pt-6 border-t border-white/5 flex items-center justify-between mt-auto">
<span class="text-2xl font-black text-blue-400 tracking-tight">
{{ course.price }}
{{ course.is_free ? 'ฟรี' : course.price }}
</span>
<NuxtLink
to="/browse/opencovery"
:to="`/course/${course.id}`"
class="px-6 py-3 bg-white/10 hover:bg-blue-600 text-white rounded-xl text-sm font-bold transition-all border border-white/10 hover:border-blue-500/50"
>
รายละเอยด

View file

@ -0,0 +1,145 @@
<script setup lang="ts">
/**
* @file [id].vue
* @description Dynamic Course Detail Page.
* Displays detailed information about a specific course based on the ID.
*/
const route = useRoute()
const courseId = computed(() => parseInt(route.params.id as string))
const { fetchCourseById } = useCourse()
// Fetch course data
const { data: courseData, error } = await useAsyncData(`course-${courseId.value}`, () => fetchCourseById(courseId.value))
const course = computed(() => {
return courseData.value?.success ? courseData.value.data : null
})
// Helper for localization
const getLocalizedText = (text: string | { th: string; en: string } | undefined | null) => {
if (!text) return ''
if (typeof text === 'string') return text
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')
})
</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">
<!-- Back Button -->
<NuxtLink to="/browse" class="inline-flex items-center gap-2 text-slate-400 hover:text-white mb-8 transition-colors">
<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>
กลบหนารายการคอร
</NuxtLink>
<div v-if="course" class="grid grid-cols-1 lg:grid-cols-12 gap-10">
<!-- 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 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" />
</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"/>
</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>
</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>
<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>
<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>
<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">
<span>ระด</span>
<span class="font-bold text-white">งหมด</span>
</div>
</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>
</div>
</div>
</template>
<style scoped>
@keyframes pulse-slow {
0%, 100% { opacity: 0.1; transform: scale(1); }
50% { opacity: 0.15; transform: scale(1.15); }
}
.animate-pulse-slow {
animation: pulse-slow 10s linear infinite;
}
</style>