feat: Add course detail page, authentication composable, and course browsing page.
This commit is contained in:
parent
1e06041769
commit
327f6ec7b5
3 changed files with 230 additions and 115 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
ดูรายละเอียด
|
||||
|
|
|
|||
145
Frontend-Learner/pages/course/[id].vue
Normal file
145
Frontend-Learner/pages/course/[id].vue
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue