feat: Implement course discovery page with API integration, useCourse composable, and CourseCard component.
This commit is contained in:
parent
2ffcc36fe4
commit
1d8acaf7d7
4 changed files with 39 additions and 23 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
// generated by the @nuxtjs/tailwindcss <https://github.com/nuxt-modules/tailwindcss> module at 16/1/2569 09:58:37
|
// generated by the @nuxtjs/tailwindcss <https://github.com/nuxt-modules/tailwindcss> module at 16/1/2569 10:18:22
|
||||||
import "@nuxtjs/tailwindcss/config-ctx"
|
import "@nuxtjs/tailwindcss/config-ctx"
|
||||||
import configMerger from "@nuxtjs/tailwindcss/merger";
|
import configMerger from "@nuxtjs/tailwindcss/merger";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
interface CourseCardProps {
|
interface CourseCardProps {
|
||||||
/** Course Title */
|
/** Course Title */
|
||||||
title: string
|
title: string | { th: string; en: string }
|
||||||
/** Difficulty Level (Beginner, Intermediate, etc.) */
|
/** Difficulty Level (Beginner, Intermediate, etc.) */
|
||||||
level?: string
|
level?: string
|
||||||
/** Visual type for level badge (color coding) */
|
/** Visual type for level badge (color coding) */
|
||||||
|
|
@ -16,7 +16,7 @@ interface CourseCardProps {
|
||||||
/** Course Price (e.g., 'Free', '฿990') */
|
/** Course Price (e.g., 'Free', '฿990') */
|
||||||
price?: string
|
price?: string
|
||||||
/** Short description */
|
/** Short description */
|
||||||
description?: string
|
description?: string | { th: string; en: string }
|
||||||
/** Rating score (e.g., '4.8') */
|
/** Rating score (e.g., '4.8') */
|
||||||
rating?: string
|
rating?: string
|
||||||
/** Number of lessons */
|
/** Number of lessons */
|
||||||
|
|
@ -37,13 +37,23 @@ interface CourseCardProps {
|
||||||
image?: string
|
image?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<CourseCardProps>()
|
const props = defineProps<CourseCardProps>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
viewDetails: []
|
viewDetails: []
|
||||||
continue: []
|
continue: []
|
||||||
viewCertificate: []
|
viewCertificate: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// Helper to get 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 || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayTitle = computed(() => getLocalizedText(props.title))
|
||||||
|
const displayDescription = computed(() => getLocalizedText(props.description))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -52,7 +62,7 @@ const emit = defineEmits<{
|
||||||
<div class="thumbnail-wrapper relative h-44 overflow-hidden rounded-t-[1.5rem]">
|
<div class="thumbnail-wrapper relative h-44 overflow-hidden rounded-t-[1.5rem]">
|
||||||
<img
|
<img
|
||||||
:src="image || 'https://images.unsplash.com/photo-1498050108023-c5249f4df085?w=400&q=80'"
|
:src="image || 'https://images.unsplash.com/photo-1498050108023-c5249f4df085?w=400&q=80'"
|
||||||
:alt="title"
|
:alt="displayTitle"
|
||||||
class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700"
|
class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700"
|
||||||
>
|
>
|
||||||
<div v-if="completed" class="absolute inset-0 bg-emerald-600/60 backdrop-blur-[2px] flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
<div v-if="completed" class="absolute inset-0 bg-emerald-600/60 backdrop-blur-[2px] flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||||
|
|
@ -80,10 +90,10 @@ const emit = defineEmits<{
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="p-6 flex flex-col flex-1">
|
<div class="p-6 flex flex-col flex-1">
|
||||||
<!-- Title -->
|
<!-- Title -->
|
||||||
<h3 class="font-black text-lg mb-2 line-clamp-1 text-slate-900 dark:text-white group-hover:text-blue-400 transition-colors">{{ title }}</h3>
|
<h3 class="font-black text-lg mb-2 line-clamp-1 text-slate-900 dark:text-white group-hover:text-blue-400 transition-colors">{{ displayTitle }}</h3>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<div v-if="description" class="text-sm mb-6 line-clamp-2 leading-relaxed text-slate-600 dark:text-slate-400">{{ description }}</div>
|
<div v-if="displayDescription" class="text-sm mb-6 line-clamp-2 leading-relaxed text-slate-600 dark:text-slate-400">{{ displayDescription }}</div>
|
||||||
|
|
||||||
<!-- Rating & Lessons -->
|
<!-- Rating & Lessons -->
|
||||||
<div v-if="rating || lessons" class="flex items-center gap-4 text-[11px] font-bold mb-6 uppercase tracking-wider text-slate-500 dark:text-slate-400">
|
<div v-if="rating || lessons" class="flex items-center gap-4 text-[11px] font-bold mb-6 uppercase tracking-wider text-slate-500 dark:text-slate-400">
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@ import type { H3Event } from 'h3'
|
||||||
// Types based on API responses
|
// Types based on API responses
|
||||||
export interface Course {
|
export interface Course {
|
||||||
id: number
|
id: number
|
||||||
title: string
|
title: string | { th: string; en: string }
|
||||||
slug: string
|
slug: string
|
||||||
description: string
|
description: string | { th: string; en: string }
|
||||||
thumbnail_url: string
|
thumbnail_url: string
|
||||||
price: string
|
price: string
|
||||||
is_free: boolean
|
is_free: boolean
|
||||||
|
|
@ -40,19 +40,17 @@ export const useCourse = () => {
|
||||||
|
|
||||||
const fetchCourses = async () => {
|
const fetchCourses = async () => {
|
||||||
try {
|
try {
|
||||||
const { data, error } = await useFetch<CourseResponse>(`${API_BASE_URL}/courses`, {
|
const data = await $fetch<CourseResponse>(`${API_BASE_URL}/courses`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: token.value ? {
|
headers: token.value ? {
|
||||||
Authorization: `Bearer ${token.value}`
|
Authorization: `Bearer ${token.value}`
|
||||||
} : {}
|
} : {}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (error.value) throw error.value
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: data.value?.data || [],
|
data: data.data || [],
|
||||||
total: data.value?.total || 0
|
total: data.total || 0
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Fetch courses failed:', err)
|
console.error('Fetch courses failed:', err)
|
||||||
|
|
@ -65,17 +63,15 @@ export const useCourse = () => {
|
||||||
|
|
||||||
const fetchCourseById = async (id: number) => {
|
const fetchCourseById = async (id: number) => {
|
||||||
try {
|
try {
|
||||||
const { data, error } = await useFetch<CourseResponse>(`${API_BASE_URL}/courses/${id}`, {
|
const data = await $fetch<CourseResponse>(`${API_BASE_URL}/courses/${id}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: token.value ? {
|
headers: token.value ? {
|
||||||
Authorization: `Bearer ${token.value}`
|
Authorization: `Bearer ${token.value}`
|
||||||
} : {}
|
} : {}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (error.value) throw error.value
|
|
||||||
|
|
||||||
// API returns data array even for single item based on schema
|
// API returns data array even for single item based on schema
|
||||||
const courseData = data.value?.data?.[0]
|
const courseData = data.data?.[0]
|
||||||
|
|
||||||
if (!courseData) throw new Error('Course not found')
|
if (!courseData) throw new Error('Course not found')
|
||||||
|
|
||||||
|
|
@ -97,3 +93,4 @@ export const useCourse = () => {
|
||||||
fetchCourseById
|
fetchCourseById
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -83,14 +83,23 @@ const visibleCategories = computed(() => {
|
||||||
return showAllCategories.value ? categories : categories.slice(0, 8);
|
return showAllCategories.value ? categories : categories.slice(0, 8);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper to get 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 || ''
|
||||||
|
}
|
||||||
|
|
||||||
// Filter Logic based on search query
|
// Filter Logic based on search query
|
||||||
const filteredCourses = computed(() => {
|
const filteredCourses = computed(() => {
|
||||||
if (!searchQuery.value) return courses.value;
|
if (!searchQuery.value) return courses.value;
|
||||||
const query = searchQuery.value.toLowerCase();
|
const query = searchQuery.value.toLowerCase();
|
||||||
return courses.value.filter(
|
return courses.value.filter(
|
||||||
(c) =>
|
(c) => {
|
||||||
c.title.toLowerCase().includes(query) ||
|
const title = getLocalizedText(c.title).toLowerCase();
|
||||||
c.description?.toLowerCase().includes(query)
|
const desc = getLocalizedText(c.description).toLowerCase();
|
||||||
|
return title.includes(query) || (desc && desc.includes(query));
|
||||||
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -318,13 +327,13 @@ const filteredCourses = computed(() => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="text-[32px] font-bold mb-4 text-slate-900 dark:text-white">
|
<h1 class="text-[32px] font-bold mb-4 text-slate-900 dark:text-white">
|
||||||
{{ selectedCourse.title }}
|
{{ getLocalizedText(selectedCourse.title) }}
|
||||||
</h1>
|
</h1>
|
||||||
<p
|
<p
|
||||||
class="text-slate-700 dark:text-slate-400 mb-6"
|
class="text-slate-700 dark:text-slate-400 mb-6"
|
||||||
style="font-size: 1.1em; line-height: 1.7"
|
style="font-size: 1.1em; line-height: 1.7"
|
||||||
>
|
>
|
||||||
{{ selectedCourse.description }}
|
{{ getLocalizedText(selectedCourse.description) }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Learning Objectives -->
|
<!-- Learning Objectives -->
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue