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 configMerger from "@nuxtjs/tailwindcss/merger";
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
interface CourseCardProps {
|
||||
/** Course Title */
|
||||
title: string
|
||||
title: string | { th: string; en: string }
|
||||
/** Difficulty Level (Beginner, Intermediate, etc.) */
|
||||
level?: string
|
||||
/** Visual type for level badge (color coding) */
|
||||
|
|
@ -16,7 +16,7 @@ interface CourseCardProps {
|
|||
/** Course Price (e.g., 'Free', '฿990') */
|
||||
price?: string
|
||||
/** Short description */
|
||||
description?: string
|
||||
description?: string | { th: string; en: string }
|
||||
/** Rating score (e.g., '4.8') */
|
||||
rating?: string
|
||||
/** Number of lessons */
|
||||
|
|
@ -37,13 +37,23 @@ interface CourseCardProps {
|
|||
image?: string
|
||||
}
|
||||
|
||||
defineProps<CourseCardProps>()
|
||||
const props = defineProps<CourseCardProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
viewDetails: []
|
||||
continue: []
|
||||
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>
|
||||
|
||||
<template>
|
||||
|
|
@ -52,7 +62,7 @@ const emit = defineEmits<{
|
|||
<div class="thumbnail-wrapper relative h-44 overflow-hidden rounded-t-[1.5rem]">
|
||||
<img
|
||||
: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"
|
||||
>
|
||||
<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 -->
|
||||
<div class="p-6 flex flex-col flex-1">
|
||||
<!-- 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 -->
|
||||
<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 -->
|
||||
<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
|
||||
export interface Course {
|
||||
id: number
|
||||
title: string
|
||||
title: string | { th: string; en: string }
|
||||
slug: string
|
||||
description: string
|
||||
description: string | { th: string; en: string }
|
||||
thumbnail_url: string
|
||||
price: string
|
||||
is_free: boolean
|
||||
|
|
@ -40,19 +40,17 @@ export const useCourse = () => {
|
|||
|
||||
const fetchCourses = async () => {
|
||||
try {
|
||||
const { data, error } = await useFetch<CourseResponse>(`${API_BASE_URL}/courses`, {
|
||||
const data = await $fetch<CourseResponse>(`${API_BASE_URL}/courses`, {
|
||||
method: 'GET',
|
||||
headers: token.value ? {
|
||||
Authorization: `Bearer ${token.value}`
|
||||
} : {}
|
||||
})
|
||||
|
||||
if (error.value) throw error.value
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: data.value?.data || [],
|
||||
total: data.value?.total || 0
|
||||
data: data.data || [],
|
||||
total: data.total || 0
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Fetch courses failed:', err)
|
||||
|
|
@ -65,17 +63,15 @@ export const useCourse = () => {
|
|||
|
||||
const fetchCourseById = async (id: number) => {
|
||||
try {
|
||||
const { data, error } = await useFetch<CourseResponse>(`${API_BASE_URL}/courses/${id}`, {
|
||||
const data = await $fetch<CourseResponse>(`${API_BASE_URL}/courses/${id}`, {
|
||||
method: 'GET',
|
||||
headers: token.value ? {
|
||||
Authorization: `Bearer ${token.value}`
|
||||
} : {}
|
||||
})
|
||||
|
||||
if (error.value) throw error.value
|
||||
|
||||
// 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')
|
||||
|
||||
|
|
@ -97,3 +93,4 @@ export const useCourse = () => {
|
|||
fetchCourseById
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -83,14 +83,23 @@ const visibleCategories = computed(() => {
|
|||
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
|
||||
const filteredCourses = computed(() => {
|
||||
if (!searchQuery.value) return courses.value;
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
return courses.value.filter(
|
||||
(c) =>
|
||||
c.title.toLowerCase().includes(query) ||
|
||||
c.description?.toLowerCase().includes(query)
|
||||
(c) => {
|
||||
const title = getLocalizedText(c.title).toLowerCase();
|
||||
const desc = getLocalizedText(c.description).toLowerCase();
|
||||
return title.includes(query) || (desc && desc.includes(query));
|
||||
}
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
|
@ -318,13 +327,13 @@ const filteredCourses = computed(() => {
|
|||
</div>
|
||||
|
||||
<h1 class="text-[32px] font-bold mb-4 text-slate-900 dark:text-white">
|
||||
{{ selectedCourse.title }}
|
||||
{{ getLocalizedText(selectedCourse.title) }}
|
||||
</h1>
|
||||
<p
|
||||
class="text-slate-700 dark:text-slate-400 mb-6"
|
||||
style="font-size: 1.1em; line-height: 1.7"
|
||||
>
|
||||
{{ selectedCourse.description }}
|
||||
{{ getLocalizedText(selectedCourse.description) }}
|
||||
</p>
|
||||
|
||||
<!-- Learning Objectives -->
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue