feat: Implement E2E tests for authentication, student account, discovery, and classroom features, alongside new browse pages and a useAuth composable.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 48s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 4s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 1s
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 48s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 4s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 1s
This commit is contained in:
parent
0205aab461
commit
b0b665f588
35 changed files with 546 additions and 862 deletions
|
|
@ -27,7 +27,7 @@ const sortBy = ref('ยอดนิยม');
|
|||
const sortOptions = ['ยอดนิยม', 'ล่าสุด', 'ราคาต่ำ-สูงสุด', 'ราคาสูง-ต่ำสุด'];
|
||||
|
||||
const categories = ref<any[]>([]);
|
||||
const courses = ref<any[]>([]);
|
||||
const allCourses = ref<any[]>([]); // เก็บคอร์สทั้งหมดเพื่อกรอง client-side
|
||||
const selectedCourse = ref<any>(null);
|
||||
|
||||
const isLoading = ref(false);
|
||||
|
|
@ -76,20 +76,17 @@ const loadCategories = async () => {
|
|||
if (res.success) categories.value = res.data || [];
|
||||
};
|
||||
|
||||
const loadCourses = async (page = 1) => {
|
||||
const loadCourses = async () => {
|
||||
isLoading.value = true;
|
||||
const categoryId = activeCategory.value === 'all' ? undefined : activeCategory.value as number;
|
||||
|
||||
// โหลดคอร์สทั้งหมดครั้งเดียว (limit สูงๆ เพื่อ client-side filter)
|
||||
const res = await fetchCourses({
|
||||
category_id: categoryId,
|
||||
search: searchQuery.value,
|
||||
page: page,
|
||||
limit: itemsPerPage,
|
||||
limit: 500,
|
||||
forceRefresh: true,
|
||||
});
|
||||
|
||||
if (res.success) {
|
||||
courses.value = (res.data || []).map(c => {
|
||||
allCourses.value = (res.data || []).map(c => {
|
||||
const cat = categories.value.find(cat => cat.id === c.category_id);
|
||||
return {
|
||||
...c,
|
||||
|
|
@ -100,12 +97,33 @@ const loadCourses = async (page = 1) => {
|
|||
reviews_count: c.total_lessons ? c.total_lessons * 123 : Math.floor(Math.random() * 2000) + 100
|
||||
}
|
||||
});
|
||||
totalPages.value = res.totalPages || 1;
|
||||
currentPage.value = res.page || 1;
|
||||
}
|
||||
isLoading.value = false;
|
||||
};
|
||||
|
||||
// Computed: กรองคอร์สแบบ real-time ตาม searchQuery + activeCategory
|
||||
const filteredCourses = computed(() => {
|
||||
let result = allCourses.value;
|
||||
|
||||
// กรองตามหมวดหมู่
|
||||
if (activeCategory.value !== 'all') {
|
||||
result = result.filter(c => c.category_id === activeCategory.value);
|
||||
}
|
||||
|
||||
// กรองตามคำค้นหา (ค้นจากชื่อทั้ง th และ en)
|
||||
if (searchQuery.value.trim()) {
|
||||
const query = searchQuery.value.trim().toLowerCase();
|
||||
result = result.filter(c => {
|
||||
const titleTh = (c.title?.th || '').toLowerCase();
|
||||
const titleEn = (c.title?.en || '').toLowerCase();
|
||||
const titleStr = (typeof c.title === 'string' ? c.title : '').toLowerCase();
|
||||
return titleTh.includes(query) || titleEn.includes(query) || titleStr.includes(query);
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const selectCourse = async (id: number) => {
|
||||
isLoadingDetail.value = true;
|
||||
selectedCourse.value = null;
|
||||
|
|
@ -137,10 +155,10 @@ watch(
|
|||
activeCategory,
|
||||
() => {
|
||||
currentPage.value = 1;
|
||||
loadCourses(1);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
await loadCategories();
|
||||
|
||||
|
|
@ -150,7 +168,7 @@ onMounted(async () => {
|
|||
activeCategory.value = Number(route.query.category_id);
|
||||
}
|
||||
|
||||
await loadCourses(1);
|
||||
await loadCourses();
|
||||
|
||||
if (route.query.course_id) {
|
||||
selectCourse(Number(route.query.course_id));
|
||||
|
|
@ -169,8 +187,8 @@ onMounted(async () => {
|
|||
<h2 class="text-[1.35rem] font-bold text-slate-900 dark:text-white tracking-tight">คอร์สเรียนทั้งหมด</h2>
|
||||
<div class="flex flex-wrap sm:flex-nowrap items-center gap-3 w-full md:w-auto">
|
||||
<div class="relative w-full sm:w-[260px] flex-1">
|
||||
<q-icon name="search" size="18px" class="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-[#3B6BE8]" />
|
||||
<input v-model="searchQuery" @keyup.enter="loadCourses(1)" class="w-full bg-slate-100 dark:bg-slate-800 border-none rounded-xl py-2.5 pl-11 pr-4 text-sm font-medium text-slate-700 dark:text-slate-200 placeholder:text-slate-400 focus:ring-2 focus:ring-blue-500/20 outline-none transition-all shadow-sm" placeholder="ค้นหาคอร์ส..." />
|
||||
<q-icon name="search" size="18px" class="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input v-model="searchQuery" class="w-full bg-slate-100 dark:bg-slate-800 border-none rounded-xl py-2.5 pl-11 pr-4 text-sm font-medium text-slate-700 dark:text-slate-200 placeholder:text-slate-400 focus:ring-2 focus:ring-blue-500/20 outline-none transition-all shadow-sm" :placeholder="$t('discovery.searchPlaceholder')" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<button @click="viewMode = 'grid'" :class="viewMode === 'grid' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-[#3B6BE8]' : 'bg-white border-slate-200 dark:bg-slate-800 dark:border-slate-700 text-slate-400 hover:bg-slate-50'" class="w-[42px] h-[42px] flex items-center justify-center rounded-xl border transition-colors outline-none"><q-icon name="grid_view" size="20px" /></button>
|
||||
|
|
@ -208,10 +226,10 @@ onMounted(async () => {
|
|||
<q-spinner size="3rem" color="primary" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="courses.length > 0">
|
||||
<div v-else-if="filteredCourses.length > 0">
|
||||
<!-- GRID VIEW -->
|
||||
<div v-if="viewMode === 'grid'" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
<div v-for="course in courses" :key="course.id" class="flex flex-col rounded-[1.5rem] bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 overflow-hidden shadow-[0_2px_10px_rgb(0,0,0,0.03)] hover:shadow-[0_8px_30px_rgb(0,0,0,0.08)] transition-all duration-300 group cursor-pointer" @click="selectCourse(course.id)">
|
||||
<div v-for="course in filteredCourses" :key="course.id" class="flex flex-col rounded-[1.5rem] bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 overflow-hidden shadow-[0_2px_10px_rgb(0,0,0,0.03)] hover:shadow-[0_8px_30px_rgb(0,0,0,0.08)] transition-all duration-300 group cursor-pointer" @click="selectCourse(course.id)">
|
||||
<!-- Thumbnail -->
|
||||
<div class="relative w-full aspect-[16/10] bg-slate-100 dark:bg-slate-800 overflow-hidden">
|
||||
<img :src="course.thumbnail_url" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
|
||||
|
|
@ -241,7 +259,7 @@ onMounted(async () => {
|
|||
|
||||
<!-- LIST VIEW -->
|
||||
<div v-else class="flex flex-col gap-5">
|
||||
<div v-for="course in courses" :key="course.id" class="flex flex-col sm:flex-row rounded-[1.5rem] bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 p-3 sm:p-4 gap-4 sm:gap-6 shadow-sm hover:shadow-[0_8px_30px_rgb(0,0,0,0.06)] transition-all duration-300 group cursor-pointer" @click="selectCourse(course.id)">
|
||||
<div v-for="course in filteredCourses" :key="course.id" class="flex flex-col sm:flex-row rounded-[1.5rem] bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 p-3 sm:p-4 gap-4 sm:gap-6 shadow-sm hover:shadow-[0_8px_30px_rgb(0,0,0,0.06)] transition-all duration-300 group cursor-pointer" @click="selectCourse(course.id)">
|
||||
<div class="relative w-full sm:w-[260px] aspect-[16/10] sm:aspect-auto sm:h-[160px] rounded-[1rem] bg-slate-100 dark:bg-slate-800 overflow-hidden shrink-0">
|
||||
<img :src="course.thumbnail_url" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
|
||||
<div v-if="course.category_name" class="absolute top-3 left-3 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md text-[#3B6BE8] dark:text-blue-400 font-bold text-[10px] px-3.5 py-1.5 rounded-full shadow-sm" style="border-radius: 9999px; padding: 4px 12px;">
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ await useAsyncData('categories-list', () => fetchCategories())
|
|||
const { data: coursesResponse, pending: isLoading, error, refresh } = await useAsyncData(
|
||||
'browse-courses-list',
|
||||
() => {
|
||||
const params: any = {}
|
||||
const params: any = { limit: 500 }
|
||||
if (selectedCategory.value !== 'all') {
|
||||
const category = categories.value.find(c => c.slug === selectedCategory.value)
|
||||
if (category) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue