267 lines
8.3 KiB
Vue
267 lines
8.3 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 Management
|
|
const showDetail = ref(false);
|
|
const searchQuery = ref("");
|
|
const selectedCategoryIds = ref<number[]>([]);
|
|
const categories = ref<any[]>([]);
|
|
const courses = ref<any[]>([]);
|
|
const selectedCourse = ref<any>(null);
|
|
|
|
const isLoading = ref(false);
|
|
const isLoadingDetail = ref(false);
|
|
const isEnrolling = ref(false);
|
|
|
|
|
|
const { t } = useI18n();
|
|
const { currentUser } = useAuth();
|
|
const { fetchCategories } = useCategory();
|
|
const { fetchCourses, fetchCourseById, enrollCourse } = useCourse();
|
|
|
|
|
|
// 2. Computed Properties
|
|
const sortOption = ref(computed(() => t('discovery.sortRecent')));
|
|
const sortOptions = computed(() => [t('discovery.sortRecent')]);
|
|
|
|
const filteredCourses = computed(() => {
|
|
let result = courses.value;
|
|
if (selectedCategoryIds.value.length > 0) {
|
|
result = result.filter(c => selectedCategoryIds.value.includes(c.category_id));
|
|
}
|
|
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;
|
|
});
|
|
|
|
// 3. Helper Functions
|
|
const getLocalizedText = (text: string | { th: string; en: string } | undefined) => {
|
|
if (!text) return '';
|
|
if (typeof text === 'string') return text;
|
|
return text.th || text.en || '';
|
|
};
|
|
|
|
// 4. API Actions
|
|
const loadCategories = async () => {
|
|
const res = await fetchCategories();
|
|
if (res.success) categories.value = res.data || [];
|
|
};
|
|
|
|
const loadCourses = async () => {
|
|
isLoading.value = true;
|
|
const res = await fetchCourses();
|
|
if (res.success) {
|
|
courses.value = res.data || [];
|
|
}
|
|
isLoading.value = false;
|
|
};
|
|
|
|
const selectCourse = async (id: number) => {
|
|
isLoadingDetail.value = true;
|
|
selectedCourse.value = null;
|
|
showDetail.value = true;
|
|
const res = await fetchCourseById(id);
|
|
if (res.success) selectedCourse.value = res.data;
|
|
isLoadingDetail.value = false;
|
|
};
|
|
|
|
const handleEnroll = async (id: number) => {
|
|
if (isEnrolling.value) return;
|
|
isEnrolling.value = true;
|
|
const res = await enrollCourse(id);
|
|
if (res.success) {
|
|
return navigateTo('/dashboard/my-courses?enrolled=true');
|
|
} else {
|
|
alert(res.error || 'Failed to enroll');
|
|
}
|
|
isEnrolling.value = false;
|
|
};
|
|
|
|
onMounted(() => {
|
|
loadCategories();
|
|
loadCourses();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="page-container">
|
|
|
|
|
|
<!-- 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-6 mb-10">
|
|
<!-- Title -->
|
|
<h1 class="text-3xl font-black text-slate-900 dark:text-white">
|
|
{{ $t('discovery.title') }}
|
|
</h1>
|
|
|
|
<!-- Right Side: Search & Sort -->
|
|
<div class="flex items-center gap-3">
|
|
<!-- Search Input -->
|
|
<q-input
|
|
v-model="searchQuery"
|
|
dense
|
|
outlined
|
|
rounded
|
|
:placeholder="$t('discovery.searchPlaceholder')"
|
|
class="disc-search w-full md:w-64"
|
|
>
|
|
<template v-slot:append>
|
|
<q-icon name="search" class="text-slate-400" />
|
|
</template>
|
|
</q-input>
|
|
|
|
|
|
</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 lg:sticky lg:top-24">
|
|
<CategorySidebar
|
|
:categories="categories"
|
|
v-model="selectedCategoryIds"
|
|
/>
|
|
</div>
|
|
|
|
<!-- RIGHT CONTENT: Course Grid -->
|
|
<div class="flex-1 w-full">
|
|
<div v-if="filteredCourses.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg: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"
|
|
>
|
|
|
|
<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>
|
|
<button
|
|
@click="showDetail = false"
|
|
class="inline-flex items-center gap-2 text-slate-600 dark:text-white hover:text-blue-600 dark:hover:text-blue-300 mb-6 transition-all font-black text-lg md:text-xl group"
|
|
>
|
|
<q-icon name="arrow_back" size="28px" class="transition-transform group-hover:-translate-x-2" />
|
|
{{ $t('discovery.backToCatalog') }}
|
|
</button>
|
|
|
|
<div v-if="isLoadingDetail" class="flex justify-center py-20">
|
|
<q-spinner size="3rem" color="primary" />
|
|
</div>
|
|
|
|
<CourseDetailView
|
|
v-else-if="selectedCourse"
|
|
:course="selectedCourse"
|
|
:user="currentUser"
|
|
@back="showDetail = false"
|
|
@enroll="handleEnroll"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* =========================
|
|
Discovery: Quasar Field Fix
|
|
- จำกัดผลกระทบเฉพาะช่องค้นหา/เรียงตาม
|
|
- ตัด focus ring (กรอบขาว 4 เหลี่ยม)
|
|
========================= */
|
|
|
|
/* ให้หน้าตา input เป็น pill ขาวเหมือนรูป (ทั้ง Light/Dark) */
|
|
.disc-search :deep(.q-field__control),
|
|
.disc-sort :deep(.q-field__control) {
|
|
background: #ffffff !important;
|
|
border-radius: 9999px !important;
|
|
}
|
|
|
|
/* สีตัวอักษร/placeholder/icon ให้คมชัด */
|
|
.disc-search :deep(.q-field__native),
|
|
.disc-search :deep(.q-field__input),
|
|
.disc-sort :deep(.q-field__native),
|
|
.disc-sort :deep(.q-field__input) {
|
|
color: #0f172a !important;
|
|
-webkit-text-fill-color: #0f172a !important;
|
|
}
|
|
|
|
.disc-search :deep(.q-placeholder),
|
|
.disc-sort :deep(.q-placeholder) {
|
|
color: #64748b !important; /* slate-500 */
|
|
}
|
|
|
|
.disc-search :deep(.q-icon),
|
|
.disc-sort :deep(.q-icon),
|
|
.disc-sort :deep(.q-select__dropdown-icon) {
|
|
color: #64748b !important;
|
|
}
|
|
|
|
/* ✅ ตัด “Focus outline / Focus ring” ที่เป็นกรอบสว่าง */
|
|
.disc-search :deep(.q-field__control:after),
|
|
.disc-sort :deep(.q-field__control:after) {
|
|
opacity: 0 !important; /* ตัวนี้มักเป็นเส้น/แสงตอน focus */
|
|
}
|
|
|
|
/* คุมเส้นขอบปกติให้บาง ๆ ไม่เด้งเป็นกรอบขาว */
|
|
.disc-search :deep(.q-field__control:before),
|
|
.disc-sort :deep(.q-field__control:before) {
|
|
border-color: rgba(203, 213, 225, 0.9) !important; /* slate-300 */
|
|
}
|
|
|
|
/* กัน browser outline ซ้อน */
|
|
.disc-search :deep(input:focus),
|
|
.disc-sort :deep(input:focus) {
|
|
outline: none !important;
|
|
box-shadow: none !important;
|
|
}
|
|
</style>
|
|
|