feat: Implement course discovery page with API integration, useCourse composable, and CourseCard component.

This commit is contained in:
supalerk-ar66 2026-01-16 10:26:33 +07:00
parent 2ffcc36fe4
commit 1d8acaf7d7
4 changed files with 39 additions and 23 deletions

View file

@ -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";

View file

@ -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">

View file

@ -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
}
}

View file

@ -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 -->