440 lines
20 KiB
Vue
440 lines
20 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* @file discovery.vue
|
|
* @description Course Discovery / Catalog Page.
|
|
* Allows users to browse, filter, and view details of available courses.
|
|
* Includes a toggleable detailed view for course previews.
|
|
*/
|
|
|
|
definePageMeta({
|
|
layout: "default",
|
|
middleware: "auth",
|
|
});
|
|
|
|
useHead({
|
|
title: "รายการคอร์ส - e-Learning",
|
|
});
|
|
|
|
// ==========================================
|
|
// 1. ตัวแปร State (สถานะของ UI)
|
|
// ==========================================
|
|
// showDetail: ควบคุมการแสดงผลหน้ารายละเอียดคอร์ส (true = แสดง, false = แสดงรายการ)
|
|
const showDetail = ref(false);
|
|
// searchQuery: เก็บคำค้นหาที่ผู้ใช้พิมพ์
|
|
const searchQuery = ref("");
|
|
const sortOption = ref('เรียงตาม: ล่าสุด')
|
|
const sortOptions = ['เรียงตาม: ล่าสุด', 'ราคา: ต่ำไปสูง', 'ราคา: สูงไปต่ำ']
|
|
// isCategoryOpen: ควบคุมการเปิด/ปิดเมนูหมวดหมู่ด้านข้าง
|
|
const isCategoryOpen = ref(true);
|
|
|
|
// ==========================================
|
|
// 2. ฟังก์ชันช่วยเหลือ (Helpers)
|
|
// ==========================================
|
|
// getLocalizedText: เลือกแสดงภาษาไทยหรืออังกฤษตามข้อมูลที่มี
|
|
// ถ้าเป็น object {th, en} จะพยายามหา th ก่อน, ถ้าไม่มีให้ใช้ en
|
|
const getLocalizedText = (text: string | { th: string; en: string } | undefined) => {
|
|
if (!text) return ''
|
|
if (typeof text === 'string') return text
|
|
return text.th || text.en || ''
|
|
}
|
|
|
|
// ==========================================
|
|
// 3. จัดการข้อมูลหมวดหมู่ (Categories)
|
|
// ==========================================
|
|
// ใช้ useCategory composable เพื่อดึงข้อมูลหมวดหมู่จาก API
|
|
const { fetchCategories } = useCategory();
|
|
const categories = ref<any[]>([]);
|
|
const showAllCategories = ref(false); // ควบคุมการแสดงหมวดหมู่ทั้งหมด (Show More/Less)
|
|
|
|
// ฟังก์ชันโหลดข้อมูลหมวดหมู่
|
|
const loadCategories = async () => {
|
|
const res = await fetchCategories();
|
|
if (res.success) {
|
|
categories.value = res.data || [];
|
|
}
|
|
};
|
|
|
|
// คำนวณหมวดหมู่ที่จะแสดงผล (ถ้ากด Show More จะแสดงทั้งหมด, ปกติแสดงแค่ 8 รายการ)
|
|
const visibleCategories = computed(() => {
|
|
return showAllCategories.value ? categories.value : categories.value.slice(0, 8);
|
|
});
|
|
|
|
// เตรียม Options สำหรับ QSelect Dropdown
|
|
const categoryOptions = computed(() => {
|
|
return categories.value.map(c => ({
|
|
id: c.id,
|
|
label: getLocalizedText(c.name),
|
|
value: c.id // q-select uses value by default if not constrained
|
|
}))
|
|
});
|
|
|
|
// ฟังก์ชัน Toggle Category สำหรับ Chip Remove
|
|
const toggleCategory = (id: number) => {
|
|
if (selectedCategoryIds.value.includes(id)) {
|
|
selectedCategoryIds.value = selectedCategoryIds.value.filter(cid => cid !== id);
|
|
} else {
|
|
selectedCategoryIds.value.push(id);
|
|
}
|
|
};
|
|
|
|
// ==========================================
|
|
// 4. จัดการข้อมูลคอร์ส (Courses)
|
|
// ==========================================
|
|
// ใช้ useCourse composable สำหรับจัดการคอร์สเรียน (ดึงข้อมูล, ลงทะเบียน)
|
|
const { fetchCourses, fetchCourseById, enrollCourse } = useCourse();
|
|
const courses = ref<any[]>([]);
|
|
const isLoading = ref(false); // สถานะกำลังโหลดรายการคอร์ส
|
|
const selectedCourse = ref<any>(null); // คอร์สที่ถูกเลือกดูรายละเอียด
|
|
const isLoadingDetail = ref(false); // สถานะกำลังโหลดรายละเอียดคอร์ส
|
|
const isEnrolling = ref(false); // สถานะกำลังกดลงทะเบียน
|
|
|
|
// ฟังก์ชันโหลดข้อมูลคอร์สทั้งหมด
|
|
const loadCourses = async () => {
|
|
isLoading.value = true;
|
|
const res = await fetchCourses();
|
|
if (res.success) {
|
|
// แปลงข้อมูลเบื้องต้นสำหรับแสดงผลในการ์ด
|
|
courses.value = (res.data || []).map((c: any) => ({
|
|
...c,
|
|
rating: "0.0",
|
|
lessons: "0",
|
|
levelType: c.levelType || "neutral"
|
|
}));
|
|
}
|
|
isLoading.value = false;
|
|
};
|
|
|
|
// ฟังก์ชันเลือกคอร์สเพื่อดูรายละเอียด
|
|
const selectCourse = async (id: number) => {
|
|
isLoadingDetail.value = true;
|
|
selectedCourse.value = null;
|
|
showDetail.value = true; // เปลี่ยนหน้าเป็นแบบ Detail View
|
|
|
|
// ดึงข้อมูลรายละเอียดคอร์สจาก API โดยใช้ ID
|
|
const res = await fetchCourseById(id);
|
|
if (res.success) {
|
|
selectedCourse.value = res.data;
|
|
}
|
|
isLoadingDetail.value = false;
|
|
};
|
|
|
|
// ฟังก์ชันลงทะเบียนเรียน (Enroll)
|
|
const handleEnroll = async (id: number) => {
|
|
if (isEnrolling.value) return; // ป้องกันการกดรัว
|
|
isEnrolling.value = true;
|
|
|
|
const res = await enrollCourse(id);
|
|
|
|
if (res.success) {
|
|
// ถ้าสำเร็จ ให้เปลี่ยนหน้าไปที่ "คอร์สของฉัน" พร้อมส่ง parameter enrolled=true เพื่อแสดง popup
|
|
return navigateTo('/dashboard/my-courses?enrolled=true');
|
|
} else {
|
|
alert(res.error || 'Failed to enroll');
|
|
}
|
|
|
|
isEnrolling.value = false;
|
|
};
|
|
|
|
onMounted(() => {
|
|
// โหลดข้อมูลเมื่อหน้าเว็บเริ่มทำงาน
|
|
loadCategories();
|
|
loadCourses();
|
|
});
|
|
|
|
// Filter Logic based on search query
|
|
// ==========================================
|
|
// 5. ระบบกรองและค้นหา (Filter & Search)
|
|
// ==========================================
|
|
// selectedCategoryIds: เก็บ ID ของหมวดหมู่ที่ถูกติ๊กเลือก
|
|
const selectedCategoryIds = ref<number[]>([]);
|
|
|
|
// คำนวณคอร์สที่จะแสดงผลจริง (Filter Logic)
|
|
const filteredCourses = computed(() => {
|
|
let result = courses.value;
|
|
|
|
// 1. กรองตามหมวดหมู่ (Category Filter)
|
|
if (selectedCategoryIds.value.length > 0) {
|
|
result = result.filter(c => selectedCategoryIds.value.includes(c.category_id));
|
|
}
|
|
|
|
// 2. กรองตามคำค้นหา (Search Query) - ค้นหาจากชื่อหรือคำอธิบาย
|
|
if (searchQuery.value) {
|
|
const query = searchQuery.value.toLowerCase();
|
|
result = result.filter(
|
|
(c) => {
|
|
const title = getLocalizedText(c.title).toLowerCase();
|
|
const desc = getLocalizedText(c.description).toLowerCase();
|
|
return title.includes(query) || (desc && desc.includes(query));
|
|
}
|
|
);
|
|
}
|
|
|
|
return result;
|
|
});
|
|
|
|
// ==========================================
|
|
// 6. User & Helpers
|
|
// ==========================================
|
|
const { currentUser } = useAuth();
|
|
</script>
|
|
|
|
<template>
|
|
<div class="p-6 max-w-[1400px] mx-auto">
|
|
|
|
|
|
<!-- CATALOG VIEW: Browse courses -->
|
|
<div v-if="!showDetail">
|
|
|
|
<!-- Top Header Area -->
|
|
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
|
|
<!-- Title -->
|
|
<h2 class="text-2xl font-bold text-slate-900 dark:text-white">
|
|
รายการคอร์สทั้งหมด
|
|
</h2>
|
|
|
|
<!-- Right Side: Search & Sort -->
|
|
<div class="flex items-center gap-3">
|
|
<!-- Search Input -->
|
|
<q-input
|
|
v-model="searchQuery"
|
|
dense
|
|
outlined
|
|
rounded
|
|
placeholder="ค้นหาคอร์ส..."
|
|
class="bg-white dark:bg-slate-800 w-full md:w-64"
|
|
bg-color="white"
|
|
dark:bg-color="slate-800"
|
|
>
|
|
<template v-slot:append>
|
|
<q-icon name="search" class="text-slate-400" />
|
|
</template>
|
|
</q-input>
|
|
|
|
<!-- Sort Dropdown -->
|
|
<q-select
|
|
v-model="sortOption"
|
|
:options="sortOptions"
|
|
dense
|
|
outlined
|
|
rounded
|
|
class="bg-white dark:bg-slate-800 w-40"
|
|
bg-color="white"
|
|
behavior="menu"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main Layout: Sidebar + Grid -->
|
|
<div class="flex flex-col lg:flex-row gap-8 items-start">
|
|
|
|
<!-- LEFT SIDEBAR: Category Filter -->
|
|
<div class="w-full lg:w-64 flex-shrink-0 sticky top-24">
|
|
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden">
|
|
<q-expansion-item
|
|
expand-separator
|
|
:label="`หมวดหมู่ (${selectedCategoryIds.length})`"
|
|
class="font-bold text-slate-800 dark:text-white"
|
|
header-class="bg-white dark:bg-slate-800"
|
|
default-opened
|
|
>
|
|
<q-list class="bg-white dark:bg-slate-800 border-t border-slate-100 dark:border-slate-700">
|
|
<q-item
|
|
v-for="cat in (showAllCategories ? categories : categories.slice(0, 4))"
|
|
:key="cat.id"
|
|
tag="label"
|
|
clickable
|
|
v-ripple
|
|
dense
|
|
>
|
|
<q-item-section avatar>
|
|
<q-checkbox v-model="selectedCategoryIds" :val="cat.id" color="primary" dense />
|
|
</q-item-section>
|
|
<q-item-section>
|
|
<q-item-label class="text-sm font-medium text-slate-700 dark:text-slate-300">{{ getLocalizedText(cat.name) }}</q-item-label>
|
|
</q-item-section>
|
|
</q-item>
|
|
|
|
<!-- Show More/Less Button -->
|
|
<q-item
|
|
v-if="categories.length > 4"
|
|
clickable
|
|
v-ripple
|
|
@click="showAllCategories = !showAllCategories"
|
|
class="text-blue-600 dark:text-blue-400 font-bold text-sm"
|
|
>
|
|
<q-item-section>
|
|
<div class="flex items-center gap-1">
|
|
{{ showAllCategories ? 'แสดงน้อยลง' : 'แสดงเพิ่มเติม' }}
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" :class="showAllCategories ? 'rotate-180' : ''">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</div>
|
|
</q-item-section>
|
|
</q-item>
|
|
</q-list>
|
|
</q-expansion-item>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- RIGHT CONTENT: Course Grid -->
|
|
<div class="flex-1 w-full">
|
|
<div v-if="filteredCourses.length > 0" class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-6">
|
|
<CourseCard
|
|
v-for="course in filteredCourses"
|
|
:key="course.id"
|
|
:title="course.title"
|
|
:price="course.price"
|
|
:description="course.description"
|
|
:rating="course.rating"
|
|
:lessons="course.lessons"
|
|
:image="course.thumbnail_url"
|
|
show-view-details
|
|
@view-details="selectCourse(course.id)"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Empty State -->
|
|
<div
|
|
v-else
|
|
class="flex flex-col items-center justify-center py-20 bg-slate-50 dark:bg-slate-800/50 rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-700"
|
|
>
|
|
<div class="text-6xl mb-4">🔍</div>
|
|
<h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">{{ $t('discovery.emptyTitle') }}</h3>
|
|
<p class="text-slate-500 dark:text-slate-400 text-center max-w-md">
|
|
{{ $t('discovery.emptyDesc') }}
|
|
</p>
|
|
<button class="mt-6 font-bold text-blue-600 hover:underline" @click="searchQuery = ''; selectedCategoryIds = []">
|
|
{{ $t('discovery.showAll') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- COURSE DETAIL VIEW: Detailed information about a specific course -->
|
|
<div v-else>
|
|
<q-btn
|
|
flat
|
|
icon="arrow_back"
|
|
:label="$t('discovery.backToCatalog')"
|
|
color="grey-7"
|
|
class="mb-6 font-medium"
|
|
@click="showDetail = false"
|
|
/>
|
|
|
|
<div v-if="isLoadingDetail" class="flex justify-center py-20">
|
|
<q-spinner size="3rem" color="primary" />
|
|
</div>
|
|
|
|
<div v-else-if="selectedCourse" class="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
|
<!-- Main Content (Left Column) -->
|
|
<div class="lg:col-span-8">
|
|
<!-- Hero Video Placeholder -->
|
|
<div
|
|
class="w-full aspect-video bg-slate-900 rounded-3xl overflow-hidden relative shadow-lg mb-8 group"
|
|
>
|
|
<img
|
|
v-if="selectedCourse.thumbnail_url"
|
|
:src="selectedCourse.thumbnail_url"
|
|
class="absolute inset-0 w-full h-full object-cover opacity-60 group-hover:opacity-40 transition-opacity"
|
|
/>
|
|
<!-- Play Button -->
|
|
<div class="absolute inset-0 flex items-center justify-center">
|
|
<div class="w-20 h-20 bg-white/20 backdrop-blur-md rounded-full flex items-center justify-center group-hover:scale-110 transition-transform cursor-pointer">
|
|
<div class="w-16 h-16 bg-white rounded-full flex items-center justify-center shadow-xl">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-blue-600 ml-1" viewBox="0 0 20 20" fill="currentColor">
|
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<h1 class="text-3xl md:text-4xl font-bold mb-4 text-slate-900 dark:text-white tracking-tight">
|
|
{{ getLocalizedText(selectedCourse.title) }}
|
|
</h1>
|
|
<p class="text-slate-600 dark:text-slate-400 text-lg leading-relaxed mb-8">
|
|
{{ getLocalizedText(selectedCourse.description) }}
|
|
</p>
|
|
|
|
<!-- Course Syllabus -->
|
|
<div class="bg-white dark:bg-slate-800 rounded-3xl p-8 shadow-sm border border-slate-100 dark:border-slate-700/50">
|
|
<h3 class="text-xl font-bold mb-6 text-slate-900 dark:text-white flex items-center gap-2">
|
|
<span class="w-1 h-6 bg-blue-500 rounded-full"></span>
|
|
{{ $t('course.courseContent') }}
|
|
</h3>
|
|
<div class="space-y-4">
|
|
<div v-for="(chapter, index) in selectedCourse.chapters" :key="chapter.id" class="border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden">
|
|
<div class="bg-slate-50 dark:bg-slate-800/80 p-4 flex justify-between items-center cursor-pointer">
|
|
<div class="font-bold text-slate-800 dark:text-slate-200">
|
|
<span class="opacity-50 mr-2">Chapter {{ Number(index) + 1 }}</span>
|
|
{{ getLocalizedText(chapter.title) }}
|
|
</div>
|
|
<span class="text-xs font-bold px-3 py-1 bg-white dark:bg-slate-700 rounded-full border border-slate-200 dark:border-slate-600">
|
|
{{ chapter.lessons ? chapter.lessons.length : 0 }} Lessons
|
|
</span>
|
|
</div>
|
|
<!-- Lessons -->
|
|
<div class="divide-y divide-slate-100 dark:divide-slate-700/50 bg-white dark:bg-slate-800">
|
|
<div v-for="(lesson, lIndex) in chapter.lessons" :key="lesson.id" class="p-4 flex justify-between items-center hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors">
|
|
<div class="flex items-center gap-3">
|
|
<div class="w-6 h-6 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center text-[10px] font-bold text-slate-500">
|
|
{{ Number(lIndex) + 1 }}
|
|
</div>
|
|
<span class="text-slate-700 dark:text-slate-300 font-medium text-sm">{{ getLocalizedText(lesson.title) }}</span>
|
|
</div>
|
|
<span class="text-xs text-slate-400">{{ lesson.duration_minutes || 0 }}:00</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sidebar (Right Column) -->
|
|
<div class="lg:col-span-4">
|
|
<div class="bg-white dark:bg-slate-800 rounded-3xl p-6 shadow-lg border border-slate-100 dark:border-slate-700 sticky top-24">
|
|
<div class="mb-8 text-center bg-slate-50 dark:bg-slate-900/50 rounded-2xl p-6 border border-slate-100 dark:border-slate-700">
|
|
<div class="text-sm text-slate-500 mb-1 font-medium">{{ selectedCourse.price ? 'ราคาคอร์ส' : 'ราคา' }}</div>
|
|
<h2 class="text-4xl font-black text-blue-600 dark:text-blue-400">
|
|
{{ selectedCourse.price || 'ฟรี' }}
|
|
</h2>
|
|
</div>
|
|
|
|
<q-btn
|
|
@click="handleEnroll(selectedCourse.id)"
|
|
unetab
|
|
unelevated
|
|
rounded
|
|
class="w-full py-3 text-lg font-bold bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-lg"
|
|
:loading="isEnrolling"
|
|
:label="$t('course.enrollNow')"
|
|
>
|
|
<template v-slot:loading>
|
|
<q-spinner-facebook />
|
|
</template>
|
|
</q-btn>
|
|
|
|
<div class="text-sm space-y-3 pt-4 border-t border-slate-100 dark:border-slate-700">
|
|
<div class="flex justify-between text-slate-600 dark:text-slate-400">
|
|
<span>{{ $t('course.certificate') }}</span>
|
|
<span class="font-bold text-slate-900 dark:text-white">{{ selectedCourse.have_certificate ? 'มีใบประกาศฯ' : 'ไม่มี' }}</span>
|
|
</div>
|
|
<div class="flex justify-between text-slate-600 dark:text-slate-400">
|
|
<span>ผู้สอน</span>
|
|
<span class="font-bold text-slate-900 dark:text-white">Professional Instructor</span>
|
|
</div>
|
|
<div class="flex justify-between text-slate-600 dark:text-slate-400">
|
|
<span>ระดับ</span>
|
|
<span class="font-bold text-slate-900 dark:text-white">ทุกระดับ</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|