Compare commits
8 commits
learner-de
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e02da48f7c | ||
|
|
ae32cfebe4 | ||
|
|
ea442d7815 | ||
|
|
ac768a3df4 | ||
|
|
9e4fcbf04e | ||
|
|
853c141910 | ||
|
|
b0b665f588 | ||
|
|
0205aab461 |
|
|
@ -40,7 +40,7 @@ export const useAuth = () => {
|
||||||
// ฟังก์ชันเข้าสู่ระบบ (Login)
|
// ฟังก์ชันเข้าสู่ระบบ (Login)
|
||||||
const login = async (credentials: { email: string; password: string }) => {
|
const login = async (credentials: { email: string; password: string }) => {
|
||||||
try {
|
try {
|
||||||
// API returns { code: 200, message: "...", data: { token, user, ... } }
|
// API returns { code: 200, message: "...", data: { token, refreshToken } }
|
||||||
const response = await $fetch<any>(`${API_BASE_URL}/auth/login`, {
|
const response = await $fetch<any>(`${API_BASE_URL}/auth/login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: credentials
|
body: credentials
|
||||||
|
|
@ -49,16 +49,35 @@ export const useAuth = () => {
|
||||||
if (response && response.data) {
|
if (response && response.data) {
|
||||||
const data = response.data
|
const data = response.data
|
||||||
|
|
||||||
// Validation: Ensure user and role exist, then check for Role 'STUDENT'
|
// บันทึก Token ก่อน เพื่อใช้เรียก /user/me
|
||||||
if (!data.user || !data.user.role || data.user.role.code !== 'STUDENT') {
|
|
||||||
return { success: false, error: 'Email ไม่ถูกต้อง' }
|
|
||||||
}
|
|
||||||
|
|
||||||
token.value = data.token
|
token.value = data.token
|
||||||
refreshToken.value = data.refreshToken // บันทึก Refresh Token
|
refreshToken.value = data.refreshToken
|
||||||
|
|
||||||
// API ส่งข้อมูล profile มาใน user object
|
// ดึงข้อมูลผู้ใช้จาก /user/me (เพราะ API login ไม่ส่ง user กลับมาแล้ว)
|
||||||
user.value = data.user
|
try {
|
||||||
|
const userData = await $fetch<any>(`${API_BASE_URL}/user/me`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${data.token}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Validation: ตรวจสอบ Role ต้องเป็น STUDENT เท่านั้น
|
||||||
|
if (!userData || !userData.role || userData.role.code !== 'STUDENT') {
|
||||||
|
// ถ้า Role ไม่ใช่ STUDENT ให้ล้าง Token ออก
|
||||||
|
token.value = null
|
||||||
|
refreshToken.value = null
|
||||||
|
return { success: false, error: 'Email ไม่ถูกต้อง' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// เก็บข้อมูล User ลง Cookie
|
||||||
|
user.value = userData
|
||||||
|
} catch (profileErr) {
|
||||||
|
// ดึงข้อมูลผู้ใช้ไม่สำเร็จ ให้ล้าง Token ออก
|
||||||
|
console.error('Failed to fetch user profile after login:', profileErr)
|
||||||
|
token.value = null
|
||||||
|
refreshToken.value = null
|
||||||
|
return { success: false, error: 'ไม่สามารถดึงข้อมูลผู้ใช้ได้' }
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,11 @@
|
||||||
"foundTotal": "Found Total",
|
"foundTotal": "Found Total",
|
||||||
"items": "items",
|
"items": "items",
|
||||||
"subtitle": "Choose to learn new skills from our curated quality courses",
|
"subtitle": "Choose to learn new skills from our curated quality courses",
|
||||||
"searchBtn": "Search"
|
"searchBtn": "Search",
|
||||||
|
"allCategory": "All",
|
||||||
|
"byInstructor": "by",
|
||||||
|
"students": "students",
|
||||||
|
"viewDetails": "View Details"
|
||||||
},
|
},
|
||||||
"myCourses": {
|
"myCourses": {
|
||||||
"title": "My Courses",
|
"title": "My Courses",
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,11 @@
|
||||||
"foundTotal": "พบทั้งหมด",
|
"foundTotal": "พบทั้งหมด",
|
||||||
"items": "รายการ",
|
"items": "รายการ",
|
||||||
"subtitle": "เลือกเรียนรู้ทักษะใหม่ๆ จากหลักสูตรคุณภาพที่คัดสรรมาเพื่อคุณ",
|
"subtitle": "เลือกเรียนรู้ทักษะใหม่ๆ จากหลักสูตรคุณภาพที่คัดสรรมาเพื่อคุณ",
|
||||||
"searchBtn": "ค้นหา"
|
"searchBtn": "ค้นหา",
|
||||||
|
"allCategory": "ทั้งหมด",
|
||||||
|
"byInstructor": "โดย",
|
||||||
|
"students": "นักเรียน",
|
||||||
|
"viewDetails": "ดูรายละเอียด"
|
||||||
},
|
},
|
||||||
"myCourses": {
|
"myCourses": {
|
||||||
"title": "คอร์สของฉัน",
|
"title": "คอร์สของฉัน",
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ const sortBy = ref('ยอดนิยม');
|
||||||
const sortOptions = ['ยอดนิยม', 'ล่าสุด', 'ราคาต่ำ-สูงสุด', 'ราคาสูง-ต่ำสุด'];
|
const sortOptions = ['ยอดนิยม', 'ล่าสุด', 'ราคาต่ำ-สูงสุด', 'ราคาสูง-ต่ำสุด'];
|
||||||
|
|
||||||
const categories = ref<any[]>([]);
|
const categories = ref<any[]>([]);
|
||||||
const courses = ref<any[]>([]);
|
const allCourses = ref<any[]>([]); // เก็บคอร์สทั้งหมดเพื่อกรอง client-side
|
||||||
const selectedCourse = ref<any>(null);
|
const selectedCourse = ref<any>(null);
|
||||||
|
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
|
|
@ -76,20 +76,17 @@ const loadCategories = async () => {
|
||||||
if (res.success) categories.value = res.data || [];
|
if (res.success) categories.value = res.data || [];
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadCourses = async (page = 1) => {
|
const loadCourses = async () => {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
const categoryId = activeCategory.value === 'all' ? undefined : activeCategory.value as number;
|
|
||||||
|
|
||||||
|
// โหลดคอร์สทั้งหมดครั้งเดียว (limit สูงๆ เพื่อ client-side filter)
|
||||||
const res = await fetchCourses({
|
const res = await fetchCourses({
|
||||||
category_id: categoryId,
|
limit: 500,
|
||||||
search: searchQuery.value,
|
|
||||||
page: page,
|
|
||||||
limit: itemsPerPage,
|
|
||||||
forceRefresh: true,
|
forceRefresh: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.success) {
|
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);
|
const cat = categories.value.find(cat => cat.id === c.category_id);
|
||||||
return {
|
return {
|
||||||
...c,
|
...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
|
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;
|
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) => {
|
const selectCourse = async (id: number) => {
|
||||||
isLoadingDetail.value = true;
|
isLoadingDetail.value = true;
|
||||||
selectedCourse.value = null;
|
selectedCourse.value = null;
|
||||||
|
|
@ -137,10 +155,10 @@ watch(
|
||||||
activeCategory,
|
activeCategory,
|
||||||
() => {
|
() => {
|
||||||
currentPage.value = 1;
|
currentPage.value = 1;
|
||||||
loadCourses(1);
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadCategories();
|
await loadCategories();
|
||||||
|
|
||||||
|
|
@ -150,7 +168,7 @@ onMounted(async () => {
|
||||||
activeCategory.value = Number(route.query.category_id);
|
activeCategory.value = Number(route.query.category_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadCourses(1);
|
await loadCourses();
|
||||||
|
|
||||||
if (route.query.course_id) {
|
if (route.query.course_id) {
|
||||||
selectCourse(Number(route.query.course_id));
|
selectCourse(Number(route.query.course_id));
|
||||||
|
|
@ -162,19 +180,19 @@ onMounted(async () => {
|
||||||
<div class="bg-[#F8F9FA] dark:bg-[#020617] min-h-screen p-4 md:p-8 transition-colors duration-300">
|
<div class="bg-[#F8F9FA] dark:bg-[#020617] min-h-screen p-4 md:p-8 transition-colors duration-300">
|
||||||
<div class="max-w-[1240px] mx-auto">
|
<div class="max-w-[1240px] mx-auto">
|
||||||
<!-- ส่วนของการค้นหาคอร์ส (Catalog View) -->
|
<!-- ส่วนของการค้นหาคอร์ส (Catalog View) -->
|
||||||
<div v-if="!showDetail" class="bg-white dark:bg-slate-900 rounded-[2rem] p-6 md:p-8 shadow-[0_2px_15px_rgb(0,0,0,0.02)] border border-slate-100 dark:border-slate-800 min-h-[500px] mb-12">
|
<div v-if="!showDetail" class="bg-white dark:!bg-slate-900 rounded-[2rem] p-6 md:p-8 shadow-[0_2px_15px_rgb(0,0,0,0.02)] border border-slate-100 dark:!border-slate-800 min-h-[500px] mb-12 transition-colors">
|
||||||
|
|
||||||
<!-- ส่วนหัวและการค้นหา -->
|
<!-- ส่วนหัวและการค้นหา -->
|
||||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
|
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
|
||||||
<h2 class="text-[1.35rem] font-bold text-slate-900 dark:text-white tracking-tight">คอร์สเรียนทั้งหมด</h2>
|
<h2 class="text-[1.35rem] font-bold text-slate-900 dark:text-[#f8fafc] tracking-tight">{{ $t('discovery.title') }}</h2>
|
||||||
<div class="flex flex-wrap sm:flex-nowrap items-center gap-3 w-full md:w-auto">
|
<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">
|
<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]" />
|
<q-icon name="search" size="18px" class="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||||
<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="ค้นหาคอร์ส..." />
|
<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>
|
||||||
<div class="flex items-center gap-2 shrink-0">
|
<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>
|
<button @click="viewMode = 'grid'" :class="viewMode === 'grid' ? 'bg-[#E9EFFD] dark:!bg-blue-900/40 text-[#3B6BE8] dark:!text-blue-400 border-[#3B6BE8] dark:!border-blue-400' : 'bg-white border-slate-200 dark:!bg-slate-800 dark:!border-slate-700 text-slate-400 dark:!text-slate-300 hover:bg-slate-50 dark:hover:!bg-slate-700'" 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>
|
||||||
<button @click="viewMode = 'list'" :class="viewMode === 'list' ? '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="view_list" size="20px" /></button>
|
<button @click="viewMode = 'list'" :class="viewMode === 'list' ? 'bg-[#E9EFFD] dark:!bg-blue-900/40 text-[#3B6BE8] dark:!text-blue-400 border-[#3B6BE8] dark:!border-blue-400' : 'bg-white border-slate-200 dark:!bg-slate-800 dark:!border-slate-700 text-slate-400 dark:!text-slate-300 hover:bg-slate-50 dark:hover:!bg-slate-700'" class="w-[42px] h-[42px] flex items-center justify-center rounded-xl border transition-colors outline-none"><q-icon name="view_list" size="20px" /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -185,17 +203,17 @@ onMounted(async () => {
|
||||||
<div class="flex flex-wrap items-center gap-3 w-full xl:w-auto">
|
<div class="flex flex-wrap items-center gap-3 w-full xl:w-auto">
|
||||||
<button
|
<button
|
||||||
@click="activeCategory = 'all'"
|
@click="activeCategory = 'all'"
|
||||||
:class="activeCategory === 'all' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-transparent font-bold' : 'bg-white dark:bg-transparent border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-300 hover:border-slate-300 font-medium'"
|
:class="activeCategory === 'all' ? 'bg-[#E9EFFD] dark:!bg-blue-900/40 text-[#3B6BE8] dark:!text-blue-400 border-transparent font-bold' : 'bg-white dark:!bg-slate-800 border-slate-200 dark:!border-slate-700 text-slate-800 dark:!text-slate-200 hover:border-slate-300 dark:hover:!border-slate-600 font-medium'"
|
||||||
class="px-5 py-2.5 rounded-full border text-[13px] sm:text-[14px] flex items-center justify-center gap-2 transition-all outline-none">
|
class="px-5 py-2.5 rounded-full border text-[13px] sm:text-[14px] flex items-center justify-center gap-2 transition-all outline-none">
|
||||||
<q-icon name="check_circle_outline" size="18px" :class="activeCategory === 'all' ? 'text-[#3B6BE8]' : 'text-slate-400'"/> ทั้งหมด
|
<q-icon name="check_circle_outline" size="18px" :class="activeCategory === 'all' ? 'text-[#3B6BE8] dark:!text-blue-400' : 'text-slate-400 dark:!text-slate-400'"/> {{ $t('discovery.allCategory') }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-for="cat in categories" :key="cat.id"
|
v-for="cat in categories" :key="cat.id"
|
||||||
@click="activeCategory = cat.id"
|
@click="activeCategory = cat.id"
|
||||||
:class="activeCategory === cat.id ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-transparent font-bold' : 'bg-white dark:bg-transparent border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-300 hover:border-slate-300 font-medium'"
|
:class="activeCategory === cat.id ? 'bg-[#E9EFFD] dark:!bg-blue-900/40 text-[#3B6BE8] dark:!text-blue-400 border-transparent font-bold' : 'bg-white dark:!bg-slate-800 border-slate-200 dark:!border-slate-700 text-slate-800 dark:!text-slate-200 hover:border-slate-300 dark:hover:!border-slate-600 font-medium'"
|
||||||
class="px-5 py-2.5 rounded-full border text-[13px] sm:text-[14px] flex items-center justify-center gap-2 transition-all outline-none bg-transparent">
|
class="px-5 py-2.5 rounded-full border text-[13px] sm:text-[14px] flex items-center justify-center gap-2 transition-all outline-none">
|
||||||
<q-icon :name="getCategoryIcon(cat.name)" size="18px" :class="activeCategory === cat.id ? 'text-[#3B6BE8]' : 'text-slate-600 dark:text-slate-400'"/>
|
<q-icon :name="getCategoryIcon(cat.name)" size="18px" :class="activeCategory === cat.id ? 'text-[#3B6BE8] dark:!text-blue-400' : 'text-slate-600 dark:!text-slate-400'"/>
|
||||||
{{ getLocalizedText(cat.name) }}
|
{{ getLocalizedText(cat.name) }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -208,10 +226,10 @@ onMounted(async () => {
|
||||||
<q-spinner size="3rem" color="primary" />
|
<q-spinner size="3rem" color="primary" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="courses.length > 0">
|
<div v-else-if="filteredCourses.length > 0">
|
||||||
<!-- GRID VIEW -->
|
<!-- 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-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 -->
|
<!-- Thumbnail -->
|
||||||
<div class="relative w-full aspect-[16/10] bg-slate-100 dark:bg-slate-800 overflow-hidden">
|
<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" />
|
<img :src="course.thumbnail_url" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
|
||||||
|
|
@ -222,7 +240,7 @@ onMounted(async () => {
|
||||||
|
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
<div class="p-5 flex flex-col flex-1">
|
<div class="p-5 flex flex-col flex-1">
|
||||||
<h3 class="font-bold text-slate-900 dark:text-white text-[15px] leading-snug line-clamp-2 mb-2">{{ getLocalizedText(course.title) }}</h3>
|
<h3 class="font-bold text-slate-900 dark:text-[#f8fafc] text-[15px] leading-snug line-clamp-2 mb-2 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">{{ getLocalizedText(course.title) }}</h3>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -230,8 +248,7 @@ onMounted(async () => {
|
||||||
<div class="font-[900] text-[18px]" :class="course.is_free ? 'text-green-500' : 'text-[#2563EB] dark:text-blue-400'">
|
<div class="font-[900] text-[18px]" :class="course.is_free ? 'text-green-500' : 'text-[#2563EB] dark:text-blue-400'">
|
||||||
{{ course.formatted_price }}
|
{{ course.formatted_price }}
|
||||||
</div>
|
</div>
|
||||||
<!-- Eye icon circle button -->
|
<button class="w-[38px] h-[38px] rounded-full bg-slate-50 dark:!bg-slate-800 text-slate-400 dark:!text-slate-300 flex items-center justify-center hover:bg-blue-50 hover:text-blue-600 dark:hover:!bg-slate-700 dark:hover:!text-blue-400 border border-slate-100 dark:!border-slate-700 transition-colors shadow-sm outline-none">
|
||||||
<button class="w-[38px] h-[38px] rounded-full bg-slate-50 dark:bg-slate-800 text-slate-400 dark:text-slate-500 flex items-center justify-center hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-slate-700 border border-slate-100 dark:border-slate-700 transition-colors shadow-sm outline-none">
|
|
||||||
<q-icon name="visibility" size="18px" />
|
<q-icon name="visibility" size="18px" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -241,7 +258,7 @@ onMounted(async () => {
|
||||||
|
|
||||||
<!-- LIST VIEW -->
|
<!-- LIST VIEW -->
|
||||||
<div v-else class="flex flex-col gap-5">
|
<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">
|
<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" />
|
<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;">
|
<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;">
|
||||||
|
|
@ -250,15 +267,15 @@ onMounted(async () => {
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col flex-1 py-1">
|
<div class="flex flex-col flex-1 py-1">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<h3 class="font-bold text-slate-900 dark:text-white text-[16px] md:text-[18px] leading-snug line-clamp-2 md:line-clamp-1 mb-2">{{ getLocalizedText(course.title) }}</h3>
|
<h3 class="font-bold text-slate-900 dark:text-[#f8fafc] text-[16px] md:text-[18px] leading-snug line-clamp-2 md:line-clamp-1 mb-2 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">{{ getLocalizedText(course.title) }}</h3>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 sm:mt-auto flex items-center justify-between">
|
<div class="mt-4 sm:mt-auto flex items-center justify-between">
|
||||||
<div class="font-[900] text-[20px]" :class="course.is_free ? 'text-green-500' : 'text-[#2563EB] dark:text-blue-400'">
|
<div class="font-[900] text-[20px]" :class="course.is_free ? 'text-green-500' : 'text-[#2563EB] dark:text-blue-400'">
|
||||||
{{ course.formatted_price }}
|
{{ course.formatted_price }}
|
||||||
</div>
|
</div>
|
||||||
<button class="px-6 py-2 rounded-full bg-slate-50 text-slate-600 dark:bg-slate-800 dark:text-slate-300 font-bold text-[13px] flex items-center gap-2 hover:bg-blue-50 border border-slate-100 dark:border-slate-700 hover:text-blue-600 transition-colors">
|
<button class="px-6 py-2 rounded-full bg-slate-50 text-slate-600 dark:!bg-slate-800 dark:!text-slate-300 font-bold text-[13px] flex items-center gap-2 hover:bg-blue-50 hover:text-blue-600 dark:hover:!bg-slate-700 dark:hover:!text-blue-400 border border-slate-100 dark:!border-slate-700 transition-colors">
|
||||||
<q-icon name="visibility" size="16px" /> ดูรายละเอียด
|
<q-icon name="visibility" size="16px" /> {{ $t('discovery.viewDetails') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -272,9 +289,9 @@ onMounted(async () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<div v-else class="flex flex-col items-center justify-center py-20 bg-white dark:bg-slate-900/40 rounded-3xl border border-dashed border-slate-200 dark:border-slate-800">
|
<div v-else class="flex flex-col items-center justify-center py-20 bg-white dark:!bg-slate-900/50 rounded-3xl border border-dashed border-slate-200 dark:!border-slate-800">
|
||||||
<q-icon name="search_off" size="64px" class="text-slate-300 dark:text-slate-600 mb-4" />
|
<q-icon name="search_off" size="64px" class="text-slate-300 dark:text-slate-600 mb-4" />
|
||||||
<h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">{{ $t("discovery.emptyTitle") }}</h3>
|
<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>
|
<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:text-blue-700 transition-colors" @click="searchQuery = ''; activeCategory = 'all';">
|
<button class="mt-6 font-bold text-blue-600 hover:text-blue-700 transition-colors" @click="searchQuery = ''; activeCategory = 'all';">
|
||||||
{{ $t("discovery.showAll") }}
|
{{ $t("discovery.showAll") }}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ useHead({
|
||||||
title: 'คอร์สทั้งหมด - E-Learning System'
|
title: 'คอร์สทั้งหมด - E-Learning System'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const { fetchCourses } = useCourse()
|
const { fetchCourses } = useCourse()
|
||||||
const { fetchCategories, categories } = useCategory()
|
const { fetchCategories, categories } = useCategory()
|
||||||
|
|
@ -54,7 +55,7 @@ await useAsyncData('categories-list', () => fetchCategories())
|
||||||
const { data: coursesResponse, pending: isLoading, error, refresh } = await useAsyncData(
|
const { data: coursesResponse, pending: isLoading, error, refresh } = await useAsyncData(
|
||||||
'browse-courses-list',
|
'browse-courses-list',
|
||||||
() => {
|
() => {
|
||||||
const params: any = {}
|
const params: any = { limit: 500 }
|
||||||
if (selectedCategory.value !== 'all') {
|
if (selectedCategory.value !== 'all') {
|
||||||
const category = categories.value.find(c => c.slug === selectedCategory.value)
|
const category = categories.value.find(c => c.slug === selectedCategory.value)
|
||||||
if (category) {
|
if (category) {
|
||||||
|
|
@ -131,11 +132,11 @@ const viewMode = ref<'grid' | 'list'>('grid')
|
||||||
|
|
||||||
<!-- ส่วนหัวและการค้นหา -->
|
<!-- ส่วนหัวและการค้นหา -->
|
||||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
|
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
|
||||||
<h2 class="text-[1.35rem] font-bold text-slate-900 dark:text-white tracking-tight">คอร์สเรียนทั้งหมด</h2>
|
<h2 class="text-[1.35rem] font-bold text-slate-900 dark:text-white tracking-tight">{{ $t('discovery.title') }}</h2>
|
||||||
<div class="flex flex-wrap sm:flex-nowrap items-center gap-3 w-full md:w-auto">
|
<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">
|
<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]" />
|
<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" 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="ค้นหาคอร์ส..." />
|
<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>
|
||||||
<div class="flex items-center gap-2 shrink-0">
|
<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>
|
<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>
|
||||||
|
|
@ -151,7 +152,7 @@ const viewMode = ref<'grid' | 'list'>('grid')
|
||||||
@click="selectCategory('all')"
|
@click="selectCategory('all')"
|
||||||
:class="selectedCategory === 'all' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-transparent font-bold' : 'bg-white dark:bg-transparent border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-300 hover:border-slate-300 font-medium'"
|
:class="selectedCategory === 'all' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-transparent font-bold' : 'bg-white dark:bg-transparent border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-300 hover:border-slate-300 font-medium'"
|
||||||
class="px-5 py-2.5 rounded-full border text-[13px] sm:text-[14px] flex items-center justify-center gap-2 transition-all outline-none">
|
class="px-5 py-2.5 rounded-full border text-[13px] sm:text-[14px] flex items-center justify-center gap-2 transition-all outline-none">
|
||||||
<q-icon name="check_circle_outline" size="18px" :class="selectedCategory === 'all' ? 'text-[#3B6BE8]' : 'text-slate-400'"/> ทั้งหมด
|
<q-icon name="check_circle_outline" size="18px" :class="selectedCategory === 'all' ? 'text-[#3B6BE8]' : 'text-slate-400'"/> {{ $t('discovery.allCategory') }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
@ -187,13 +188,13 @@ const viewMode = ref<'grid' | 'list'>('grid')
|
||||||
<h3 class="font-bold text-slate-900 dark:text-white text-[15px] leading-snug line-clamp-2 mb-2 group-hover:text-blue-600 transition-colors">{{ getLocalizedText(course.title) }}</h3>
|
<h3 class="font-bold text-slate-900 dark:text-white text-[15px] leading-snug line-clamp-2 mb-2 group-hover:text-blue-600 transition-colors">{{ getLocalizedText(course.title) }}</h3>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 mb-4">
|
<div class="flex items-center gap-2 mb-4">
|
||||||
<span class="text-[12px] text-slate-500 font-medium">โดย {{ course.instructor_name }}</span>
|
<span class="text-[12px] text-slate-500 font-medium">{{ $t('discovery.byInstructor') }} {{ course.instructor_name }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-1.5 mb-5">
|
<div class="flex items-center gap-1.5 mb-5">
|
||||||
<q-icon name="star" class="text-amber-400" size="16px" />
|
<q-icon name="star" class="text-amber-400" size="16px" />
|
||||||
<span class="text-[13px] font-bold text-slate-800 dark:text-slate-200">{{ course.rating }}</span>
|
<span class="text-[13px] font-bold text-slate-800 dark:text-slate-200">{{ course.rating }}</span>
|
||||||
<span class="text-[12px] text-slate-400">({{ course.reviews_count.toLocaleString() }} นักเรียน)</span>
|
<span class="text-[12px] text-slate-400">({{ course.reviews_count.toLocaleString() }} {{ $t('discovery.students') }})</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-auto flex items-center justify-between">
|
<div class="mt-auto flex items-center justify-between">
|
||||||
|
|
@ -222,12 +223,12 @@ const viewMode = ref<'grid' | 'list'>('grid')
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<h3 class="font-bold text-slate-900 dark:text-white text-[16px] md:text-[18px] leading-snug line-clamp-2 md:line-clamp-1 mb-2 group-hover:text-blue-600 transition-colors">{{ getLocalizedText(course.title) }}</h3>
|
<h3 class="font-bold text-slate-900 dark:text-white text-[16px] md:text-[18px] leading-snug line-clamp-2 md:line-clamp-1 mb-2 group-hover:text-blue-600 transition-colors">{{ getLocalizedText(course.title) }}</h3>
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="flex items-center gap-2 mb-3">
|
||||||
<span class="text-[13px] text-slate-500 font-medium">โดย {{ course.instructor_name }}</span>
|
<span class="text-[13px] text-slate-500 font-medium">{{ $t('discovery.byInstructor') }} {{ course.instructor_name }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5 mb-2">
|
<div class="flex items-center gap-1.5 mb-2">
|
||||||
<q-icon name="star" class="text-amber-400" size="16px" />
|
<q-icon name="star" class="text-amber-400" size="16px" />
|
||||||
<span class="text-[13px] font-bold text-slate-800 dark:text-slate-200">{{ course.rating }}</span>
|
<span class="text-[13px] font-bold text-slate-800 dark:text-slate-200">{{ course.rating }}</span>
|
||||||
<span class="text-[12px] text-slate-400">({{ course.reviews_count.toLocaleString() }} นักเรียน)</span>
|
<span class="text-[12px] text-slate-400">({{ course.reviews_count.toLocaleString() }} {{ $t('discovery.students') }})</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 sm:mt-auto flex items-center justify-between">
|
<div class="mt-4 sm:mt-auto flex items-center justify-between">
|
||||||
|
|
@ -235,7 +236,7 @@ const viewMode = ref<'grid' | 'list'>('grid')
|
||||||
{{ course.formatted_price }}
|
{{ course.formatted_price }}
|
||||||
</div>
|
</div>
|
||||||
<button class="px-6 py-2 rounded-full bg-slate-50 text-slate-600 dark:bg-slate-800 dark:text-slate-300 font-bold text-[13px] flex items-center gap-2 hover:bg-blue-50 border border-slate-100 dark:border-slate-700 hover:text-blue-600 transition-colors">
|
<button class="px-6 py-2 rounded-full bg-slate-50 text-slate-600 dark:bg-slate-800 dark:text-slate-300 font-bold text-[13px] flex items-center gap-2 hover:bg-blue-50 border border-slate-100 dark:border-slate-700 hover:text-blue-600 transition-colors">
|
||||||
<q-icon name="visibility" size="16px" /> ดูรายละเอียด
|
<q-icon name="visibility" size="16px" /> {{ $t('discovery.viewDetails') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -246,10 +247,10 @@ const viewMode = ref<'grid' | 'list'>('grid')
|
||||||
<!-- กรณีไม่พบข้อมูลคอร์ส (Empty State) -->
|
<!-- กรณีไม่พบข้อมูลคอร์ส (Empty State) -->
|
||||||
<div v-else class="flex flex-col items-center justify-center py-20 bg-white dark:bg-slate-900/40 rounded-3xl border border-dashed border-slate-200 dark:border-slate-800">
|
<div v-else class="flex flex-col items-center justify-center py-20 bg-white dark:bg-slate-900/40 rounded-3xl border border-dashed border-slate-200 dark:border-slate-800">
|
||||||
<q-icon name="search_off" size="64px" class="text-slate-300 dark:text-slate-600 mb-4" />
|
<q-icon name="search_off" size="64px" class="text-slate-300 dark:text-slate-600 mb-4" />
|
||||||
<h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">{{ searchQuery ? 'ไม่พบคอร์สที่คุณค้นหา' : 'ไม่มีคอร์สในหมวดหมู่นี้' }}</h3>
|
<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">ลองใช้คำค้นหาอื่น หรือเลือกหมวดหมู่อื่นเพื่อดูคอร์สที่เรามีให้บริการ</p>
|
<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:text-blue-700 transition-colors" @click="searchQuery = ''; selectedCategory = 'all';">
|
<button class="mt-6 font-bold text-blue-600 hover:text-blue-700 transition-colors" @click="searchQuery = ''; selectedCategory = 'all';">
|
||||||
แสดงคอร์สทั้งหมด
|
{{ $t('discovery.showAll') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,13 @@
|
||||||
|
/**
|
||||||
|
* @file auth.spec.ts
|
||||||
|
* @description ทดสอบระบบยืนยันตัวตน (Authentication) — Login, Register, Forgot Password
|
||||||
|
*/
|
||||||
import { test, expect, type Page, type Locator } from '@playwright/test';
|
import { test, expect, type Page, type Locator } from '@playwright/test';
|
||||||
|
import {
|
||||||
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
BASE_URL, TEST_EMAIL, TEST_PASSWORD, TIMEOUT,
|
||||||
|
waitAppSettled, expectAnyVisible,
|
||||||
async function waitAppSettled(page: Page) {
|
emailLocator, passwordLocator, loginButtonLocator,
|
||||||
await page.waitForLoadState('domcontentloaded');
|
} from './helpers';
|
||||||
await page.waitForLoadState('networkidle').catch(() => {});
|
|
||||||
await page.waitForTimeout(250);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------
|
|
||||||
// Helpers: Login
|
|
||||||
// ---------------------------
|
|
||||||
const LOGIN_EMAIL = 'studentedtest@example.com';
|
|
||||||
const LOGIN_PASSWORD = 'admin123';
|
|
||||||
|
|
||||||
function loginEmailLocator(page: Page): Locator {
|
|
||||||
return page.locator('input[type="email"]').or(page.getByRole('textbox', { name: /อีเมล|email/i })).first();
|
|
||||||
}
|
|
||||||
function loginPasswordLocator(page: Page): Locator {
|
|
||||||
return page.locator('input[type="password"]').or(page.getByRole('textbox', { name: /รหัสผ่าน|password/i })).first();
|
|
||||||
}
|
|
||||||
function loginButtonLocator(page: Page): Locator {
|
|
||||||
return page.getByRole('button', { name: /เข้าสู่ระบบ|login/i }).or(page.locator('button[type="submit"]')).first();
|
|
||||||
}
|
|
||||||
async function expectAnyVisible(page: Page, locators: Locator[], timeout = 20_000) {
|
|
||||||
const start = Date.now();
|
|
||||||
while (Date.now() - start < timeout) {
|
|
||||||
for (const loc of locators) {
|
|
||||||
try {
|
|
||||||
if (await loc.isVisible()) return;
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
await page.waitForTimeout(200);
|
|
||||||
}
|
|
||||||
throw new Error('None of the expected locators became visible.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
// Helpers: Register
|
// Helpers: Register
|
||||||
|
|
@ -53,6 +26,7 @@ function regLoginLink(page: Page) { return page.getByRole('link', { name: 'เ
|
||||||
function regErrorBox(page: Page) {
|
function regErrorBox(page: Page) {
|
||||||
return page.locator(['.q-field__messages', '.q-field__bottom', '.text-negative', '.q-notification', '.q-banner', '[role="alert"]'].join(', '));
|
return page.locator(['.q-field__messages', '.q-field__bottom', '.text-negative', '.q-notification', '.q-banner', '[role="alert"]'].join(', '));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pickPrefix(page: Page, value: 'นาย' | 'นาง' | 'นางสาว' = 'นาย') {
|
async function pickPrefix(page: Page, value: 'นาย' | 'นาง' | 'นางสาว' = 'นาย') {
|
||||||
const combo = regPrefix(page);
|
const combo = regPrefix(page);
|
||||||
await combo.selectOption({ label: value }).catch(async () => {
|
await combo.selectOption({ label: value }).catch(async () => {
|
||||||
|
|
@ -60,6 +34,7 @@ async function pickPrefix(page: Page, value: 'นาย' | 'นาง' | 'นา
|
||||||
await page.getByRole('option', { name: value }).click();
|
await page.getByRole('option', { name: value }).click();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function uniqueUser() {
|
function uniqueUser() {
|
||||||
const n = Date.now().toString().slice(-6);
|
const n = Date.now().toString().slice(-6);
|
||||||
const rand8 = Math.floor(Math.random() * 1e8).toString().padStart(8, '0');
|
const rand8 = Math.floor(Math.random() * 1e8).toString().padStart(8, '0');
|
||||||
|
|
@ -90,10 +65,12 @@ test.describe('ระบบยืนยันตัวตน (Authentication)',
|
||||||
test('Success Login แล้วเข้า /dashboard ได้', async ({ page }) => {
|
test('Success Login แล้วเข้า /dashboard ได้', async ({ page }) => {
|
||||||
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
|
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
|
||||||
await waitAppSettled(page);
|
await waitAppSettled(page);
|
||||||
await loginEmailLocator(page).fill(LOGIN_EMAIL);
|
|
||||||
await loginPasswordLocator(page).fill(LOGIN_PASSWORD);
|
await emailLocator(page).fill(TEST_EMAIL);
|
||||||
|
await passwordLocator(page).fill(TEST_PASSWORD);
|
||||||
await loginButtonLocator(page).click();
|
await loginButtonLocator(page).click();
|
||||||
await page.waitForURL('**/dashboard', { timeout: 25_000 });
|
|
||||||
|
await page.waitForURL('**/dashboard', { timeout: TIMEOUT.LOGIN });
|
||||||
await waitAppSettled(page);
|
await waitAppSettled(page);
|
||||||
|
|
||||||
const dashboardEvidence = [
|
const dashboardEvidence = [
|
||||||
|
|
@ -102,38 +79,45 @@ test.describe('ระบบยืนยันตัวตน (Authentication)',
|
||||||
page.locator('img[src*="avataaars"]').first(),
|
page.locator('img[src*="avataaars"]').first(),
|
||||||
page.locator('img[alt],[alt="User Avatar"]').first()
|
page.locator('img[alt],[alt="User Avatar"]').first()
|
||||||
];
|
];
|
||||||
await expectAnyVisible(page, dashboardEvidence, 20_000);
|
await expectAnyVisible(page, dashboardEvidence, TIMEOUT.PAGE_LOAD);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Invalid Email - Thai characters (พิมพ์ภาษาไทย)', async ({ page }) => {
|
test('Invalid Email - Thai characters (พิมพ์ภาษาไทย)', async ({ page }) => {
|
||||||
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
|
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
|
||||||
await waitAppSettled(page);
|
await waitAppSettled(page);
|
||||||
await loginEmailLocator(page).fill('ทดสอบภาษาไทย');
|
|
||||||
await loginPasswordLocator(page).fill(LOGIN_PASSWORD);
|
await emailLocator(page).fill('ทดสอบภาษาไทย');
|
||||||
const errorHint = page.getByText('ห้ามใส่ภาษาไทย');
|
await passwordLocator(page).fill(TEST_PASSWORD);
|
||||||
await expect(errorHint.first()).toBeVisible({ timeout: 12_000 });
|
|
||||||
|
await expect(page.getByText('ห้ามใส่ภาษาไทย').first()).toBeVisible({ timeout: TIMEOUT.ELEMENT });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Invalid Email Format (อีเมลผิดรูปแบบ)', async ({ page }) => {
|
test('Invalid Email Format (อีเมลผิดรูปแบบ)', async ({ page }) => {
|
||||||
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
|
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
|
||||||
await waitAppSettled(page);
|
await waitAppSettled(page);
|
||||||
await loginEmailLocator(page).fill('test@domain');
|
|
||||||
await loginPasswordLocator(page).fill(LOGIN_PASSWORD);
|
await emailLocator(page).fill('test@domain');
|
||||||
|
await passwordLocator(page).fill(TEST_PASSWORD);
|
||||||
await loginButtonLocator(page).click();
|
await loginButtonLocator(page).click();
|
||||||
await waitAppSettled(page);
|
await waitAppSettled(page);
|
||||||
const errorHint = page.getByText('กรุณากรอกอีเมลให้ถูกต้อง (you@example.com)');
|
|
||||||
await expect(errorHint.first()).toBeVisible({ timeout: 12_000 });
|
await expect(
|
||||||
|
page.getByText('กรุณากรอกอีเมลให้ถูกต้อง (you@example.com)').first()
|
||||||
|
).toBeVisible({ timeout: TIMEOUT.ELEMENT });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Wrong Password (รหัสผ่านผิด หรืออีเมลไม่ถูกต้องในระบบ)', async ({ page }) => {
|
test('Wrong Password (รหัสผ่านผิด หรืออีเมลไม่ถูกต้องในระบบ)', async ({ page }) => {
|
||||||
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
|
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
|
||||||
await waitAppSettled(page);
|
await waitAppSettled(page);
|
||||||
await loginEmailLocator(page).fill(LOGIN_EMAIL);
|
|
||||||
await loginPasswordLocator(page).fill('wrong-password-123');
|
await emailLocator(page).fill(TEST_EMAIL);
|
||||||
|
await passwordLocator(page).fill('wrong-password-123');
|
||||||
await loginButtonLocator(page).click();
|
await loginButtonLocator(page).click();
|
||||||
await waitAppSettled(page);
|
await waitAppSettled(page);
|
||||||
const errorHint = page.getByText('กรุณาเช็ค Email หรือ รหัสผ่านใหม่อีกครั้ง');
|
|
||||||
await expect(errorHint.first()).toBeVisible({ timeout: 12_000 });
|
await expect(
|
||||||
|
page.getByText('กรุณาเช็ค Email หรือ รหัสผ่านใหม่อีกครั้ง').first()
|
||||||
|
).toBeVisible({ timeout: TIMEOUT.ELEMENT });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -142,21 +126,22 @@ test.describe('ระบบยืนยันตัวตน (Authentication)',
|
||||||
test('หน้า Register ต้องโหลดได้ (Smoke)', async ({ page }) => {
|
test('หน้า Register ต้องโหลดได้ (Smoke)', async ({ page }) => {
|
||||||
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
|
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
|
||||||
await waitAppSettled(page);
|
await waitAppSettled(page);
|
||||||
await expect(regHeading(page)).toBeVisible({ timeout: 15_000 });
|
await expect(regHeading(page)).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||||
await expect(regSubmit(page)).toBeVisible({ timeout: 15_000 });
|
await expect(regSubmit(page)).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ลิงก์ "เข้าสู่ระบบ" ต้องกดแล้วไปหน้า login ได้', async ({ page }) => {
|
test('ลิงก์ "เข้าสู่ระบบ" ต้องกดแล้วไปหน้า login ได้', async ({ page }) => {
|
||||||
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
|
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
|
||||||
await waitAppSettled(page);
|
await waitAppSettled(page);
|
||||||
await regLoginLink(page).click();
|
await regLoginLink(page).click();
|
||||||
await page.waitForURL('**/auth/login', { timeout: 15_000 });
|
await page.waitForURL('**/auth/login', { timeout: TIMEOUT.PAGE_LOAD });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('สมัครสมาชิกสำเร็จ (Happy Path) → redirect ไป /auth/login', async ({ page }) => {
|
test('สมัครสมาชิกสำเร็จ (Happy Path) → redirect ไป /auth/login', async ({ page }) => {
|
||||||
const u = uniqueUser();
|
const u = uniqueUser();
|
||||||
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
|
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
|
||||||
await waitAppSettled(page);
|
await waitAppSettled(page);
|
||||||
|
|
||||||
await regUsername(page).fill(u.username);
|
await regUsername(page).fill(u.username);
|
||||||
await regEmail(page).fill(u.email);
|
await regEmail(page).fill(u.email);
|
||||||
await pickPrefix(page, 'นาย');
|
await pickPrefix(page, 'นาย');
|
||||||
|
|
@ -168,15 +153,18 @@ test.describe('ระบบยืนยันตัวตน (Authentication)',
|
||||||
await regSubmit(page).click();
|
await regSubmit(page).click();
|
||||||
await waitAppSettled(page);
|
await waitAppSettled(page);
|
||||||
|
|
||||||
const navToLogin = page.waitForURL('**/auth/login', { timeout: 25_000, waitUntil: 'domcontentloaded' }).then(() => 'login' as const).catch(() => null);
|
// รอ 3 สัญญาณ: redirect ไป login / success toast / error
|
||||||
const successToast = page.getByText(/สมัครสมาชิกสำเร็จ|success/i, { exact: false }).first().waitFor({ state: 'visible', timeout: 25_000 }).then(() => 'success' as const).catch(() => null);
|
const navToLogin = page.waitForURL('**/auth/login', { timeout: TIMEOUT.LOGIN, waitUntil: 'domcontentloaded' }).then(() => 'login' as const).catch(() => null);
|
||||||
const anyError = regErrorBox(page).first().waitFor({ state: 'visible', timeout: 25_000 }).then(() => 'error' as const).catch(() => null);
|
const successToast = page.getByText(/สมัครสมาชิกสำเร็จ|success/i, { exact: false }).first().waitFor({ state: 'visible', timeout: TIMEOUT.LOGIN }).then(() => 'success' as const).catch(() => null);
|
||||||
|
const anyError = regErrorBox(page).first().waitFor({ state: 'visible', timeout: TIMEOUT.LOGIN }).then(() => 'error' as const).catch(() => null);
|
||||||
|
|
||||||
const result = await Promise.race([navToLogin, successToast, anyError]);
|
const result = await Promise.race([navToLogin, successToast, anyError]);
|
||||||
if (result === 'error') {
|
if (result === 'error') {
|
||||||
throw new Error('Register errors visible');
|
const errs = await regErrorBox(page).allInnerTexts().catch(() => []);
|
||||||
|
throw new Error(`Register failed with errors: ${errs.join(' | ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ถ้ามี toast แต่ยัง redirect ไม่ไป ให้ navigate เอง
|
||||||
if (!page.url().includes('/auth/login')) {
|
if (!page.url().includes('/auth/login')) {
|
||||||
const hasSuccess = await page.getByText(/สมัครสมาชิกสำเร็จ/i, { exact: false }).first().isVisible().catch(() => false);
|
const hasSuccess = await page.getByText(/สมัครสมาชิกสำเร็จ/i, { exact: false }).first().isVisible().catch(() => false);
|
||||||
if (hasSuccess) {
|
if (hasSuccess) {
|
||||||
|
|
@ -185,24 +173,28 @@ test.describe('ระบบยืนยันตัวตน (Authentication)',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/auth\/login/i, { timeout: 15_000 });
|
await expect(page).toHaveURL(/\/auth\/login/i, { timeout: TIMEOUT.PAGE_LOAD });
|
||||||
await expect(page.getByRole('heading', { name: 'เข้าสู่ระบบ' })).toBeVisible({ timeout: 15_000 });
|
await expect(page.getByRole('heading', { name: 'เข้าสู่ระบบ' })).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||||
await expect(page.getByRole('button', { name: 'เข้าสู่ระบบ' })).toBeVisible({ timeout: 15_000 });
|
await expect(page.getByRole('button', { name: 'เข้าสู่ระบบ' })).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Invalid Email - ใส่ภาษาไทย ต้องขึ้น error', async ({ page }) => {
|
test('Invalid Email - ใส่ภาษาไทย ต้องขึ้น error', async ({ page }) => {
|
||||||
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
|
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
|
||||||
await waitAppSettled(page);
|
await waitAppSettled(page);
|
||||||
await regEmail(page).fill('ทดสอบภาษาไทย');
|
await regEmail(page).fill('ทดสอบภาษาไทย');
|
||||||
await regUsername(page).click();
|
await regUsername(page).click(); // blur trigger
|
||||||
const err = page.getByText(/ห้ามใส่ภาษาไทย|อีเมลไม่ถูกต้อง|รูปแบบอีเมล/i, { exact: false }).or(regErrorBox(page).filter({ hasText: /ไทย|อีเมล|email|invalid/i }));
|
|
||||||
await expect(err.first()).toBeVisible({ timeout: 12_000 });
|
const err = page
|
||||||
|
.getByText(/ห้ามใส่ภาษาไทย|อีเมลไม่ถูกต้อง|รูปแบบอีเมล/i, { exact: false })
|
||||||
|
.or(regErrorBox(page).filter({ hasText: /ไทย|อีเมล|email|invalid/i }));
|
||||||
|
await expect(err.first()).toBeVisible({ timeout: TIMEOUT.ELEMENT });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Password ไม่ตรงกัน ต้องขึ้น error (ต้องกดสร้างบัญชีก่อน)', async ({ page }) => {
|
test('Password ไม่ตรงกัน ต้องขึ้น error (ต้องกดสร้างบัญชีก่อน)', async ({ page }) => {
|
||||||
const u = uniqueUser();
|
const u = uniqueUser();
|
||||||
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
|
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
|
||||||
await waitAppSettled(page);
|
await waitAppSettled(page);
|
||||||
|
|
||||||
await regUsername(page).fill(u.username);
|
await regUsername(page).fill(u.username);
|
||||||
await regEmail(page).fill(u.email);
|
await regEmail(page).fill(u.email);
|
||||||
await pickPrefix(page, 'นาย');
|
await pickPrefix(page, 'นาย');
|
||||||
|
|
@ -210,11 +202,14 @@ test.describe('ระบบยืนยันตัวตน (Authentication)',
|
||||||
await regLastName(page).fill(u.lastName);
|
await regLastName(page).fill(u.lastName);
|
||||||
await regPhone(page).fill(u.phone);
|
await regPhone(page).fill(u.phone);
|
||||||
await regPassword(page).fill('Admin12345!');
|
await regPassword(page).fill('Admin12345!');
|
||||||
await regConfirmPassword(page).fill('Admin12345?');
|
await regConfirmPassword(page).fill('Admin12345?'); // mismatch
|
||||||
await regSubmit(page).click();
|
await regSubmit(page).click();
|
||||||
await waitAppSettled(page);
|
await waitAppSettled(page);
|
||||||
const mismatchErr = page.getByText(/รหัสผ่านไม่ตรงกัน/i, { exact: false }).or(regErrorBox(page).filter({ hasText: /รหัสผ่านไม่ตรงกัน/i }));
|
|
||||||
await expect(mismatchErr.first()).toBeVisible({ timeout: 12_000 });
|
const mismatchErr = page
|
||||||
|
.getByText(/รหัสผ่านไม่ตรงกัน/i, { exact: false })
|
||||||
|
.or(regErrorBox(page).filter({ hasText: /รหัสผ่านไม่ตรงกัน/i }));
|
||||||
|
await expect(mismatchErr.first()).toBeVisible({ timeout: TIMEOUT.ELEMENT });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -235,13 +230,12 @@ test.describe('ระบบยืนยันตัวตน (Authentication)',
|
||||||
test('Validation: ใส่อีเมลภาษาไทยแล้วขึ้น Error', async ({ page }) => {
|
test('Validation: ใส่อีเมลภาษาไทยแล้วขึ้น Error', async ({ page }) => {
|
||||||
await forgotEmail(page).fill('ฟฟฟฟ');
|
await forgotEmail(page).fill('ฟฟฟฟ');
|
||||||
await page.getByRole('heading', { name: /ลืมรหัสผ่าน/i }).click();
|
await page.getByRole('heading', { name: /ลืมรหัสผ่าน/i }).click();
|
||||||
const err = page.getByText(/ห้ามใส่ภาษาไทย/i).first();
|
await expect(page.getByText(/ห้ามใส่ภาษาไทย/i).first()).toBeVisible({ timeout: TIMEOUT.ELEMENT });
|
||||||
await expect(err).toBeVisible({ timeout: 10_000 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('กดลิงก์กลับไปหน้า Login ได้', async ({ page }) => {
|
test('กดลิงก์กลับไปหน้า Login ได้', async ({ page }) => {
|
||||||
await forgotBackLink(page).click();
|
await forgotBackLink(page).click();
|
||||||
await page.waitForURL('**/auth/login', { timeout: 10_000 });
|
await page.waitForURL('**/auth/login', { timeout: TIMEOUT.ELEMENT });
|
||||||
await expect(page).toHaveURL(/\/auth\/login/i);
|
await expect(page).toHaveURL(/\/auth\/login/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -257,9 +251,10 @@ test.describe('ระบบยืนยันตัวตน (Authentication)',
|
||||||
}
|
}
|
||||||
await route.continue();
|
await route.continue();
|
||||||
});
|
});
|
||||||
|
|
||||||
await forgotEmail(page).fill('test@gmail.com');
|
await forgotEmail(page).fill('test@gmail.com');
|
||||||
await forgotSubmit(page).click();
|
await forgotSubmit(page).click();
|
||||||
await expect(page.getByText(/ส่งลิงก์เรียบร้อยแล้ว/i, { exact: false })).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByText(/ส่งลิงก์เรียบร้อยแล้ว/i, { exact: false })).toBeVisible({ timeout: TIMEOUT.ELEMENT });
|
||||||
await expect(page.getByText(/กรุณาตรวจสอบกล่องจดหมาย/i, { exact: false })).toBeVisible();
|
await expect(page.getByText(/กรุณาตรวจสอบกล่องจดหมาย/i, { exact: false })).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,95 +1,175 @@
|
||||||
|
/**
|
||||||
|
* @file classroom.spec.ts
|
||||||
|
* @description ทดสอบระบบห้องเรียนออนไลน์ และระบบแบบทดสอบ
|
||||||
|
* (Classroom, Learning & Quiz System)
|
||||||
|
*
|
||||||
|
* รวม 2 module:
|
||||||
|
* - Classroom & Learning (Layout, Access Control, Video/Quiz area)
|
||||||
|
* - Quiz System (Start Screen, Pagination, Submit & Navigation)
|
||||||
|
*/
|
||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { BASE_URL, TIMEOUT, waitAppSettled, setupLogin } from './helpers';
|
||||||
|
|
||||||
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
// ==========================================
|
||||||
|
// Mock: ข้อมูล Quiz สำหรับ test
|
||||||
|
// ==========================================
|
||||||
|
async function mockQuizData(page: any) {
|
||||||
|
await page.route('**/lessons/*', async (route: any) => {
|
||||||
|
const mockQuestions = Array.from({ length: 15 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
question: { th: `คำถามข้อที่ ${i + 1}?`, en: `Question ${i + 1}?` },
|
||||||
|
text: { th: `คำถามข้อที่ ${i + 1}?`, en: `Question ${i + 1}?` },
|
||||||
|
choices: [
|
||||||
|
{ id: i * 10 + 1, text: { th: 'ก', en: 'A' } },
|
||||||
|
{ id: i * 10 + 2, text: { th: 'ข', en: 'B' } },
|
||||||
|
{ id: i * 10 + 3, text: { th: 'ค', en: 'C' } }
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
|
||||||
async function waitAppSettled(page: any) {
|
await route.fulfill({
|
||||||
await page.waitForLoadState('domcontentloaded');
|
status: 200,
|
||||||
await page.waitForLoadState('networkidle').catch(() => {});
|
contentType: 'application/json',
|
||||||
await page.waitForTimeout(200);
|
body: JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
id: 17,
|
||||||
|
type: 'QUIZ',
|
||||||
|
quiz: {
|
||||||
|
id: 99,
|
||||||
|
title: { th: 'แบบทดสอบปลายภาค (Mock)', en: 'Final Exam (Mock)' },
|
||||||
|
time_limit: 30,
|
||||||
|
questions: mockQuestions
|
||||||
|
}
|
||||||
|
},
|
||||||
|
progress: {}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ฟังก์ชันจำลองล็อกอิน
|
// ==========================================
|
||||||
async function setupLogin(page: any) {
|
// Tests
|
||||||
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
|
// ==========================================
|
||||||
await waitAppSettled(page);
|
test.describe('ระบบห้องเรียนออนไลน์และแบบทดสอบ (Classroom & Quiz)', () => {
|
||||||
|
|
||||||
await page.locator('input[type="email"]').or(page.getByRole('textbox', { name: /อีเมล|email/i })).first().fill('studentedtest@example.com');
|
|
||||||
await page.locator('input[type="password"]').or(page.getByRole('textbox', { name: /รหัสผ่าน|password/i })).first().fill('admin123');
|
|
||||||
await page.getByRole('button', { name: /เข้าสู่ระบบ|login/i }).or(page.locator('button[type="submit"]')).first().click();
|
|
||||||
|
|
||||||
await page.waitForURL('**/dashboard', { timeout: 15_000 }).catch(() => {});
|
|
||||||
await waitAppSettled(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('ระบบห้องเรียนออนไลน์ (Classroom & Learning)', () => {
|
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await setupLogin(page);
|
await setupLogin(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('6.1 เข้าห้องเรียนหลัก (Classroom Basic Layout)', async ({ page }) => {
|
// --------------------------------------------------
|
||||||
// สมมติว่ามี Course ID: 1 ทดสอบแบบเปิดหน้าตรงๆ
|
// Section 1: ห้องเรียน (Classroom & Learning)
|
||||||
await page.goto(`${BASE_URL}/classroom/learning?course_id=1`);
|
// --------------------------------------------------
|
||||||
|
test.describe('ห้องเรียน (Classroom Layout & Access)', () => {
|
||||||
// 1. โครงร่างของหน้า (Top Bar) ควรมีปุ่มกลับ กับไอคอนแผงด้านข้าง
|
|
||||||
const backBtn = page.getByRole('button').filter({ has: page.locator('i.q-icon', { hasText: 'arrow_back' }) }).first();
|
|
||||||
await expect(backBtn).toBeVisible({ timeout: 15_000 });
|
|
||||||
|
|
||||||
const menuCurriculumBtn = page.getByRole('button').filter({ has: page.locator('i.q-icon', { hasText: 'menu_open' }) }).first();
|
test('6.1 เข้าห้องเรียนหลัก (Classroom Basic Layout)', async ({ page }) => {
|
||||||
await expect(menuCurriculumBtn).toBeVisible({ timeout: 15_000 });
|
await page.goto(`${BASE_URL}/classroom/learning?course_id=1`);
|
||||||
|
|
||||||
// 2. เช็คว่ามีพื้นที่ Sidebar หลักสูตร (CurriculumSidebar Component) โผล่ขึ้นมาหรือมีอยู่ใน DOM
|
// 1. โครงร่างของหน้า — ปุ่มกลับ + ไอคอนแผงด้านข้าง
|
||||||
const sidebar = page.locator('.q-drawer').first();
|
const backBtn = page.getByRole('button').filter({ has: page.locator('i.q-icon', { hasText: 'arrow_back' }) }).first();
|
||||||
if (!await sidebar.isVisible()) {
|
await expect(backBtn).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||||
await menuCurriculumBtn.click();
|
|
||||||
}
|
|
||||||
await expect(sidebar).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('6.2 เช็คสถานะการเข้าถึงเนื้อหา (Access Control)', async ({ page }) => {
|
const menuCurriculumBtn = page.getByRole('button').filter({ has: page.locator('i.q-icon', { hasText: 'menu_open' }) }).first();
|
||||||
// ลองสุ่ม Course ID สูงๆ ที่อาจจะไม่อนุญาตให้เรียน (ไม่มีสิทธิ์) ควรรองรับกล่องแจ้งเตือนด้วย Alert ของระบบ
|
await expect(menuCurriculumBtn).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||||
// ใน learning.vue จะมีการสั่ง `alert(msg)` แต่อาจจะต้องพึ่งกลไก Intercepter
|
|
||||||
|
// 2. Sidebar หลักสูตร
|
||||||
page.on('dialog', async dialog => {
|
const sidebar = page.locator('.q-drawer').first();
|
||||||
// หน้าต่าง Alert ถ้ามีสิทธิ์ไม่อนุญาตมันจะเด้งอันนี้
|
if (!await sidebar.isVisible()) {
|
||||||
expect(dialog.message()).toBeTruthy();
|
await menuCurriculumBtn.click();
|
||||||
await dialog.accept();
|
}
|
||||||
|
await expect(sidebar).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto(`${BASE_URL}/classroom/learning?course_id=99999`);
|
test('6.2 เช็คสถานะการเข้าถึงเนื้อหา (Access Control)', async ({ page }) => {
|
||||||
|
page.on('dialog', async dialog => {
|
||||||
// รอดู Loading หายไป
|
expect(dialog.message()).toBeTruthy();
|
||||||
const loadingMask = page.locator('.animate-pulse, .q-spinner');
|
await dialog.accept();
|
||||||
await loadingMask.first().waitFor({ state: 'hidden', timeout: 20_000 }).catch(() => {});
|
});
|
||||||
|
|
||||||
|
await page.goto(`${BASE_URL}/classroom/learning?course_id=99999`);
|
||||||
|
|
||||||
|
const loadingMask = page.locator('.animate-pulse, .q-spinner');
|
||||||
|
await loadingMask.first().waitFor({ state: 'hidden', timeout: TIMEOUT.PAGE_LOAD }).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('6.3 การแสดงผลช่องวิดีโอ หรือ พื้นที่ทำข้อสอบ (Video / Quiz)', async ({ page }) => {
|
||||||
|
await page.goto(`${BASE_URL}/classroom/learning?course_id=1`);
|
||||||
|
|
||||||
|
const videoLocator = page.locator('video').first();
|
||||||
|
const quizLocator = page.getByText(/เริ่มทำแบบทดสอบ|แบบทดสอบ/i).first();
|
||||||
|
const errorLocator = page.getByText(/ไม่สามารถเข้าถึง/i).first();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
videoLocator.waitFor({ state: 'visible', timeout: TIMEOUT.PAGE_LOAD }),
|
||||||
|
quizLocator.waitFor({ state: 'visible', timeout: TIMEOUT.PAGE_LOAD }),
|
||||||
|
errorLocator.waitFor({ state: 'visible', timeout: TIMEOUT.PAGE_LOAD })
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isOkay = (await videoLocator.isVisible()) || (await quizLocator.isVisible()) || (await errorLocator.isVisible());
|
||||||
|
expect(isOkay).toBeTruthy();
|
||||||
|
} catch {
|
||||||
|
await page.screenshot({ path: 'tests/e2e/screenshots/classroom-blank-state.png', fullPage: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('6.3 การแสดงผลช่องวิดีโอ (Video Player) หรือ พื้นที่ทำข้อสอบ (Quiz)', async ({ page }) => {
|
// --------------------------------------------------
|
||||||
// เข้าหน้าห้องเรียน Course id: 1
|
// Section 2: แบบทดสอบ (Quiz System)
|
||||||
await page.goto(`${BASE_URL}/classroom/learning?course_id=1`);
|
// --------------------------------------------------
|
||||||
|
test.describe('แบบทดสอบ (Quiz System)', () => {
|
||||||
// กรณีที่ 1: อาจแสดง Video ถ้าเป็นบทเรียนวิดีโอ
|
|
||||||
const videoLocator = page.locator('video').first();
|
|
||||||
|
|
||||||
// กรณีที่ 2: ถ้าบทแรกเป็น Quiz จะแสดงไอคอนแบบทดสอบ
|
|
||||||
const quizLocator = page.getByText(/เริ่มทำแบบทดสอบ|แบบทดสอบ/i).first();
|
|
||||||
|
|
||||||
// กรณีที่ 3: ไม่มีบทเรียนเนื้อหาใดๆ เลยให้แสดง
|
|
||||||
const errorLocator = page.getByText(/ไม่สามารถเข้าถึง/i).first();
|
|
||||||
|
|
||||||
try {
|
test('7.1 โหลดหน้า Quiz และเริ่มทำข้อสอบได้ (Start Screen)', async ({ page }) => {
|
||||||
await Promise.race([
|
await mockQuizData(page);
|
||||||
videoLocator.waitFor({ state: 'visible', timeout: 20_000 }),
|
await page.goto(`${BASE_URL}/classroom/quiz?course_id=2&lesson_id=17`);
|
||||||
quizLocator.waitFor({ state: 'visible', timeout: 20_000 }),
|
|
||||||
errorLocator.waitFor({ state: 'visible', timeout: 20_000 })
|
const startBtn = page.getByRole('button', { name: /เริ่มทำแบบทดสอบ|Start/i }).first();
|
||||||
]);
|
await expect(startBtn).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||||
|
|
||||||
const isOkay = (await videoLocator.isVisible()) || (await quizLocator.isVisible()) || (await errorLocator.isVisible());
|
// กดเริ่มทำ
|
||||||
expect(isOkay).toBeTruthy();
|
await startBtn.click();
|
||||||
} catch {
|
|
||||||
// ถ้าไม่มีเลยใน 20 วิ ถือว่าหน้าอาจจะล้มเหลว หรือเป็น Content เปล่า
|
// เช็คว่าหน้า Taking (คำถามข้อที่ 1) โผล่มา
|
||||||
// ให้ลอง Capture เพื่อเก็บข้อมูลไปใช้งาน
|
const questionText = page.locator('h3').first();
|
||||||
await page.screenshot({ path: 'tests/e2e/screenshots/classroom-blank-state.png', fullPage: true });
|
await expect(questionText).toBeVisible({ timeout: TIMEOUT.ELEMENT });
|
||||||
}
|
});
|
||||||
|
|
||||||
|
test('7.2 แถบข้อสอบแบ่งหน้า (Pagination — เลื่อนซ้าย/ขวา)', async ({ page }) => {
|
||||||
|
await mockQuizData(page);
|
||||||
|
await page.goto(`${BASE_URL}/classroom/quiz?course_id=2&lesson_id=17`);
|
||||||
|
|
||||||
|
const startBtn = page.getByRole('button', { name: /เริ่มทำแบบทดสอบ|Start/i }).first();
|
||||||
|
await expect(startBtn).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||||
|
await startBtn.click();
|
||||||
|
|
||||||
|
// ลูกศรเลื่อนหน้าข้อสอบ
|
||||||
|
const nextPaginationPageBtn = page.locator('button').filter({ has: page.locator('i.q-icon:has-text("chevron_right")') }).first();
|
||||||
|
|
||||||
|
if (await nextPaginationPageBtn.isVisible()) {
|
||||||
|
await expect(nextPaginationPageBtn).toBeEnabled();
|
||||||
|
await nextPaginationPageBtn.click();
|
||||||
|
|
||||||
|
// ข้อที่ 11 ต้องแสดง
|
||||||
|
const question11Btn = page.locator('button').filter({ hasText: /^11$/ }).first();
|
||||||
|
await expect(question11Btn).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('7.3 การแสดงผลปุ่มถัดไป/ส่งคำตอบ (Submit & Navigation UI)', async ({ page }) => {
|
||||||
|
await mockQuizData(page);
|
||||||
|
await page.goto(`${BASE_URL}/classroom/quiz?course_id=2&lesson_id=17`);
|
||||||
|
|
||||||
|
const startBtn = page.getByRole('button', { name: /เริ่มทำแบบทดสอบ|Start/i }).first();
|
||||||
|
await expect(startBtn).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||||
|
await startBtn.click();
|
||||||
|
|
||||||
|
// รอคำถามโหลดเสร็จ
|
||||||
|
await expect(page.locator('h3').first()).toBeVisible({ timeout: TIMEOUT.ELEMENT });
|
||||||
|
|
||||||
|
const submitBtn = page.locator('button').filter({ hasText: /(ส่งคำตอบ|Submit)/i }).first();
|
||||||
|
const nextBtn = page.locator('button').filter({ hasText: /(ถัดไป|Next)/i }).first();
|
||||||
|
|
||||||
|
// ข้อแรกต้องมีปุ่มถัดไปหรือปุ่มส่ง
|
||||||
|
await expect(submitBtn.or(nextBtn)).toBeVisible({ timeout: TIMEOUT.ELEMENT });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,91 +1,103 @@
|
||||||
|
/**
|
||||||
|
* @file discovery.spec.ts
|
||||||
|
* @description ทดสอบหมวดหน้าค้นหาคอร์สและผลลัพธ์ (Discovery & Browse)
|
||||||
|
*/
|
||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { BASE_URL, TIMEOUT, waitAppSettled } from './helpers';
|
||||||
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
|
||||||
|
|
||||||
test.describe('หมวดหน้าค้นหาคอร์สและผลลัพธ์ (Discovery & Browse)', () => {
|
test.describe('หมวดหน้าค้นหาคอร์สและผลลัพธ์ (Discovery & Browse)', () => {
|
||||||
|
|
||||||
test.describe('ส่วนหน้าแรก (Home)', () => {
|
test.describe('ส่วนหน้าแรก (Home)', () => {
|
||||||
test('โหลดหน้าแรก และตรวจสอบแสดงผลครบถ้วน (Hero, Cards, Categories)', async ({ page }) => {
|
test('โหลดหน้าแรก และตรวจสอบแสดงผลครบถ้วน (Hero, Cards, Categories)', async ({ page }) => {
|
||||||
await page.goto(BASE_URL);
|
await page.goto(BASE_URL);
|
||||||
|
await waitAppSettled(page);
|
||||||
|
|
||||||
const heroTitle = page.locator('h1, h2, .hero-title').first();
|
const heroTitle = page.locator('h1, h2, .hero-title').first();
|
||||||
await expect(heroTitle).toBeVisible({ timeout: 15_000 });
|
await expect(heroTitle).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||||
|
|
||||||
const ctaButton = page.locator('a[href="/browse"]').first();
|
const ctaButton = page.locator('a[href="/browse"]').first();
|
||||||
if (await ctaButton.isVisible()) {
|
if (await ctaButton.isVisible()) {
|
||||||
await expect(ctaButton).toBeVisible();
|
await expect(ctaButton).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
const courseSectionHeading = page.getByText(/คอร์สออนไลน์|Online Courses/i).first();
|
const courseSectionHeading = page.getByText(/คอร์สออนไลน์|Online Courses/i).first();
|
||||||
await expect(courseSectionHeading).toBeVisible({ timeout: 10_000 });
|
await expect(courseSectionHeading).toBeVisible({ timeout: TIMEOUT.ELEMENT });
|
||||||
|
|
||||||
const allCategoryBtn = page.getByRole('button', { name: /ทั้งหมด|All/i }).first();
|
const allCategoryBtn = page.getByRole('button', { name: /ทั้งหมด|All/i }).first();
|
||||||
await expect(allCategoryBtn).toBeVisible();
|
await expect(allCategoryBtn).toBeVisible();
|
||||||
|
|
||||||
const courseCards = page.locator('div.cursor-pointer').filter({ has: page.locator('img') });
|
const courseCards = page.locator('div.cursor-pointer').filter({ has: page.locator('img') });
|
||||||
await expect(courseCards.first()).toBeVisible({ timeout: 15_000 });
|
await expect(courseCards.first()).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||||
expect(await courseCards.count()).toBeGreaterThan(0);
|
expect(await courseCards.count()).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'tests/e2e/screenshots/discovery-home.png', fullPage: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('ส่วนค้นหาและแคตตาล็อก (Browse)', () => {
|
test.describe('ส่วนค้นหาและแคตตาล็อก (Browse)', () => {
|
||||||
test('ค้นหาหลักสูตร (Search Course)', async ({ page }) => {
|
test('ค้นหาหลักสูตร (Search Course)', async ({ page }) => {
|
||||||
await page.goto(`${BASE_URL}/browse`);
|
await page.goto(`${BASE_URL}/browse`);
|
||||||
const searchInput = page.locator('input[placeholder="ค้นหาคอร์ส..."]').first();
|
await waitAppSettled(page);
|
||||||
await searchInput.fill('การเขียนโปรแกรม');
|
|
||||||
await searchInput.press('Enter');
|
|
||||||
|
|
||||||
// ในหน้า browse จะใช้ <NuxtLink> ซึ่ง render เป็น tag <a>
|
const searchInput = page.locator('input[placeholder="ค้นหาคอร์ส..."]').first();
|
||||||
|
await expect(searchInput).toBeVisible({ timeout: TIMEOUT.ELEMENT });
|
||||||
|
await searchInput.fill('Python');
|
||||||
|
await searchInput.press('Enter');
|
||||||
|
await waitAppSettled(page);
|
||||||
|
|
||||||
|
// ต้องเจออย่างใดอย่างหนึ่ง: ผลลัพธ์คอร์ส หรือ empty state
|
||||||
const searchResults = page.locator('a[href*="/course/"]').filter({ has: page.locator('img') }).first();
|
const searchResults = page.locator('a[href*="/course/"]').filter({ has: page.locator('img') }).first();
|
||||||
await expect(searchResults).toBeVisible({ timeout: 15_000 });
|
const emptyState = page.getByText(/ไม่พบ|ไม่เจอ|No result|not found/i).first()
|
||||||
|
.or(page.locator('i.q-icon').filter({ hasText: 'search_off' }).first());
|
||||||
|
|
||||||
|
await expect(searchResults.or(emptyState)).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'tests/e2e/screenshots/discovery-search.png', fullPage: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ตัวกรองหมวดหมู่คอร์ส (Category Filter)', async ({ page }) => {
|
test('ตัวกรองหมวดหมู่คอร์ส (Category Filter)', async ({ page }) => {
|
||||||
await page.goto(`${BASE_URL}/browse`);
|
await page.goto(`${BASE_URL}/browse`);
|
||||||
|
await waitAppSettled(page);
|
||||||
|
|
||||||
const categoryButton = page.locator('button').filter({ hasText: 'การออกแบบ' }).first();
|
const categoryButton = page.locator('button').filter({ hasText: 'การออกแบบ' }).first();
|
||||||
|
|
||||||
if (await categoryButton.isVisible()) {
|
if (await categoryButton.isVisible()) {
|
||||||
await categoryButton.click();
|
await categoryButton.click();
|
||||||
// ในหน้า browse จะใช้ <NuxtLink> ซึ่ง render เป็น tag <a>
|
|
||||||
const courseCard = page.locator('a[href*="/course/"]').filter({ has: page.locator('img') }).first();
|
const courseCard = page.locator('a[href*="/course/"]').filter({ has: page.locator('img') }).first();
|
||||||
await expect(courseCard).toBeVisible({ timeout: 15_000 });
|
await expect(courseCard).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'tests/e2e/screenshots/discovery-filter.png', fullPage: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('หน้ารายละเอียดคอร์ส (Course Detail)', () => {
|
test.describe('หน้ารายละเอียดคอร์ส (Course Detail)', () => {
|
||||||
test('โหลดเนื้อหาวิชา (Curriculum) แถบรายละเอียดขึ้นปกติ', async ({ page }) => {
|
test('โหลดเนื้อหาวิชา (Curriculum) แถบรายละเอียดขึ้นปกติ', async ({ page }) => {
|
||||||
await page.goto(`${BASE_URL}`);
|
await page.goto(`${BASE_URL}/course/1`);
|
||||||
const courseCard = page.locator('div.cursor-pointer').filter({ has: page.locator('img') }).first();
|
await waitAppSettled(page);
|
||||||
await expect(courseCard).toBeVisible({ timeout: 10_000 });
|
|
||||||
// Get URL from navigating when clicking the div or finding another link. Since it's a div, we cannot easily get href.
|
|
||||||
// So let's click it or fallback to /course/1
|
|
||||||
const targetUrl = '/course/1';
|
|
||||||
|
|
||||||
await page.goto(`${BASE_URL}${targetUrl}`);
|
|
||||||
|
|
||||||
const courseTitle = page.locator('h1').first();
|
const courseTitle = page.locator('h1').first();
|
||||||
await expect(courseTitle).toBeVisible({ timeout: 15_000 });
|
await expect(courseTitle).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||||
|
|
||||||
const curriculumTab = page.getByRole('tab', { name: /เนื้อหาวิชา|ส่วนหลักสูตร|Curriculum/i }).first();
|
const curriculumTab = page.getByRole('tab', { name: /เนื้อหาวิชา|ส่วนหลักสูตร|Curriculum/i }).first();
|
||||||
if (await curriculumTab.isVisible()) {
|
if (await curriculumTab.isVisible()) {
|
||||||
await curriculumTab.click();
|
await curriculumTab.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
const lessonItems = page.locator('.q-expansion-item, .lesson-item, [role="listitem"]');
|
const lessonItems = page.locator('.q-expansion-item, .lesson-item, [role="listitem"]');
|
||||||
await expect(lessonItems.first()).toBeVisible().catch(() => {});
|
await expect(lessonItems.first()).toBeVisible().catch(() => {});
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'tests/e2e/screenshots/discovery-curriculum.png', fullPage: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('การแสดงผลปุ่ม เข้าเรียน/ลงทะเบียน (Enroll / Start Learning)', async ({ page }) => {
|
test('การแสดงผลปุ่ม เข้าเรียน/ลงทะเบียน (Enroll / Start Learning)', async ({ page }) => {
|
||||||
await page.goto(`${BASE_URL}`);
|
await page.goto(`${BASE_URL}/course/1`);
|
||||||
const courseCard = page.locator('div.cursor-pointer').filter({ has: page.locator('img') }).first();
|
await waitAppSettled(page);
|
||||||
await expect(courseCard).toBeVisible({ timeout: 10_000 });
|
|
||||||
const targetUrl = '/course/1';
|
|
||||||
|
|
||||||
await page.goto(`${BASE_URL}${targetUrl}`);
|
|
||||||
await page.waitForLoadState('networkidle').catch(() => {});
|
|
||||||
|
|
||||||
const enrollStartBtn = page.locator('button').filter({ hasText: /(เข้าสู่ระบบ|ลงทะเบียน|เข้าเรียน|เรียนต่อ|ซื้อ|ฟรี|Enroll|Start|Buy|Login)/i }).first();
|
const enrollStartBtn = page.locator('button').filter({ hasText: /(เข้าสู่ระบบ|ลงทะเบียน|เข้าเรียน|เรียนต่อ|ซื้อ|ฟรี|Enroll|Start|Buy|Login)/i }).first();
|
||||||
await expect(enrollStartBtn).toBeVisible({ timeout: 10_000 });
|
await expect(enrollStartBtn).toBeVisible({ timeout: TIMEOUT.ELEMENT });
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'tests/e2e/screenshots/discovery-enroll-btn.png', fullPage: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
import { test, expect, type Page } from '@playwright/test';
|
|
||||||
|
|
||||||
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
|
||||||
|
|
||||||
// ✅ หน้าจริงคือ /auth/forgot-password (อ้างอิงจากรูป)
|
|
||||||
const FORGOT_URL = `${BASE_URL}/auth/forgot-password`;
|
|
||||||
|
|
||||||
async function waitAppSettled(page: Page) {
|
|
||||||
await page.waitForLoadState('domcontentloaded');
|
|
||||||
await page.waitForLoadState('networkidle').catch(() => {});
|
|
||||||
await page.waitForTimeout(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
function emailInput(page: Page) {
|
|
||||||
// เผื่อบางที input ไม่ได้ type=email แต่เป็น textbox ธรรมดา
|
|
||||||
return page.locator('input[type="email"]').or(page.getByRole('textbox')).first();
|
|
||||||
}
|
|
||||||
|
|
||||||
function submitBtn(page: Page) {
|
|
||||||
// ปุ่มในรูปเป็น “ส่งลิงก์รีเซ็ต”
|
|
||||||
return page.getByRole('button', { name: /ส่งลิงก์รีเซ็ต/i }).first();
|
|
||||||
}
|
|
||||||
|
|
||||||
function backToLoginLink(page: Page) {
|
|
||||||
// ในรูปเป็นลิงก์ “กลับไปหน้าเข้าสู่ระบบ”
|
|
||||||
return page.getByRole('link', { name: /กลับไปหน้าเข้าสู่ระบบ/i }).first();
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('หน้าลืมรหัสผ่าน (Forgot Password)', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await page.goto(FORGOT_URL, { waitUntil: 'domcontentloaded' });
|
|
||||||
await waitAppSettled(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('3.1 โหลดหน้าลืมรหัสผ่านได้ครบถ้วน (Smoke Test)', async ({ page }) => {
|
|
||||||
await expect(page.getByRole('heading', { name: /ลืมรหัสผ่าน/i })).toBeVisible();
|
|
||||||
await expect(emailInput(page)).toBeVisible();
|
|
||||||
await expect(submitBtn(page)).toBeVisible();
|
|
||||||
await expect(backToLoginLink(page)).toBeVisible();
|
|
||||||
|
|
||||||
await page.screenshot({ path: 'tests/e2e/screenshots/forgot-01-smoke.png', fullPage: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('3.2 Validation: ใส่อีเมลภาษาไทยแล้วขึ้น Error', async ({ page }) => {
|
|
||||||
await emailInput(page).fill('ฟฟฟฟ');
|
|
||||||
|
|
||||||
// trigger blur
|
|
||||||
await page.getByRole('heading', { name: /ลืมรหัสผ่าน/i }).click();
|
|
||||||
|
|
||||||
// ข้อความจริงในระบบ “ห้ามใส่ภาษาไทย”
|
|
||||||
const err = page.getByText(/ห้ามใส่ภาษาไทย/i).first();
|
|
||||||
await expect(err).toBeVisible({ timeout: 10_000 });
|
|
||||||
|
|
||||||
await page.screenshot({ path: 'tests/e2e/screenshots/forgot-02-thai-email.png', fullPage: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('3.3 กดลิงก์กลับไปหน้า Login ได้', async ({ page }) => {
|
|
||||||
await backToLoginLink(page).click();
|
|
||||||
await page.waitForURL('**/auth/login', { timeout: 10_000 });
|
|
||||||
await expect(page).toHaveURL(/\/auth\/login/i);
|
|
||||||
|
|
||||||
await page.screenshot({ path: 'tests/e2e/screenshots/forgot-03-back-login.png', fullPage: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('3.4 ทดลองส่งลิงก์รีเซ็ตรหัสผ่าน (API Mock)', async ({ page }) => {
|
|
||||||
// ✅ ดัก request แบบกว้างขึ้น: POST ที่ URL มี forgot/reset
|
|
||||||
await page.route('**/*', async (route) => {
|
|
||||||
const req = route.request();
|
|
||||||
const url = req.url();
|
|
||||||
const method = req.method();
|
|
||||||
|
|
||||||
const looksLikeForgotApi =
|
|
||||||
method === 'POST' &&
|
|
||||||
/forgot|reset/i.test(url) &&
|
|
||||||
// กันไม่ให้ไป intercept asset
|
|
||||||
!/\.(png|jpg|jpeg|webp|svg|css|js|map)$/i.test(url);
|
|
||||||
|
|
||||||
if (looksLikeForgotApi) {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({ success: true, data: { message: 'Reset link sent' } }),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await route.continue();
|
|
||||||
});
|
|
||||||
|
|
||||||
await emailInput(page).fill('test@gmail.com');
|
|
||||||
await submitBtn(page).click();
|
|
||||||
|
|
||||||
// ✅ ตรวจหน้าสำเร็จตามที่คุณคาดหวัง
|
|
||||||
await expect(page.getByText(/ส่งลิงก์เรียบร้อยแล้ว/i, { exact: false })).toBeVisible({ timeout: 10_000 });
|
|
||||||
await expect(page.getByText(/กรุณาตรวจสอบกล่องจดหมาย/i, { exact: false })).toBeVisible();
|
|
||||||
|
|
||||||
// ปุ่ม “ส่งอีกครั้ง” (ถ้ามี)
|
|
||||||
await expect(page.getByRole('button', { name: /ส่งอีกครั้ง/i })).toBeVisible({ timeout: 10_000 }).catch(() => {});
|
|
||||||
|
|
||||||
await page.screenshot({ path: 'tests/e2e/screenshots/forgot-04-mock-success.png', fullPage: true });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
129
Frontend-Learner/tests/e2e/helpers.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
/**
|
||||||
|
* @file helpers.ts
|
||||||
|
* @description Shared E2E test helpers — ฟังก์ชันที่ใช้ร่วมกันในทุกไฟล์ test
|
||||||
|
* รวม: waitAppSettled, login helpers, common locators, constants
|
||||||
|
*/
|
||||||
|
import { type Page, type Locator, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Constants
|
||||||
|
// ==========================================
|
||||||
|
export const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
||||||
|
export const TEST_EMAIL = 'studentedtest@example.com';
|
||||||
|
export const TEST_PASSWORD = 'admin123';
|
||||||
|
|
||||||
|
/** Timeout configs — ปรับค่าได้ที่เดียว */
|
||||||
|
export const TIMEOUT: Record<string, number> = {
|
||||||
|
/** รอหน้าโหลด */
|
||||||
|
PAGE_LOAD: 15_000,
|
||||||
|
/** รอ login + redirect */
|
||||||
|
LOGIN: 25_000,
|
||||||
|
/** รอ element แสดงผล */
|
||||||
|
ELEMENT: 12_000,
|
||||||
|
/** รอ network settle */
|
||||||
|
SETTLE: 300,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Wait Helpers
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* รอให้แอปโหลดเสร็จสมบูรณ์ (DOM + Network + hydration)
|
||||||
|
*/
|
||||||
|
export async function waitAppSettled(page: Page, ms = TIMEOUT.SETTLE) {
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForLoadState('networkidle').catch(() => {});
|
||||||
|
await page.waitForTimeout(ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* รอจนกว่า locator ใดก็ได้ใน array จะ visible
|
||||||
|
* @throws เมื่อไม่มี locator ไหน visible ภายใน timeout
|
||||||
|
*/
|
||||||
|
export async function expectAnyVisible(
|
||||||
|
page: Page,
|
||||||
|
locators: Locator[],
|
||||||
|
timeout = TIMEOUT.PAGE_LOAD
|
||||||
|
) {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeout) {
|
||||||
|
for (const loc of locators) {
|
||||||
|
try {
|
||||||
|
if (await loc.isVisible()) return;
|
||||||
|
} catch { /* locator detached / stale — ลองใหม่ */ }
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`None of the expected locators became visible within ${timeout}ms`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Login Locators
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
export function emailLocator(page: Page): Locator {
|
||||||
|
return page
|
||||||
|
.locator('input[type="email"]')
|
||||||
|
.or(page.getByRole('textbox', { name: /อีเมล|email/i }))
|
||||||
|
.first();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function passwordLocator(page: Page): Locator {
|
||||||
|
return page
|
||||||
|
.locator('input[type="password"]')
|
||||||
|
.or(page.getByRole('textbox', { name: /รหัสผ่าน|password/i }))
|
||||||
|
.first();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loginButtonLocator(page: Page): Locator {
|
||||||
|
return page
|
||||||
|
.getByRole('button', { name: /เข้าสู่ระบบ|login/i })
|
||||||
|
.or(page.locator('button[type="submit"]'))
|
||||||
|
.first();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Login Flow
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ล็อกอินด้วย test account — ใช้ใน beforeEach ของ tests ที่ต้อง authenticate
|
||||||
|
*
|
||||||
|
* @param page — Playwright Page
|
||||||
|
* @param opts — ตัวเลือกเสริม
|
||||||
|
* @param opts.assertDashboard — (default: true) ถ้า true จะ assert ว่าเข้า dashboard สำเร็จ
|
||||||
|
*
|
||||||
|
* @throws หาก login ล้มเหลวหรือไม่ถึง dashboard
|
||||||
|
*/
|
||||||
|
export async function setupLogin(
|
||||||
|
page: Page,
|
||||||
|
opts: { assertDashboard?: boolean } = {}
|
||||||
|
) {
|
||||||
|
const { assertDashboard = true } = opts;
|
||||||
|
|
||||||
|
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await waitAppSettled(page);
|
||||||
|
|
||||||
|
// กรอกข้อมูล
|
||||||
|
await emailLocator(page).fill(TEST_EMAIL);
|
||||||
|
await passwordLocator(page).fill(TEST_PASSWORD);
|
||||||
|
await loginButtonLocator(page).click();
|
||||||
|
|
||||||
|
// รอ redirect ไป dashboard
|
||||||
|
await page.waitForURL('**/dashboard', { timeout: TIMEOUT.LOGIN });
|
||||||
|
await waitAppSettled(page);
|
||||||
|
|
||||||
|
if (assertDashboard) {
|
||||||
|
// ยืนยันว่าเข้า dashboard ได้จริง
|
||||||
|
const evidence = [
|
||||||
|
page.locator('.q-page-container').first(),
|
||||||
|
page.locator('.q-drawer').first(),
|
||||||
|
page.locator('img[src*="avataaars"]').first(),
|
||||||
|
page.locator('img[alt],[alt="User Avatar"]').first(),
|
||||||
|
];
|
||||||
|
await expectAnyVisible(page, evidence, TIMEOUT.PAGE_LOAD);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
import { test, expect, type Page, type Locator } from '@playwright/test';
|
|
||||||
|
|
||||||
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
|
||||||
|
|
||||||
// ใช้ account ตามที่คุณให้มา
|
|
||||||
const EMAIL = 'studentedtest@example.com';
|
|
||||||
const PASSWORD = 'admin123';
|
|
||||||
|
|
||||||
async function waitAppSettled(page: Page) {
|
|
||||||
await page.waitForLoadState('domcontentloaded');
|
|
||||||
await page.waitForLoadState('networkidle').catch(() => {});
|
|
||||||
await page.waitForTimeout(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
function emailLocator(page: Page): Locator {
|
|
||||||
return page
|
|
||||||
.locator('input[type="email"]')
|
|
||||||
.or(page.getByRole('textbox', { name: /อีเมล|email/i }))
|
|
||||||
.first();
|
|
||||||
}
|
|
||||||
|
|
||||||
function passwordLocator(page: Page): Locator {
|
|
||||||
return page
|
|
||||||
.locator('input[type="password"]')
|
|
||||||
.or(page.getByRole('textbox', { name: /รหัสผ่าน|password/i }))
|
|
||||||
.first();
|
|
||||||
}
|
|
||||||
|
|
||||||
function loginButtonLocator(page: Page): Locator {
|
|
||||||
return page
|
|
||||||
.getByRole('button', { name: /เข้าสู่ระบบ|login/i })
|
|
||||||
.or(page.locator('button[type="submit"]'))
|
|
||||||
.first();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectAnyVisible(page: Page, locators: Locator[], timeout = 20_000) {
|
|
||||||
const start = Date.now();
|
|
||||||
while (Date.now() - start < timeout) {
|
|
||||||
for (const loc of locators) {
|
|
||||||
try {
|
|
||||||
if (await loc.isVisible()) return;
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
await page.waitForTimeout(200);
|
|
||||||
}
|
|
||||||
throw new Error('None of the expected dashboard locators became visible.');
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('Login -> Dashboard', () => {
|
|
||||||
test('Success Login แล้วเข้า /dashboard ได้', async ({ page }) => {
|
|
||||||
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
|
|
||||||
await waitAppSettled(page);
|
|
||||||
|
|
||||||
await emailLocator(page).fill(EMAIL);
|
|
||||||
await passwordLocator(page).fill(PASSWORD);
|
|
||||||
await loginButtonLocator(page).click();
|
|
||||||
|
|
||||||
await page.waitForURL('**/dashboard', { timeout: 25_000 });
|
|
||||||
await waitAppSettled(page);
|
|
||||||
|
|
||||||
// ✅ ใช้ Locator ที่พบเจอแน่นอนใน Layout/Page โดยไม่ยึดติดกับภาษาปัจจุบัน (I18n)
|
|
||||||
const dashboardEvidence = [
|
|
||||||
// มองหา Layout container ฝั่ง Dashboard
|
|
||||||
page.locator('.q-page-container').first(),
|
|
||||||
page.locator('.q-drawer').first(),
|
|
||||||
// มองหารูปโปรไฟล์ (UserAvatar)
|
|
||||||
page.locator('img[src*="avataaars"]').first(),
|
|
||||||
page.locator('img[alt],[alt="User Avatar"]').first()
|
|
||||||
];
|
|
||||||
|
|
||||||
await expectAnyVisible(page, dashboardEvidence, 20_000);
|
|
||||||
|
|
||||||
await page.screenshot({ path: 'tests/e2e/screenshots/login-to-dashboard.png', fullPage: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Invalid Email - Thai characters (พิมพ์ภาษาไทย)', async ({ page }) => {
|
|
||||||
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
|
|
||||||
await waitAppSettled(page);
|
|
||||||
|
|
||||||
await emailLocator(page).fill('ทดสอบภาษาไทย');
|
|
||||||
await passwordLocator(page).fill(PASSWORD);
|
|
||||||
|
|
||||||
const errorHint = page.getByText('ห้ามใส่ภาษาไทย');
|
|
||||||
|
|
||||||
await expect(errorHint.first()).toBeVisible({ timeout: 12_000 });
|
|
||||||
await page.screenshot({ path: 'tests/e2e/screenshots/login-thai-email.png', fullPage: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Invalid Email Format (อีเมลผิดรูปแบบ)', async ({ page }) => {
|
|
||||||
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
|
|
||||||
await waitAppSettled(page);
|
|
||||||
|
|
||||||
// *สำคัญ*: HTML5 จะดักจับ invalid-email-format ตั้งแต่กด Submit (native validation)
|
|
||||||
// ทำให้ Vue Form ไม่เริ่มทำงาน
|
|
||||||
// ดังนั้นเพื่อให้ทดสอบเจอ Error จาก useFormValidation จริงๆ เราใช้ 'test@domain'
|
|
||||||
// ซึ่ง HTML5 <input type="email"> ปล่อยผ่าน แต่ /regex/ ของระบบตรวจเจอว่าไม่มี .com
|
|
||||||
await emailLocator(page).fill('test@domain');
|
|
||||||
await passwordLocator(page).fill(PASSWORD);
|
|
||||||
await loginButtonLocator(page).click();
|
|
||||||
await waitAppSettled(page);
|
|
||||||
|
|
||||||
const errorHint = page.getByText('กรุณากรอกอีเมลให้ถูกต้อง (you@example.com)');
|
|
||||||
|
|
||||||
await expect(errorHint.first()).toBeVisible({ timeout: 12_000 });
|
|
||||||
await page.screenshot({ path: 'tests/e2e/screenshots/login-invalid-email.png', fullPage: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Wrong Password (รหัสผ่านผิด หรืออีเมลไม่ถูกต้องในระบบ)', async ({ page }) => {
|
|
||||||
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
|
|
||||||
await waitAppSettled(page);
|
|
||||||
|
|
||||||
await emailLocator(page).fill(EMAIL);
|
|
||||||
await passwordLocator(page).fill('wrong-password-123');
|
|
||||||
await loginButtonLocator(page).click();
|
|
||||||
await waitAppSettled(page);
|
|
||||||
|
|
||||||
const errorHint = page.getByText('กรุณาเช็ค Email หรือ รหัสผ่านใหม่อีกครั้ง');
|
|
||||||
|
|
||||||
await expect(errorHint.first()).toBeVisible({ timeout: 12_000 });
|
|
||||||
await page.screenshot({ path: 'tests/e2e/screenshots/login-wrong-password.png', fullPage: true });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
import { test, expect } from '@playwright/test';
|
|
||||||
|
|
||||||
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
|
||||||
|
|
||||||
async function waitAppSettled(page: any) {
|
|
||||||
await page.waitForLoadState('domcontentloaded');
|
|
||||||
await page.waitForLoadState('networkidle').catch(() => {});
|
|
||||||
await page.waitForTimeout(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ฟังก์ชันจำลองล็อกอิน (เพราะทำข้อสอบต้องล็อกอินเสมอ)
|
|
||||||
async function setupLogin(page: any) {
|
|
||||||
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
|
|
||||||
await waitAppSettled(page);
|
|
||||||
|
|
||||||
await page.locator('input[type="email"]').or(page.getByRole('textbox', { name: /อีเมล|email/i })).first().fill('studentedtest@example.com');
|
|
||||||
await page.locator('input[type="password"]').or(page.getByRole('textbox', { name: /รหัสผ่าน|password/i })).first().fill('admin123');
|
|
||||||
await page.getByRole('button', { name: /เข้าสู่ระบบ|login/i }).or(page.locator('button[type="submit"]')).first().click();
|
|
||||||
|
|
||||||
await page.waitForURL('**/dashboard', { timeout: 15_000 }).catch(() => {});
|
|
||||||
await waitAppSettled(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ฟังก์ชัน Mock ข้อมูลข้อสอบให้ Playwright ไม่ต้องไปดึงจากฐานข้อมูลจริงๆ (เพื่อป้องกันปัญหาคอร์ส/บทเรียนไม่มีอยู่จริง)
|
|
||||||
async function mockQuizData(page: any) {
|
|
||||||
await page.route('**/lessons/*', async (route: any) => {
|
|
||||||
// สมมติข้อมูลข้อสอบจำลองให้มี 15 ข้อเพื่อเทส Pagination ได้
|
|
||||||
const mockQuestions = Array.from({ length: 15 }, (_, i) => ({
|
|
||||||
id: i + 1,
|
|
||||||
question: { th: `คำถามข้อที่ ${i + 1}?`, en: `Question ${i + 1}?` },
|
|
||||||
text: { th: `คำถามข้อที่ ${i + 1}?`, en: `Question ${i + 1}?` },
|
|
||||||
choices: [
|
|
||||||
{ id: i * 10 + 1, text: { th: 'ก', en: 'A' } },
|
|
||||||
{ id: i * 10 + 2, text: { th: 'ข', en: 'B' } },
|
|
||||||
{ id: i * 10 + 3, text: { th: 'ค', en: 'C' } }
|
|
||||||
]
|
|
||||||
}));
|
|
||||||
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
id: 17,
|
|
||||||
type: 'QUIZ',
|
|
||||||
quiz: {
|
|
||||||
id: 99,
|
|
||||||
title: { th: 'แบบทดสอบปลายภาค (Mock)', en: 'Final Exam (Mock)' },
|
|
||||||
time_limit: 30,
|
|
||||||
questions: mockQuestions
|
|
||||||
}
|
|
||||||
},
|
|
||||||
progress: {}
|
|
||||||
})
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('ระบบทำแบบทดสอบ (Quiz System)', () => {
|
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
// ต้อง Login ก่อนเรียน!
|
|
||||||
await setupLogin(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('โหลดหน้า Quiz และคลิกระบบเริ่มทำข้อสอบได้ (Start Screen)', async ({ page }) => {
|
|
||||||
await mockQuizData(page);
|
|
||||||
|
|
||||||
// สมมติเอาที่ quiz ใน course 2 lesson 17 (ซึ่ง API เสาะหาจะถูกดักจับและ Mock ไว้ด้านบนแล้ว)
|
|
||||||
await page.goto(`${BASE_URL}/classroom/quiz?course_id=2&lesson_id=17`);
|
|
||||||
|
|
||||||
// หน้าจอ Start Screen ต้องขึ้นมา
|
|
||||||
const startBtn = page.getByRole('button', { name: /เริ่มทำแบบทดสอบ|Start/i }).first();
|
|
||||||
await expect(startBtn).toBeVisible({ timeout: 15_000 });
|
|
||||||
|
|
||||||
// ลองกดเริ่มทำ
|
|
||||||
await startBtn.click();
|
|
||||||
|
|
||||||
// เช็คว่าหน้า Taking (พื้นที่ทำข้อสอบข้อที่ 1) โผล่มา
|
|
||||||
const questionText = page.locator('h3').first(); // ชื่อคำถาม
|
|
||||||
await expect(questionText).toBeVisible({ timeout: 10_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ทดสอบระบบแถบข้อสอบ แบ่งหน้า (Pagination - เลื่อนซ้าย/ขวา)', async ({ page }) => {
|
|
||||||
await mockQuizData(page);
|
|
||||||
await page.goto(`${BASE_URL}/classroom/quiz?course_id=2&lesson_id=17`);
|
|
||||||
|
|
||||||
// เข้าเมนูแบบทดสอบ
|
|
||||||
const startBtn = page.getByRole('button', { name: /เริ่มทำแบบทดสอบ|Start/i }).first();
|
|
||||||
await expect(startBtn).toBeVisible({ timeout: 15_000 });
|
|
||||||
await startBtn.click();
|
|
||||||
|
|
||||||
// เช็คว่ามีลูกศรเลื่อนหน้าข้อสอบ (Paginations) สำหรับแบบทดสอบเกิน 10 ข้อที่สร้างมาใหม่
|
|
||||||
const nextPaginationPageBtn = page.locator('button').filter({ has: page.locator('i.q-icon:has-text("chevron_right")') }).first();
|
|
||||||
|
|
||||||
if (await nextPaginationPageBtn.isVisible()) {
|
|
||||||
// หากปุ่มแสดง (บอกว่ามีข้อสอบหลายหน้า) ลองกดข้าม
|
|
||||||
await expect(nextPaginationPageBtn).toBeEnabled();
|
|
||||||
await nextPaginationPageBtn.click();
|
|
||||||
|
|
||||||
// เช็คว่ากดแล้ว ข้อที่ 11 โผล่มา
|
|
||||||
const question11Btn = page.locator('button').filter({ hasText: /^11$/ }).first();
|
|
||||||
await expect(question11Btn).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('การแสดงผลปุ่มถัดไป/ส่งคำตอบ (Submit & Navigation UI)', async ({ page }) => {
|
|
||||||
await mockQuizData(page);
|
|
||||||
await page.goto(`${BASE_URL}/classroom/quiz?course_id=2&lesson_id=17`);
|
|
||||||
const startBtn = page.getByRole('button', { name: /เริ่มทำแบบทดสอบ|Start/i }).first();
|
|
||||||
await expect(startBtn).toBeVisible({ timeout: 15_000 });
|
|
||||||
await startBtn.click();
|
|
||||||
|
|
||||||
// รอให้หน้าโหลดคำถามเสร็จก่อน ค่อยหาปุ่ม
|
|
||||||
await expect(page.locator('h3').first()).toBeVisible({ timeout: 10_000 });
|
|
||||||
|
|
||||||
const submitBtn = page.locator('button').filter({ hasText: /(ส่งคำตอบ|Submit)/i }).first();
|
|
||||||
const nextBtn = page.locator('button').filter({ hasText: /(ถัดไป|Next)/i }).first();
|
|
||||||
|
|
||||||
// แบบทดสอบข้อแรก ต้องมีปุ่ม ถัดไป(Next) หรือปุ่มส่ง ถ้ามีแค่ 1 ข้อ
|
|
||||||
await expect(submitBtn.or(nextBtn)).toBeVisible({ timeout: 10_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
@ -1,241 +0,0 @@
|
||||||
import { test, expect, type Page, type Locator } from '@playwright/test';
|
|
||||||
|
|
||||||
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
|
||||||
|
|
||||||
async function waitAppSettled(page: Page) {
|
|
||||||
await page.waitForLoadState('domcontentloaded');
|
|
||||||
await page.waitForLoadState('networkidle').catch(() => {});
|
|
||||||
await page.waitForTimeout(250);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Anchors / Scope =====
|
|
||||||
function headingRegister(page: Page) {
|
|
||||||
return page.getByRole('heading', { name: 'สร้างบัญชีผู้ใช้งาน' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Inputs (ตาม snapshot ที่คุณส่งมา) =====
|
|
||||||
function usernameInput(page: Page): Locator {
|
|
||||||
// snapshot: textbox "username"
|
|
||||||
return page.getByRole('textbox', { name: 'username' }).first();
|
|
||||||
}
|
|
||||||
|
|
||||||
function emailInput(page: Page): Locator {
|
|
||||||
// snapshot: textbox "student@example.com"
|
|
||||||
return page.getByRole('textbox', { name: 'student@example.com' }).first();
|
|
||||||
}
|
|
||||||
|
|
||||||
function prefixCombobox(page: Page): Locator {
|
|
||||||
// snapshot: combobox มี option นาย/นาง/นางสาว
|
|
||||||
return page.getByRole('combobox').first();
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstNameInput(page: Page): Locator {
|
|
||||||
// snapshot: label "ชื่อ *" + textbox
|
|
||||||
return page.getByText(/^ชื่อ\s*\*$/).locator('..').getByRole('textbox').first();
|
|
||||||
}
|
|
||||||
|
|
||||||
function lastNameInput(page: Page): Locator {
|
|
||||||
return page.getByText(/^นามสกุล\s*\*$/).locator('..').getByRole('textbox').first();
|
|
||||||
}
|
|
||||||
|
|
||||||
function phoneInput(page: Page): Locator {
|
|
||||||
return page.getByText(/^เบอร์โทรศัพท์\s*\*$/).locator('..').getByRole('textbox').first();
|
|
||||||
}
|
|
||||||
|
|
||||||
function passwordInput(page: Page): Locator {
|
|
||||||
// snapshot: label "รหัสผ่าน *" + textbox (มีปุ่ม visibility อยู่ข้างๆ)
|
|
||||||
return page.getByText(/^รหัสผ่าน\s*\*$/).locator('..').getByRole('textbox').first();
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmPasswordInput(page: Page): Locator {
|
|
||||||
return page.getByText(/^ยืนยันรหัสผ่าน\s*\*$/).locator('..').getByRole('textbox').first();
|
|
||||||
}
|
|
||||||
|
|
||||||
function submitButton(page: Page): Locator {
|
|
||||||
return page.getByRole('button', { name: 'สร้างบัญชี' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function loginLink(page: Page): Locator {
|
|
||||||
return page.getByRole('link', { name: 'เข้าสู่ระบบ' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function errorBox(page: Page): Locator {
|
|
||||||
// ทั้ง field message และ notification/toast/alert
|
|
||||||
return page.locator(
|
|
||||||
['.q-field__messages', '.q-field__bottom', '.text-negative', '.q-notification', '.q-banner', '[role="alert"]'].join(
|
|
||||||
', '
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pickPrefix(page: Page, value: 'นาย' | 'นาง' | 'นางสาว' = 'นาย') {
|
|
||||||
const combo = prefixCombobox(page);
|
|
||||||
|
|
||||||
// ถ้าเป็น <select> จริง selectOption จะเวิร์คทันที
|
|
||||||
await combo.selectOption({ label: value }).catch(async () => {
|
|
||||||
// fallback: คลิกแล้วเลือก option
|
|
||||||
await combo.click();
|
|
||||||
await page.getByRole('option', { name: value }).click();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Test data =====
|
|
||||||
function uniqueUser() {
|
|
||||||
const n = Date.now().toString().slice(-6);
|
|
||||||
|
|
||||||
// ✅ แก้ปัญหา "Phone number already exists" ด้วยเบอร์สุ่มไม่ซ้ำ
|
|
||||||
// รูปแบบ 09xxxxxxxx (10 หลัก)
|
|
||||||
const rand8 = Math.floor(Math.random() * 1e8).toString().padStart(8, '0');
|
|
||||||
const phone = `09${rand8}`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
username: `e2e_user_${n}`,
|
|
||||||
email: `e2e_${n}@example.com`,
|
|
||||||
firstName: 'ทดสอบ',
|
|
||||||
lastName: 'ระบบ',
|
|
||||||
phone,
|
|
||||||
password: 'Admin12345!',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================== TESTS ==================
|
|
||||||
test.describe('Register Page (auth/register)', () => {
|
|
||||||
test('หน้า Register ต้องโหลดได้ (Smoke)', async ({ page }) => {
|
|
||||||
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
|
|
||||||
await waitAppSettled(page);
|
|
||||||
|
|
||||||
await expect(headingRegister(page)).toBeVisible({ timeout: 15_000 });
|
|
||||||
await expect(submitButton(page)).toBeVisible({ timeout: 15_000 });
|
|
||||||
await page.screenshot({ path: 'tests/e2e/screenshots/register-page.png', fullPage: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ลิงก์ "เข้าสู่ระบบ" ต้องกดแล้วไปหน้า login ได้', async ({ page }) => {
|
|
||||||
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
|
|
||||||
await waitAppSettled(page);
|
|
||||||
|
|
||||||
await loginLink(page).click();
|
|
||||||
await page.waitForURL('**/auth/login', { timeout: 15_000 });
|
|
||||||
await page.screenshot({ path: 'tests/e2e/screenshots/register-go-login.png', fullPage: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('สมัครสมาชิกสำเร็จ (Happy Path) → redirect ไป /auth/login', async ({ page }) => {
|
|
||||||
const u = uniqueUser();
|
|
||||||
|
|
||||||
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
|
|
||||||
await waitAppSettled(page);
|
|
||||||
|
|
||||||
await usernameInput(page).fill(u.username);
|
|
||||||
await emailInput(page).fill(u.email);
|
|
||||||
await pickPrefix(page, 'นาย');
|
|
||||||
|
|
||||||
await firstNameInput(page).fill(u.firstName);
|
|
||||||
await lastNameInput(page).fill(u.lastName);
|
|
||||||
await phoneInput(page).fill(u.phone);
|
|
||||||
|
|
||||||
await passwordInput(page).fill(u.password);
|
|
||||||
await confirmPasswordInput(page).fill(u.password);
|
|
||||||
|
|
||||||
await submitButton(page).click();
|
|
||||||
await waitAppSettled(page);
|
|
||||||
|
|
||||||
// ✅ รอ 3 อย่าง: ไป login / success toast / error
|
|
||||||
const navToLogin = page
|
|
||||||
.waitForURL('**/auth/login', { timeout: 25_000, waitUntil: 'domcontentloaded' })
|
|
||||||
.then(() => 'login' as const)
|
|
||||||
.catch(() => null);
|
|
||||||
|
|
||||||
const successToast = page
|
|
||||||
.getByText(/สมัครสมาชิกสำเร็จ|success/i, { exact: false })
|
|
||||||
.first()
|
|
||||||
.waitFor({ state: 'visible', timeout: 25_000 })
|
|
||||||
.then(() => 'success' as const)
|
|
||||||
.catch(() => null);
|
|
||||||
|
|
||||||
const anyError = errorBox(page)
|
|
||||||
.first()
|
|
||||||
.waitFor({ state: 'visible', timeout: 25_000 })
|
|
||||||
.then(() => 'error' as const)
|
|
||||||
.catch(() => null);
|
|
||||||
|
|
||||||
const result = await Promise.race([navToLogin, successToast, anyError]);
|
|
||||||
|
|
||||||
// ถ้ามี error ให้ fail พร้อม log ชัดๆ
|
|
||||||
if (result === 'error') {
|
|
||||||
const errs = await errorBox(page).allInnerTexts().catch(() => []);
|
|
||||||
await page.screenshot({ path: 'tests/e2e/screenshots/register-happy-error.png', fullPage: true });
|
|
||||||
throw new Error(`Register did not redirect. Errors: ${errs.join(' | ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ถ้ามีแต่ toast success แต่ยังไม่ redirect ให้ไปหน้า login เอง (ตาม flow ที่คุณต้องการ)
|
|
||||||
if (!page.url().includes('/auth/login')) {
|
|
||||||
const hasSuccess = await page
|
|
||||||
.getByText(/สมัครสมาชิกสำเร็จ/i, { exact: false })
|
|
||||||
.first()
|
|
||||||
.isVisible()
|
|
||||||
.catch(() => false);
|
|
||||||
|
|
||||||
if (hasSuccess) {
|
|
||||||
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
|
|
||||||
await waitAppSettled(page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ สุดท้ายต้องอยู่หน้า /auth/login แน่นอน
|
|
||||||
await expect(page).toHaveURL(/\/auth\/login/i, { timeout: 15_000 });
|
|
||||||
|
|
||||||
// ✅ แก้ strict mode: ระบุให้ชัดว่าเป็น heading และ button
|
|
||||||
await expect(page.getByRole('heading', { name: 'เข้าสู่ระบบ' })).toBeVisible({ timeout: 15_000 });
|
|
||||||
await expect(page.getByRole('button', { name: 'เข้าสู่ระบบ' })).toBeVisible({ timeout: 15_000 });
|
|
||||||
|
|
||||||
// optional: การ์ด TEST ACCOUNT (ถ้ามี)
|
|
||||||
await expect(page.getByText(/TEST ACCOUNT/i, { exact: false }))
|
|
||||||
.toBeVisible({ timeout: 10_000 })
|
|
||||||
.catch(() => {});
|
|
||||||
|
|
||||||
await page.screenshot({ path: 'tests/e2e/screenshots/register-redirect-login.png', fullPage: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Invalid Email - ใส่ภาษาไทย ต้องขึ้น error', async ({ page }) => {
|
|
||||||
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
|
|
||||||
await waitAppSettled(page);
|
|
||||||
|
|
||||||
await emailInput(page).fill('ทดสอบภาษาไทย');
|
|
||||||
await usernameInput(page).click(); // blur trigger
|
|
||||||
|
|
||||||
const err = page
|
|
||||||
.getByText(/ห้ามใส่ภาษาไทย|อีเมลไม่ถูกต้อง|รูปแบบอีเมล/i, { exact: false })
|
|
||||||
.or(errorBox(page).filter({ hasText: /ไทย|อีเมล|email|invalid/i }));
|
|
||||||
|
|
||||||
await expect(err.first()).toBeVisible({ timeout: 12_000 });
|
|
||||||
await page.screenshot({ path: 'tests/e2e/screenshots/register-invalid-email-thai.png', fullPage: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Password ไม่ตรงกัน ต้องขึ้น error (ต้องกดสร้างบัญชีก่อน)', async ({ page }) => {
|
|
||||||
const u = uniqueUser();
|
|
||||||
|
|
||||||
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
|
|
||||||
await waitAppSettled(page);
|
|
||||||
|
|
||||||
await usernameInput(page).fill(u.username);
|
|
||||||
await emailInput(page).fill(u.email);
|
|
||||||
await pickPrefix(page, 'นาย');
|
|
||||||
|
|
||||||
await firstNameInput(page).fill(u.firstName);
|
|
||||||
await lastNameInput(page).fill(u.lastName);
|
|
||||||
await phoneInput(page).fill(u.phone);
|
|
||||||
|
|
||||||
await passwordInput(page).fill('Admin12345!');
|
|
||||||
await confirmPasswordInput(page).fill('Admin12345?'); // mismatch
|
|
||||||
|
|
||||||
// ✅ ต้อง submit ก่อน error ถึงขึ้น
|
|
||||||
await submitButton(page).click();
|
|
||||||
await waitAppSettled(page);
|
|
||||||
|
|
||||||
const mismatchErr = page
|
|
||||||
.getByText(/รหัสผ่านไม่ตรงกัน/i, { exact: false })
|
|
||||||
.or(errorBox(page).filter({ hasText: /รหัสผ่านไม่ตรงกัน/i }));
|
|
||||||
|
|
||||||
await expect(mismatchErr.first()).toBeVisible({ timeout: 12_000 });
|
|
||||||
await page.screenshot({ path: 'tests/e2e/screenshots/register-password-mismatch.png', fullPage: true });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,54 +1,58 @@
|
||||||
|
/**
|
||||||
|
* @file student-account.spec.ts
|
||||||
|
* @description ทดสอบระบบพื้นที่ส่วนตัวผู้เรียน (Student Account / Portal)
|
||||||
|
*/
|
||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { BASE_URL, TIMEOUT, waitAppSettled, setupLogin } from './helpers';
|
||||||
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
|
||||||
|
|
||||||
async function waitAppSettled(page: any) {
|
|
||||||
await page.waitForLoadState('domcontentloaded');
|
|
||||||
await page.waitForLoadState('networkidle').catch(() => {});
|
|
||||||
await page.waitForTimeout(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ฟังก์ชันจำลองล็อกอิน (เพื่อที่จะเข้า Dashboard ได้)
|
|
||||||
async function setupLogin(page: any) {
|
|
||||||
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
|
|
||||||
await waitAppSettled(page);
|
|
||||||
|
|
||||||
await page.locator('input[type="email"]').or(page.getByRole('textbox', { name: /อีเมล|email/i })).first().fill('studentedtest@example.com');
|
|
||||||
await page.locator('input[type="password"]').or(page.getByRole('textbox', { name: /รหัสผ่าน|password/i })).first().fill('admin123');
|
|
||||||
await page.getByRole('button', { name: /เข้าสู่ระบบ|login/i }).or(page.locator('button[type="submit"]')).first().click();
|
|
||||||
|
|
||||||
await page.waitForURL('**/dashboard', { timeout: 15_000 }).catch(() => {});
|
|
||||||
await waitAppSettled(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('ระบบพื้นที่ส่วนตัวผู้เรียน (Student Account / Portal)', () => {
|
test.describe('ระบบพื้นที่ส่วนตัวผู้เรียน (Student Account / Portal)', () => {
|
||||||
|
|
||||||
test.describe('การตั้งค่าและส่วนติดต่อผู้ใช้ (Settings & UI Theme)', () => {
|
test.describe('การตั้งค่าและส่วนติดต่อผู้ใช้ (Settings & UI Theme)', () => {
|
||||||
test('เปลี่ยนภาษาการแสดงผล (Localisation/i18n)', async ({ page }) => {
|
test('เปลี่ยนภาษาการแสดงผล (Localisation/i18n)', async ({ page }) => {
|
||||||
await page.goto(BASE_URL);
|
await page.goto(BASE_URL);
|
||||||
const langBtn = page.getByRole('button', { name: 'Language' }).or(page.locator('button').filter({ hasText: /TH|EN/ })).first();
|
await waitAppSettled(page);
|
||||||
|
|
||||||
if (await langBtn.isVisible()) {
|
|
||||||
await langBtn.click();
|
|
||||||
const englishOpt = page.locator('text=English, text=EN').first();
|
|
||||||
await englishOpt.click();
|
|
||||||
|
|
||||||
const loginLink = page.locator('a[href*="login"], button').filter({ hasText: /Log in|Sign In/i });
|
// หาปุ่มภาษา — ถ้าไม่เจอให้ test.skip แทนที่จะผ่านเงียบๆ
|
||||||
await expect(loginLink).toBeVisible({ timeout: 5000 });
|
const langBtn = page.getByRole('button', { name: 'Language' })
|
||||||
|
.or(page.locator('button').filter({ hasText: /TH|EN/ }))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
const isLangBtnVisible = await langBtn.isVisible().catch(() => false);
|
||||||
|
if (!isLangBtnVisible) {
|
||||||
|
test.skip(true, 'Language button not found on page — skipping');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await langBtn.click();
|
||||||
|
const englishOpt = page.locator('text=English, text=EN').first();
|
||||||
|
await englishOpt.click();
|
||||||
|
|
||||||
|
const loginLink = page.locator('a[href*="login"], button').filter({ hasText: /Log in|Sign In/i });
|
||||||
|
await expect(loginLink).toBeVisible({ timeout: TIMEOUT.ELEMENT });
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'tests/e2e/screenshots/student-i18n.png', fullPage: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('เปลี่ยนโหมดมืดสว่าง (Theme Switcher)', async ({ page }) => {
|
test('เปลี่ยนโหมดมืดสว่าง (Theme Switcher)', async ({ page }) => {
|
||||||
await page.goto(BASE_URL);
|
await page.goto(BASE_URL);
|
||||||
|
await waitAppSettled(page);
|
||||||
|
|
||||||
|
// หาปุ่ม Theme — ถ้าไม่เจอให้ test.skip แทนที่จะผ่านเงียบๆ
|
||||||
const themeBtn = page.locator('.dark-mode-toggle, button[aria-label*="theme"]').first();
|
const themeBtn = page.locator('.dark-mode-toggle, button[aria-label*="theme"]').first();
|
||||||
|
|
||||||
if (await themeBtn.isVisible()) {
|
const isThemeBtnVisible = await themeBtn.isVisible().catch(() => false);
|
||||||
const htmlBefore = await page.evaluate(() => document.documentElement.className);
|
if (!isThemeBtnVisible) {
|
||||||
await themeBtn.click();
|
test.skip(true, 'Theme toggle button not found on page — skipping');
|
||||||
await page.waitForTimeout(500);
|
return;
|
||||||
const htmlAfter = await page.evaluate(() => document.documentElement.className);
|
|
||||||
expect(htmlBefore).not.toEqual(htmlAfter);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const htmlBefore = await page.evaluate(() => document.documentElement.className);
|
||||||
|
await themeBtn.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
const htmlAfter = await page.evaluate(() => document.documentElement.className);
|
||||||
|
expect(htmlBefore).not.toEqual(htmlAfter);
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'tests/e2e/screenshots/student-theme.png', fullPage: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -59,60 +63,77 @@ test.describe('ระบบพื้นที่ส่วนตัวผู้
|
||||||
|
|
||||||
test('หน้าแรกของ Dashboard โหลดได้ปกติ', async ({ page }) => {
|
test('หน้าแรกของ Dashboard โหลดได้ปกติ', async ({ page }) => {
|
||||||
await page.goto(`${BASE_URL}/dashboard`);
|
await page.goto(`${BASE_URL}/dashboard`);
|
||||||
await page.waitForTimeout(1000);
|
await waitAppSettled(page, 1000);
|
||||||
|
|
||||||
const welcomeText = page.getByText(/ยินดีต้อนรับกลับ/i, { exact: false });
|
const welcomeText = page.getByText(/ยินดีต้อนรับกลับ/i, { exact: false });
|
||||||
const profileSummary = page.locator('.q-avatar, img[alt*="Profile"], img[src*="avatar"]').first();
|
const profileSummary = page.locator('.q-avatar, img[alt*="Profile"], img[src*="avatar"]').first();
|
||||||
|
|
||||||
await expect(welcomeText.first().or(profileSummary)).toBeVisible({ timeout: 10_000 });
|
await expect(welcomeText.first().or(profileSummary)).toBeVisible({ timeout: TIMEOUT.ELEMENT });
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'tests/e2e/screenshots/student-dashboard.png', fullPage: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('โหลดหน้า คอร์สของฉัน (My Courses)', async ({ page }) => {
|
test('โหลดหน้า คอร์สของฉัน (My Courses)', async ({ page }) => {
|
||||||
await page.goto(`${BASE_URL}/dashboard/my-courses`);
|
await page.goto(`${BASE_URL}/dashboard/my-courses`);
|
||||||
|
await waitAppSettled(page);
|
||||||
|
|
||||||
const heading = page.locator('h2').filter({ hasText: /คอร์สของฉัน|My Courses/i }).first();
|
const heading = page.locator('h2').filter({ hasText: /คอร์สของฉัน|My Courses/i }).first();
|
||||||
await expect(heading).toBeVisible();
|
await expect(heading).toBeVisible({ timeout: TIMEOUT.ELEMENT });
|
||||||
|
|
||||||
const searchInput = page.getByPlaceholder(/ค้นหา|Search/i).first();
|
const searchInput = page.getByPlaceholder(/ค้นหา|Search/i).first();
|
||||||
await expect(searchInput).toBeVisible({ timeout: 10_000 });
|
await expect(searchInput).toBeVisible({ timeout: TIMEOUT.ELEMENT });
|
||||||
|
|
||||||
await expect(page.locator('i.q-icon').filter({ hasText: 'grid_view' }).first()).toBeVisible();
|
await expect(page.locator('i.q-icon').filter({ hasText: 'grid_view' }).first()).toBeVisible();
|
||||||
await expect(page.locator('i.q-icon').filter({ hasText: 'view_list' }).first()).toBeVisible();
|
await expect(page.locator('i.q-icon').filter({ hasText: 'view_list' }).first()).toBeVisible();
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'tests/e2e/screenshots/student-my-courses.png', fullPage: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ลองค้นหาคอร์ส (Search Input) ไม่พบข้อมูล', async ({ page }) => {
|
test('ลองค้นหาคอร์ส (Search Input) ไม่พบข้อมูล', async ({ page }) => {
|
||||||
await page.goto(`${BASE_URL}/dashboard/my-courses`);
|
await page.goto(`${BASE_URL}/dashboard/my-courses`);
|
||||||
|
await waitAppSettled(page);
|
||||||
|
|
||||||
const searchInput = page.getByPlaceholder(/ค้นหา|Search/i).first();
|
const searchInput = page.getByPlaceholder(/ค้นหา|Search/i).first();
|
||||||
await expect(searchInput).toBeVisible({ timeout: 10_000 });
|
await expect(searchInput).toBeVisible({ timeout: TIMEOUT.ELEMENT });
|
||||||
|
|
||||||
await searchInput.fill('คอร์สที่ไม่มีอยู่จริงแน่นอน1234');
|
await searchInput.fill('คอร์สที่ไม่มีอยู่จริงแน่นอน1234');
|
||||||
|
|
||||||
const emptyState = page.locator('h3').filter({ hasText: /ไม่พบ|ไม่เจอ|No result/i }).first()
|
const emptyState = page.locator('h3').filter({ hasText: /ไม่พบ|ไม่เจอ|No result/i }).first()
|
||||||
.or(page.locator('i.q-icon').filter({ hasText: 'search_off' }));
|
.or(page.locator('i.q-icon').filter({ hasText: 'search_off' }));
|
||||||
|
|
||||||
await expect(emptyState.first()).toBeVisible({ timeout: 10_000 });
|
await expect(emptyState.first()).toBeVisible({ timeout: TIMEOUT.ELEMENT });
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'tests/e2e/screenshots/student-search-empty.png', fullPage: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('แก้ไขและบันทึกข้อมูลส่วนตัว (Edit Profile)', async ({ page }) => {
|
test('แก้ไขและบันทึกข้อมูลส่วนตัว (Edit Profile)', async ({ page }) => {
|
||||||
await page.goto(`${BASE_URL}/dashboard/profile`);
|
await page.goto(`${BASE_URL}/dashboard/profile`);
|
||||||
|
await waitAppSettled(page, 1000);
|
||||||
|
|
||||||
const nameInput = page.locator('input[type="text"]').first();
|
// หา input ชื่อ — ใช้ textbox "First Name" หรือ input[type="text"] ตัวแรก
|
||||||
|
const nameInput = page.getByRole('textbox', { name: /First Name|ชื่อ/i }).first()
|
||||||
if (await nameInput.isVisible()) {
|
.or(page.locator('input[type="text"]').first());
|
||||||
const oldName = await nameInput.inputValue();
|
|
||||||
|
|
||||||
await nameInput.clear();
|
|
||||||
await nameInput.fill(`${oldName}แก้ไข`);
|
|
||||||
|
|
||||||
const saveBtn = page.getByRole('button', { name: /บันทึก/i }).first();
|
const isNameVisible = await nameInput.isVisible().catch(() => false);
|
||||||
if(await saveBtn.isVisible()) {
|
if (!isNameVisible) {
|
||||||
await saveBtn.click();
|
test.skip(true, 'Profile name input not found — skipping');
|
||||||
const successNotify = page.locator('.q-notification__message, text=อัปเดตข้อมูลสำเร็จ').first();
|
return;
|
||||||
await expect(successNotify).toBeVisible({ timeout: 5000 }).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
|
const oldName = await nameInput.inputValue();
|
||||||
|
await nameInput.clear();
|
||||||
|
await nameInput.fill(`${oldName}แก้ไข`);
|
||||||
|
|
||||||
|
// ปุ่มบันทึก — รองรับทั้งภาษาไทยและอังกฤษ
|
||||||
|
const saveBtn = page.getByRole('button', { name: /บันทึก|Save Changes|Save/i }).first();
|
||||||
|
await expect(saveBtn).toBeVisible({ timeout: TIMEOUT.ELEMENT });
|
||||||
|
await saveBtn.click();
|
||||||
|
|
||||||
|
// Toast สำเร็จ — รองรับทั้ง 2 ภาษา
|
||||||
|
const successNotify = page.getByText(/อัปเดตข้อมูลสำเร็จ|บันทึกข้อมูล|updated|saved|success/i).first();
|
||||||
|
await expect(successNotify).toBeVisible({ timeout: TIMEOUT.ELEMENT }).catch(() => {});
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'tests/e2e/screenshots/student-edit-profile.png', fullPage: true });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -343,10 +343,12 @@ const save = async () => {
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
// Convert local datetime to ISO string to preserve timezone
|
// Convert local datetime to ISO string to preserve timezone
|
||||||
const payload = { ...form.value };
|
const payload: any = { ...form.value };
|
||||||
if (payload.published_at) {
|
if (payload.published_at) {
|
||||||
const localDate = new Date(payload.published_at.replace(' ', 'T'));
|
const localDate = new Date(payload.published_at.replace(' ', 'T'));
|
||||||
payload.published_at = localDate.toISOString();
|
payload.published_at = localDate.toISOString();
|
||||||
|
} else {
|
||||||
|
delete payload.published_at;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (editing.value) {
|
if (editing.value) {
|
||||||
|
|
@ -447,10 +449,7 @@ const deleteAttachment = async (attachmentId: number) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
// Date formatting function is auto-imported from utils/date.ts
|
||||||
const date = new Date(dateStr);
|
|
||||||
return date.toLocaleDateString('th-TH', { day: 'numeric', month: 'short', year: 'numeric' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatFileSize = (bytes: number) => {
|
const formatFileSize = (bytes: number) => {
|
||||||
if (bytes < 1024) return bytes + ' B';
|
if (bytes < 1024) return bytes + ' B';
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
v-for="item in history"
|
v-for="item in history"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:title="titleMap[item.action] || item.action"
|
:title="titleMap[item.action] || item.action"
|
||||||
:subtitle="formatDate(item.created_at)"
|
:subtitle="formatDateTime(item.created_at)"
|
||||||
:color="colorMap[item.action] || 'grey'"
|
:color="colorMap[item.action] || 'grey'"
|
||||||
:icon="iconMap[item.action] || 'circle'"
|
:icon="iconMap[item.action] || 'circle'"
|
||||||
>
|
>
|
||||||
|
|
@ -91,12 +91,7 @@ const getActorName = (item: ApprovalHistory) => {
|
||||||
return actor.username || actor.email || 'Unknown User';
|
return actor.username || actor.email || 'Unknown User';
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
// Date formatting function is auto-imported from utils/date.ts
|
||||||
return new Date(dateString).toLocaleString('th-TH', {
|
|
||||||
dateStyle: 'medium',
|
|
||||||
timeStyle: 'short'
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchHistory();
|
fetchHistory();
|
||||||
|
|
|
||||||
|
|
@ -450,14 +450,7 @@ const openStudentDetail = async (studentId: number) => {
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => {
|
||||||
if (!dateStr) return '-';
|
if (!dateStr) return '-';
|
||||||
const date = new Date(dateStr);
|
return formatDateTime(dateStr);
|
||||||
return date.toLocaleDateString('th-TH', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
|
|
|
||||||
|
|
@ -404,8 +404,7 @@ const getStudentStatusLabel = (status: string) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatEnrollDate = (dateStr: string) => {
|
const formatEnrollDate = (dateStr: string) => {
|
||||||
const date = new Date(dateStr);
|
return formatDate(dateStr);
|
||||||
return date.toLocaleDateString('th-TH', { day: 'numeric', month: 'short', year: 'numeric' });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getLessonTypeIcon = (type: string) => {
|
const getLessonTypeIcon = (type: string) => {
|
||||||
|
|
@ -436,8 +435,7 @@ const formatVideoTime = (seconds: number) => {
|
||||||
|
|
||||||
const formatCompletedDate = (dateStr: string | null) => {
|
const formatCompletedDate = (dateStr: string | null) => {
|
||||||
if (!dateStr) return '-';
|
if (!dateStr) return '-';
|
||||||
const date = new Date(dateStr);
|
return formatDate(dateStr);
|
||||||
return date.toLocaleDateString('th-TH', { day: 'numeric', month: 'short' });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch on mount
|
// Fetch on mount
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,7 @@
|
||||||
<!-- Created At Custom Column -->
|
<!-- Created At Custom Column -->
|
||||||
<template v-slot:body-cell-created_at="props">
|
<template v-slot:body-cell-created_at="props">
|
||||||
<q-td :props="props">
|
<q-td :props="props">
|
||||||
{{ formatDate(props.value) }}
|
{{ formatDateTime(props.value) }}
|
||||||
</q-td>
|
</q-td>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -168,8 +168,8 @@
|
||||||
<q-badge :color="getActionColor(selectedLog.action)">{{ selectedLog.action }}</q-badge>
|
<q-badge :color="getActionColor(selectedLog.action)">{{ selectedLog.action }}</q-badge>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-subtitle2 text-grey">Time</div>
|
<div class="text-subtitle2 text-grey">Date & Time</div>
|
||||||
<div>{{ formatDate(selectedLog.created_at) }}</div>
|
<div>{{ formatDateTime(selectedLog.created_at) }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -241,7 +241,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useQuasar } from 'quasar';
|
import { useQuasar, type QTableColumn } from 'quasar';
|
||||||
import { adminService, type AuditLog, type AuditLogStats } from '~/services/admin.service';
|
import { adminService, type AuditLog, type AuditLogStats } from '~/services/admin.service';
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
|
|
@ -284,15 +284,15 @@ const pagination = ref({
|
||||||
});
|
});
|
||||||
|
|
||||||
// Table setup
|
// Table setup
|
||||||
const columns = [
|
const columns: QTableColumn[] = [
|
||||||
{ name: 'id', label: 'ID', field: 'id', align: 'left', style: 'width: 60px' },
|
{ name: 'id', label: 'ID', field: 'id', align: 'left' as const, style: 'width: 60px' },
|
||||||
{ name: 'action', label: 'Action', field: 'action', align: 'left' },
|
{ name: 'action', label: 'Action', field: 'action', align: 'left' as const },
|
||||||
{ name: 'user', label: 'User', field: 'user', align: 'left' },
|
{ name: 'user', label: 'User', field: 'user', align: 'left' as const },
|
||||||
{ name: 'entity_type', label: 'Entity Type', field: 'entity_type', align: 'left' },
|
{ name: 'entity_type', label: 'Entity Type', field: 'entity_type', align: 'left' as const },
|
||||||
{ name: 'entity_id', label: 'Entity ID', field: 'entity_id', align: 'left' },
|
{ name: 'entity_id', label: 'Entity ID', field: 'entity_id', align: 'left' as const },
|
||||||
|
|
||||||
{ name: 'created_at', label: 'Time', field: 'created_at', align: 'left' },
|
{ name: 'created_at', label: 'Date & Time', field: 'created_at', align: 'left' as const },
|
||||||
{ name: 'actions', label: '', field: 'actions', align: 'center' }
|
{ name: 'actions', label: '', field: 'actions', align: 'center' as const }
|
||||||
];
|
];
|
||||||
|
|
||||||
// Actions options (for filtering)
|
// Actions options (for filtering)
|
||||||
|
|
@ -416,18 +416,25 @@ const tryFormatJson = (str: string | null) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (date: string) => {
|
// Date formatting function is auto-imported from utils/date.ts
|
||||||
if (!date) return '-';
|
|
||||||
return new Date(date).toLocaleString('th-TH');
|
const ACTION_COLOR_MAP: Record<string, string> = {
|
||||||
|
DELETE: 'negative',
|
||||||
|
REJECT: 'negative',
|
||||||
|
DEACTIVATE: 'negative',
|
||||||
|
ERROR: 'negative',
|
||||||
|
UPDATE: 'warning',
|
||||||
|
CHANGE: 'warning',
|
||||||
|
CREATE: 'positive',
|
||||||
|
APPROVE: 'positive',
|
||||||
|
ACTIVATE: 'positive',
|
||||||
|
LOGIN: 'info',
|
||||||
};
|
};
|
||||||
|
|
||||||
const getActionColor = (action: string) => {
|
const getActionColor = (action: string) => {
|
||||||
if (!action) return 'grey';
|
if (!action) return 'grey';
|
||||||
if (action.includes('DELETE') || action.includes('REJECT') || action.includes('DEACTIVATE') || action.includes('ERROR')) return 'negative';
|
const keyword = Object.keys(ACTION_COLOR_MAP).find((key) => action.includes(key));
|
||||||
if (action.includes('UPDATE') || action.includes('CHANGE')) return 'warning';
|
return keyword ? ACTION_COLOR_MAP[keyword] : 'grey-8';
|
||||||
if (action.includes('CREATE') || action.includes('APPROVE') || action.includes('ACTIVATE')) return 'positive';
|
|
||||||
if (action.includes('LOGIN')) return 'info';
|
|
||||||
return 'grey-8';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check for deep link to detail
|
// Check for deep link to detail
|
||||||
|
|
@ -443,10 +450,12 @@ onMounted(() => {
|
||||||
:deep(input[type=number]::-webkit-outer-spin-button),
|
:deep(input[type=number]::-webkit-outer-spin-button),
|
||||||
:deep(input[type=number]::-webkit-inner-spin-button) {
|
:deep(input[type=number]::-webkit-inner-spin-button) {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(input[type=number]) {
|
:deep(input[type=number]) {
|
||||||
-moz-appearance: textfield;
|
-moz-appearance: textfield;
|
||||||
|
appearance: textfield;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -233,13 +233,7 @@ const fetchCategories = async () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (date: string) => {
|
// Date formatting function is auto-imported from utils/date.ts
|
||||||
return new Date(date).toLocaleDateString('th-TH', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit'
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
form.value = {
|
form.value = {
|
||||||
|
|
@ -307,8 +301,17 @@ const handleSave = async () => {
|
||||||
const confirmDelete = (category: CategoryResponse) => {
|
const confirmDelete = (category: CategoryResponse) => {
|
||||||
$q.dialog({
|
$q.dialog({
|
||||||
title: 'ยืนยันการลบ',
|
title: 'ยืนยันการลบ',
|
||||||
message: `คุณต้องการลบหมวดหมู่ "${category.name.th}" หรือไม่?`,
|
message: `คุณต้องการลบหมวดหมู่ "${category.name.th}" หรือไม่?<br><span style="color: red;">การลบหมวดหมู่นี้จะทำให้หมวดหมู่ถูกลบออกจากหลักสูตรทั้งหมดที่ใช้งานอยู่</span>`,
|
||||||
cancel: true,
|
html: true,
|
||||||
|
cancel: {
|
||||||
|
label: 'ยกเลิก',
|
||||||
|
color: 'grey',
|
||||||
|
flat: true
|
||||||
|
},
|
||||||
|
ok: {
|
||||||
|
label: 'ลบหมวดหมู่',
|
||||||
|
color: 'negative'
|
||||||
|
},
|
||||||
persistent: true
|
persistent: true
|
||||||
}).onOk(async () => {
|
}).onOk(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="flex flex-wrap gap-2 mb-4">
|
<div class="flex flex-wrap gap-2 mb-4">
|
||||||
<q-badge :color="getStatusColor(course.status)" :label="getStatusLabel(course.status)" />
|
<q-badge :color="getStatusColor(course.status)" :label="getStatusLabel(course.status)" />
|
||||||
<q-badge color="grey" :label="course.category.name.th" />
|
<q-badge color="grey" :label="course.category?.name?.th || 'ไม่มีหมวดหมู่'" />
|
||||||
<q-badge v-if="course.is_free" color="green" label="ฟรี" />
|
<q-badge v-if="course.is_free" color="green" label="ฟรี" />
|
||||||
<q-badge v-else color="blue" :label="`฿${course.price.toLocaleString()}`" />
|
<q-badge v-else color="blue" :label="`฿${course.price.toLocaleString()}`" />
|
||||||
<q-badge v-if="course.have_certificate" color="purple" label="มีใบประกาศนียบัตร" />
|
<q-badge v-if="course.have_certificate" color="purple" label="มีใบประกาศนียบัตร" />
|
||||||
|
|
@ -356,23 +356,7 @@ const getActionColor = (action: string) => {
|
||||||
return colors[action] || 'grey';
|
return colors[action] || 'grey';
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (date: string) => {
|
// Date formatting functions are auto-imported from utils/date.ts
|
||||||
return new Date(date).toLocaleDateString('th-TH', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
year: '2-digit'
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDateTime = (date: string) => {
|
|
||||||
return new Date(date).toLocaleDateString('th-TH', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
year: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmApprove = () => {
|
const confirmApprove = () => {
|
||||||
if (!course.value) return;
|
if (!course.value) return;
|
||||||
|
|
|
||||||
|
|
@ -135,7 +135,7 @@
|
||||||
<div v-if="course.latest_submission" class="mt-3 text-sm text-gray-500">
|
<div v-if="course.latest_submission" class="mt-3 text-sm text-gray-500">
|
||||||
<q-icon name="send" size="16px" class="mr-1" />
|
<q-icon name="send" size="16px" class="mr-1" />
|
||||||
ส่งโดย {{ course.latest_submission.submitter.username }}
|
ส่งโดย {{ course.latest_submission.submitter.username }}
|
||||||
เมื่อ {{ formatDate(course.latest_submission.created_at) }}
|
เมื่อ {{ formatDateTime(course.latest_submission.created_at) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -203,7 +203,7 @@
|
||||||
<template v-slot:body-cell-submitted_at="props">
|
<template v-slot:body-cell-submitted_at="props">
|
||||||
<q-td :props="props">
|
<q-td :props="props">
|
||||||
<div v-if="props.row.latest_submission">
|
<div v-if="props.row.latest_submission">
|
||||||
<div class="text-xs">{{ formatDate(props.row.latest_submission.created_at) }}</div>
|
<div class="text-xs">{{ formatDateTime(props.row.latest_submission.created_at) }}</div>
|
||||||
<div class="text-xs text-gray-500">โดย {{ props.row.latest_submission.submitter.username }}</div>
|
<div class="text-xs text-gray-500">โดย {{ props.row.latest_submission.submitter.username }}</div>
|
||||||
</div>
|
</div>
|
||||||
<span v-else>-</span>
|
<span v-else>-</span>
|
||||||
|
|
@ -298,15 +298,7 @@ const getPrimaryInstructor = (course: PendingCourse) => {
|
||||||
return primary?.user.username || course.creator.username;
|
return primary?.user.username || course.creator.username;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (date: string) => {
|
// Date formatting function is auto-imported from utils/date.ts
|
||||||
return new Date(date).toLocaleDateString('th-TH', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
year: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const viewCourse = (course: PendingCourse) => {
|
const viewCourse = (course: PendingCourse) => {
|
||||||
router.push(`/admin/courses/${course.id}`);
|
router.push(`/admin/courses/${course.id}`);
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,7 @@
|
||||||
<p class="text-xs text-gray-500 truncate">โดย {{ course.creator.username }}</p>
|
<p class="text-xs text-gray-500 truncate">โดย {{ course.creator.username }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-400 whitespace-nowrap">
|
<div class="text-xs text-gray-400 whitespace-nowrap">
|
||||||
{{ formatDate(course.created_at) }}
|
{{ formatDateStr(course.created_at) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -170,7 +170,7 @@
|
||||||
<span class="text-gray-600 mx-1">{{ formatAction(log.action) }}</span>
|
<span class="text-gray-600 mx-1">{{ formatAction(log.action) }}</span>
|
||||||
<span class="text-primary-700 font-medium">{{ log.entity_type }} #{{ log.entity_id }}</span>
|
<span class="text-primary-700 font-medium">{{ log.entity_type }} #{{ log.entity_id }}</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-gray-400 mt-0.5">{{ formatDate(log.created_at) }}</p>
|
<p class="text-xs text-gray-400 mt-0.5">{{ formatDateStr(log.created_at) }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -254,14 +254,7 @@ const fetchDashboardData = async () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Utilities
|
// Utilities
|
||||||
const formatDate = (date: string) => {
|
const formatDateStr = (date: string) => formatDateTime(date);
|
||||||
return new Date(date).toLocaleDateString('th-TH', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getActionIcon = (action: string) => {
|
const getActionIcon = (action: string) => {
|
||||||
if (action.includes('create')) return 'add_circle';
|
if (action.includes('create')) return 'add_circle';
|
||||||
|
|
|
||||||
|
|
@ -301,7 +301,7 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useQuasar } from 'quasar';
|
import { useQuasar } from 'quasar';
|
||||||
import { userService, type UserProfileResponse } from '~/services/user.service';
|
import { userService } from '~/services/user.service';
|
||||||
import { authService } from '~/services/auth.service';
|
import { authService } from '~/services/auth.service';
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
|
|
@ -368,20 +368,8 @@ const getRoleLabel = (role: string) => {
|
||||||
return labels[role] || role;
|
return labels[role] || role;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (date: string, includeTime = true) => {
|
// Use formatting utilities from utils/date.ts
|
||||||
const options: Intl.DateTimeFormatOptions = {
|
// Format functions are auto-imported
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
year: '2-digit'
|
|
||||||
};
|
|
||||||
|
|
||||||
if (includeTime) {
|
|
||||||
options.hour = '2-digit';
|
|
||||||
options.minute = '2-digit';
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Date(date).toLocaleDateString('th-TH', options);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Avatar upload
|
// Avatar upload
|
||||||
const avatarInputRef = ref<HTMLInputElement | null>(null);
|
const avatarInputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
|
@ -425,8 +413,8 @@ const handleAvatarUpload = async (event: Event) => {
|
||||||
try {
|
try {
|
||||||
const response = await userService.uploadAvatar(file);
|
const response = await userService.uploadAvatar(file);
|
||||||
|
|
||||||
// Re-fetch profile to get presigned URL from backend
|
// Force refresh profile cache and update local state
|
||||||
await fetchProfile();
|
await fetchProfile(true);
|
||||||
|
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
|
|
@ -457,8 +445,8 @@ const handleUpdateProfile = async () => {
|
||||||
phone: editForm.value.phone || null
|
phone: editForm.value.phone || null
|
||||||
});
|
});
|
||||||
|
|
||||||
// Refresh profile data from API
|
// Force refresh profile cache and update local state
|
||||||
await fetchProfile();
|
await fetchProfile(true);
|
||||||
|
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
|
|
@ -546,25 +534,29 @@ watch(showEditModal, (newVal) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch profile from API
|
// Helper to map fullProfile to local profile state
|
||||||
const fetchProfile = async () => {
|
const mapProfileData = (data: typeof authStore.fullProfile) => {
|
||||||
|
if (!data) return;
|
||||||
|
profile.value = {
|
||||||
|
fullName: `${data.profile.first_name} ${data.profile.last_name}`,
|
||||||
|
email: data.email,
|
||||||
|
emailVerified: !!data.email_verified_at,
|
||||||
|
username: data.username,
|
||||||
|
phone: data.profile.phone || '',
|
||||||
|
role: data.role.code,
|
||||||
|
roleName: data.role.name.th,
|
||||||
|
avatar: '',
|
||||||
|
avatarUrl: data.profile.avatar_url,
|
||||||
|
createdAt: data.created_at
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch profile — uses auth store cache, force=true to refresh
|
||||||
|
const fetchProfile = async (force = false) => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const data = await userService.getProfile();
|
await authStore.fetchUserProfile(force);
|
||||||
|
mapProfileData(authStore.fullProfile);
|
||||||
// Map API response to profile
|
|
||||||
profile.value = {
|
|
||||||
fullName: `${data.profile.first_name} ${data.profile.last_name}`,
|
|
||||||
email: data.email,
|
|
||||||
emailVerified: !!data.email_verified_at,
|
|
||||||
username: data.username,
|
|
||||||
phone: data.profile.phone || '',
|
|
||||||
role: data.role.code,
|
|
||||||
roleName: data.role.name.th,
|
|
||||||
avatar: '',
|
|
||||||
avatarUrl: data.profile.avatar_url,
|
|
||||||
createdAt: data.created_at
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'negative',
|
type: 'negative',
|
||||||
|
|
@ -576,7 +568,7 @@ const fetchProfile = async () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load profile on mount
|
// Load profile on mount (uses cache if available)
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchProfile();
|
fetchProfile();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -153,7 +153,8 @@
|
||||||
<!-- Category -->
|
<!-- Category -->
|
||||||
<div class="bg-gray-50 p-4 rounded-lg gap-2">
|
<div class="bg-gray-50 p-4 rounded-lg gap-2">
|
||||||
<div class="font-bold mb-2">หมวดหมู่ (Category):</div>
|
<div class="font-bold mb-2">หมวดหมู่ (Category):</div>
|
||||||
<div class="text-gray-700 mb-2">{{ selectedCourse.category.name.th }} ({{ selectedCourse.category.name.en }})</div>
|
<div v-if="selectedCourse.category" class="text-gray-700 mb-2">{{ selectedCourse.category.name.th }} ({{ selectedCourse.category.name.en }})</div>
|
||||||
|
<div v-else class="text-gray-400 italic mb-2">ไม่มีหมวดหมู่</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Instructors -->
|
<!-- Instructors -->
|
||||||
|
|
@ -262,7 +263,7 @@ const columns = [
|
||||||
field: (row: RecommendedCourse) => row.instructors?.find((i: any) => i.is_primary)?.user.username || '',
|
field: (row: RecommendedCourse) => row.instructors?.find((i: any) => i.is_primary)?.user.username || '',
|
||||||
align: 'left' as const
|
align: 'left' as const
|
||||||
},
|
},
|
||||||
{ name: 'category', label: 'Category', field: (row: RecommendedCourse) => row.category.name.th, sortable: true, align: 'left' as const },
|
{ name: 'category', label: 'Category', field: (row: RecommendedCourse) => row.category?.name?.th || 'ไม่มีหมวดหมู่', sortable: true, align: 'left' as const },
|
||||||
{ name: 'price', label: 'Price', field: 'price', sortable: true, align: 'right' as const },
|
{ name: 'price', label: 'Price', field: 'price', sortable: true, align: 'right' as const },
|
||||||
{ name: 'is_recommended', label: 'Recommended', field: 'is_recommended', sortable: true, align: 'center' as const },
|
{ name: 'is_recommended', label: 'Recommended', field: 'is_recommended', sortable: true, align: 'center' as const },
|
||||||
{ name: 'actions', label: 'Actions', field: 'actions', align: 'center' as const },
|
{ name: 'actions', label: 'Actions', field: 'actions', align: 'center' as const },
|
||||||
|
|
|
||||||
|
|
@ -324,13 +324,7 @@ const getRoleBadgeColor = (roleCode: string) => {
|
||||||
return colors[roleCode] || 'grey';
|
return colors[roleCode] || 'grey';
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (date: string) => {
|
// Date formatting function is auto-imported from utils/date.ts
|
||||||
return new Date(date).toLocaleDateString('th-TH', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
year: '2-digit'
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const viewUser = (user: AdminUserResponse) => {
|
const viewUser = (user: AdminUserResponse) => {
|
||||||
selectedUser.value = user;
|
selectedUser.value = user;
|
||||||
|
|
|
||||||
|
|
@ -449,13 +449,7 @@ const getStatusLabel = (status: string) => {
|
||||||
return labels[status] || status;
|
return labels[status] || status;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (date: string) => {
|
// Date formatting function is auto-imported from utils/date.ts
|
||||||
return new Date(date).toLocaleDateString('th-TH', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
year: '2-digit'
|
|
||||||
});
|
|
||||||
};
|
|
||||||
// Clone Dialog
|
// Clone Dialog
|
||||||
const cloneDialog = ref(false);
|
const cloneDialog = ref(false);
|
||||||
const cloneLoading = ref(false);
|
const cloneLoading = ref(false);
|
||||||
|
|
|
||||||
|
|
@ -64,21 +64,21 @@
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
<q-card class="p-6 text-center">
|
<q-card class="p-6 text-center">
|
||||||
<div class="text-4xl font-bold text-primary-600 mb-2">
|
<div class="text-4xl font-bold text-primary-600 mb-2">
|
||||||
{{ instructorStore.stats.totalCourses }}
|
{{ stats.totalCourses }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-gray-600">หลักสูตรทั้งหมด</div>
|
<div class="text-gray-600">หลักสูตรทั้งหมด</div>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
||||||
<q-card class="p-6 text-center">
|
<q-card class="p-6 text-center">
|
||||||
<div class="text-4xl font-bold text-secondary-600 mb-2">
|
<div class="text-4xl font-bold text-secondary-600 mb-2">
|
||||||
{{ instructorStore.stats.totalStudents }}
|
{{ stats.totalStudents }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-gray-600">ผู้เรียนทั้งหมด</div>
|
<div class="text-gray-600">ผู้เรียนทั้งหมด</div>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
||||||
<q-card class="p-6 text-center">
|
<q-card class="p-6 text-center">
|
||||||
<div class="text-4xl font-bold text-accent-600 mb-2">
|
<div class="text-4xl font-bold text-accent-600 mb-2">
|
||||||
{{ instructorStore.stats.completedStudents }}
|
{{ stats.completedStudents }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-gray-600">เรียนจบแล้ว</div>
|
<div class="text-gray-600">เรียนจบแล้ว</div>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
@ -96,28 +96,28 @@
|
||||||
<q-icon name="check_circle" color="green" size="24px" />
|
<q-icon name="check_circle" color="green" size="24px" />
|
||||||
<span class="font-medium text-gray-700">เผยแพร่แล้ว</span>
|
<span class="font-medium text-gray-700">เผยแพร่แล้ว</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-2xl font-bold text-green-600">{{ instructorStore.courseStatusCounts.approved }}</span>
|
<span class="text-2xl font-bold text-green-600">{{ courseStatusCounts.approved }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between p-3 bg-orange-50 rounded-lg">
|
<div class="flex items-center justify-between p-3 bg-orange-50 rounded-lg">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<q-icon name="hourglass_empty" color="orange" size="24px" />
|
<q-icon name="hourglass_empty" color="orange" size="24px" />
|
||||||
<span class="font-medium text-gray-700">รอตรวจสอบ</span>
|
<span class="font-medium text-gray-700">รอตรวจสอบ</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-2xl font-bold text-orange-600">{{ instructorStore.courseStatusCounts.pending }}</span>
|
<span class="text-2xl font-bold text-orange-600">{{ courseStatusCounts.pending }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<q-icon name="edit_note" color="grey" size="24px" />
|
<q-icon name="edit_note" color="grey" size="24px" />
|
||||||
<span class="font-medium text-gray-700">แบบร่าง</span>
|
<span class="font-medium text-gray-700">แบบร่าง</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-2xl font-bold text-gray-600">{{ instructorStore.courseStatusCounts.draft }}</span>
|
<span class="text-2xl font-bold text-gray-600">{{ courseStatusCounts.draft }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="instructorStore.courseStatusCounts.rejected > 0" class="flex items-center justify-between p-3 bg-red-50 rounded-lg">
|
<div v-if="courseStatusCounts.rejected > 0" class="flex items-center justify-between p-3 bg-red-50 rounded-lg">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<q-icon name="cancel" color="red" size="24px" />
|
<q-icon name="cancel" color="red" size="24px" />
|
||||||
<span class="font-medium text-gray-700">ถูกปฏิเสธ</span>
|
<span class="font-medium text-gray-700">ถูกปฏิเสธ</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-2xl font-bold text-red-600">{{ instructorStore.courseStatusCounts.rejected }}</span>
|
<span class="text-2xl font-bold text-red-600">{{ courseStatusCounts.rejected }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
@ -138,7 +138,7 @@
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<q-card
|
<q-card
|
||||||
v-for="course in instructorStore.recentCourses"
|
v-for="course in recentCourses"
|
||||||
:key="course.id"
|
:key="course.id"
|
||||||
class="cursor-pointer hover:shadow-md transition"
|
class="cursor-pointer hover:shadow-md transition"
|
||||||
@click="router.push(`/instructor/courses/${course.id}`)"
|
@click="router.push(`/instructor/courses/${course.id}`)"
|
||||||
|
|
@ -172,6 +172,7 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useQuasar } from 'quasar';
|
import { useQuasar } from 'quasar';
|
||||||
|
import { instructorService } from '~/services/instructor.service';
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'instructor',
|
layout: 'instructor',
|
||||||
|
|
@ -179,10 +180,32 @@ definePageMeta({
|
||||||
});
|
});
|
||||||
|
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const instructorStore = useInstructorStore();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const $q = useQuasar();
|
const $q = useQuasar();
|
||||||
|
|
||||||
|
// Dashboard local state
|
||||||
|
const stats = ref({
|
||||||
|
totalCourses: 0,
|
||||||
|
totalStudents: 0,
|
||||||
|
completedStudents: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const courseStatusCounts = ref({
|
||||||
|
approved: 0,
|
||||||
|
pending: 0,
|
||||||
|
draft: 0,
|
||||||
|
rejected: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const recentCourses = ref<{
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
students: number;
|
||||||
|
lessons: number;
|
||||||
|
icon: string;
|
||||||
|
thumbnail: string | null;
|
||||||
|
}[]>([]);
|
||||||
|
|
||||||
// Navigation functions
|
// Navigation functions
|
||||||
const goToProfile = () => {
|
const goToProfile = () => {
|
||||||
router.push('/instructor/profile');
|
router.push('/instructor/profile');
|
||||||
|
|
@ -212,9 +235,41 @@ const handleLogout = () => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch dashboard data on mount
|
// Fetch dashboard data
|
||||||
|
const fetchDashboardData = async () => {
|
||||||
|
try {
|
||||||
|
const [courses, studentStats] = await Promise.all([
|
||||||
|
instructorService.getCourses(),
|
||||||
|
instructorService.getMyStudentsStats()
|
||||||
|
]);
|
||||||
|
|
||||||
|
stats.value.totalCourses = courses.length;
|
||||||
|
stats.value.totalStudents = studentStats.total_students;
|
||||||
|
stats.value.completedStudents = studentStats.total_completed;
|
||||||
|
|
||||||
|
courseStatusCounts.value = {
|
||||||
|
approved: courses.filter(c => c.status === 'APPROVED').length,
|
||||||
|
pending: courses.filter(c => c.status === 'PENDING').length,
|
||||||
|
draft: courses.filter(c => c.status === 'DRAFT').length,
|
||||||
|
rejected: courses.filter(c => c.status === 'REJECTED').length
|
||||||
|
};
|
||||||
|
|
||||||
|
recentCourses.value = courses.slice(0, 3).map(course => ({
|
||||||
|
id: course.id,
|
||||||
|
title: course.title.th,
|
||||||
|
students: 0,
|
||||||
|
lessons: 0,
|
||||||
|
icon: 'book',
|
||||||
|
thumbnail: course.thumbnail_url || null
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch dashboard data:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch data on mount
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
instructorStore.fetchDashboardData();
|
|
||||||
authStore.fetchUserProfile();
|
authStore.fetchUserProfile();
|
||||||
|
fetchDashboardData();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -301,7 +301,7 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useQuasar } from 'quasar';
|
import { useQuasar } from 'quasar';
|
||||||
import { userService, type UserProfileResponse } from '~/services/user.service';
|
import { userService } from '~/services/user.service';
|
||||||
import { authService } from '~/services/auth.service';
|
import { authService } from '~/services/auth.service';
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
|
|
@ -368,20 +368,8 @@ const getRoleLabel = (role: string) => {
|
||||||
return labels[role] || role;
|
return labels[role] || role;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (date: string, includeTime = true) => {
|
// Use formatting utilities from utils/date.ts
|
||||||
const options: Intl.DateTimeFormatOptions = {
|
// Format functions are auto-imported
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
year: '2-digit'
|
|
||||||
};
|
|
||||||
|
|
||||||
if (includeTime) {
|
|
||||||
options.hour = '2-digit';
|
|
||||||
options.minute = '2-digit';
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Date(date).toLocaleDateString('th-TH', options);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Avatar upload
|
// Avatar upload
|
||||||
const avatarInputRef = ref<HTMLInputElement | null>(null);
|
const avatarInputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
|
@ -425,8 +413,8 @@ const handleAvatarUpload = async (event: Event) => {
|
||||||
try {
|
try {
|
||||||
const response = await userService.uploadAvatar(file);
|
const response = await userService.uploadAvatar(file);
|
||||||
|
|
||||||
// Re-fetch profile to get presigned URL from backend
|
// Force refresh profile cache and update local state
|
||||||
await fetchProfile();
|
await fetchProfile(true);
|
||||||
|
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
|
|
@ -457,8 +445,8 @@ const handleUpdateProfile = async () => {
|
||||||
phone: editForm.value.phone || null
|
phone: editForm.value.phone || null
|
||||||
});
|
});
|
||||||
|
|
||||||
// Refresh profile data from API
|
// Force refresh profile cache and update local state
|
||||||
await fetchProfile();
|
await fetchProfile(true);
|
||||||
|
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
|
|
@ -546,25 +534,29 @@ watch(showEditModal, (newVal) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch profile from API
|
// Helper to map fullProfile to local profile state
|
||||||
const fetchProfile = async () => {
|
const mapProfileData = (data: typeof authStore.fullProfile) => {
|
||||||
|
if (!data) return;
|
||||||
|
profile.value = {
|
||||||
|
fullName: `${data.profile.first_name} ${data.profile.last_name}`,
|
||||||
|
email: data.email,
|
||||||
|
emailVerified: !!data.email_verified_at,
|
||||||
|
username: data.username,
|
||||||
|
phone: data.profile.phone || '',
|
||||||
|
role: data.role.code,
|
||||||
|
roleName: data.role.name.th,
|
||||||
|
avatar: '',
|
||||||
|
avatarUrl: data.profile.avatar_url,
|
||||||
|
createdAt: data.created_at
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch profile — uses auth store cache, force=true to refresh
|
||||||
|
const fetchProfile = async (force = false) => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const data = await userService.getProfile();
|
await authStore.fetchUserProfile(force);
|
||||||
|
mapProfileData(authStore.fullProfile);
|
||||||
// Map API response to profile
|
|
||||||
profile.value = {
|
|
||||||
fullName: `${data.profile.first_name} ${data.profile.last_name}`,
|
|
||||||
email: data.email,
|
|
||||||
emailVerified: !!data.email_verified_at,
|
|
||||||
username: data.username,
|
|
||||||
phone: data.profile.phone || '',
|
|
||||||
role: data.role.code,
|
|
||||||
roleName: data.role.name.th,
|
|
||||||
avatar: '',
|
|
||||||
avatarUrl: data.profile.avatar_url,
|
|
||||||
createdAt: data.created_at
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'negative',
|
type: 'negative',
|
||||||
|
|
@ -576,7 +568,7 @@ const fetchProfile = async () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load profile on mount
|
// Load profile on mount (uses cache if available)
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchProfile();
|
fetchProfile();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@
|
||||||
<q-input
|
<q-input
|
||||||
v-model="form.email"
|
v-model="form.email"
|
||||||
label="อีเมล *"
|
label="อีเมล *"
|
||||||
type="email"
|
type="text"
|
||||||
outlined
|
outlined
|
||||||
:rules="[
|
:rules="[
|
||||||
val => !!val || 'กรุณากรอกอีเมล',
|
val => !!val || 'กรุณากรอกอีเมล',
|
||||||
|
|
|
||||||
|
|
@ -34,11 +34,11 @@ export default defineConfig({
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://localhost:3000/',// ปรับเป็น URL ที่ทดสอบ
|
baseURL: 'http://localhost:3000/',// ปรับเป็น URL ที่ทดสอบ
|
||||||
headless: false, // false = เห็น browser ขณะรัน
|
headless: false, // false = เห็น browser ขณะรัน
|
||||||
screenshot: 'only-on-failure', // เก็บ screenshot เมื่อ fail
|
screenshot: 'on', // เก็บ screenshot
|
||||||
trace: 'retain-on-failure', // เก็บ trace เพื่อดีบักเมื่อ fail
|
trace: 'retain-on-failure', // เก็บ trace เพื่อดีบักเมื่อ fail
|
||||||
// launchOptions: {
|
launchOptions: {
|
||||||
// slowMo: 1000,
|
slowMo: 500,
|
||||||
// }, // ช้าลง 10 วินาที
|
}, // ช้าลง 10 วินาที
|
||||||
},
|
},
|
||||||
|
|
||||||
/* ──── Setup Projects: login ครั้งเดียว แล้ว save cookies ──── */
|
/* ──── Setup Projects: login ครั้งเดียว แล้ว save cookies ──── */
|
||||||
|
|
|
||||||
|
|
@ -3,35 +3,20 @@ export interface LoginRequest {
|
||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// API Response structure (from backend)
|
// API Response structure (from backend) - new format: only token/refreshToken
|
||||||
export interface ApiLoginResponse {
|
export interface ApiLoginResponse {
|
||||||
token: string;
|
token: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
user: {
|
}
|
||||||
id: number;
|
|
||||||
username: string;
|
// JWT Payload structure (decoded from token)
|
||||||
email: string;
|
export interface JwtPayload {
|
||||||
updated_at: string;
|
id: number;
|
||||||
created_at: string;
|
username: string;
|
||||||
role: {
|
email: string;
|
||||||
code: string;
|
roleCode: string;
|
||||||
name: {
|
iat: number;
|
||||||
en: string;
|
exp: number;
|
||||||
th: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
profile: {
|
|
||||||
prefix: {
|
|
||||||
en: string;
|
|
||||||
th: string;
|
|
||||||
};
|
|
||||||
first_name: string;
|
|
||||||
last_name: string;
|
|
||||||
phone: string | null;
|
|
||||||
avatar_url: string | null;
|
|
||||||
birth_date: string | null;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Frontend User structure
|
// Frontend User structure
|
||||||
|
|
@ -55,6 +40,21 @@ export interface ApiResponse<T> {
|
||||||
data: T;
|
data: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode JWT payload without verification (read-only)
|
||||||
|
* Verification is handled by the backend on each request
|
||||||
|
*/
|
||||||
|
function decodeJwtPayload(token: string): JwtPayload {
|
||||||
|
const base64Url = token.split('.')[1];
|
||||||
|
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const jsonPayload = decodeURIComponent(
|
||||||
|
atob(base64).split('').map(c =>
|
||||||
|
'%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
|
||||||
|
).join('')
|
||||||
|
);
|
||||||
|
return JSON.parse(jsonPayload);
|
||||||
|
}
|
||||||
|
|
||||||
export const authService = {
|
export const authService = {
|
||||||
async login(email: string, password: string): Promise<LoginResponse> {
|
async login(email: string, password: string): Promise<LoginResponse> {
|
||||||
const config = useRuntimeConfig();
|
const config = useRuntimeConfig();
|
||||||
|
|
@ -71,22 +71,26 @@ export const authService = {
|
||||||
|
|
||||||
const loginData = response.data;
|
const loginData = response.data;
|
||||||
|
|
||||||
|
// Decode JWT to get user info
|
||||||
|
const payload = decodeJwtPayload(loginData.token);
|
||||||
|
|
||||||
// Check if user role is STUDENT - block login
|
// Check if user role is STUDENT - block login
|
||||||
if (loginData.user.role.code === 'STUDENT') {
|
if (payload.roleCode === 'STUDENT') {
|
||||||
throw new Error('ไม่สามารถเข้าสู่ระบบได้ ระบบนี้สำหรับผู้สอนและผู้ดูแลระบบเท่านั้น');
|
throw new Error('ไม่สามารถเข้าสู่ระบบได้ ระบบนี้สำหรับผู้สอนและผู้ดูแลระบบเท่านั้น');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transform API response to frontend format
|
// Return basic user info from JWT payload
|
||||||
|
// Full profile will be fetched via fetchUserProfile() in the auth store
|
||||||
return {
|
return {
|
||||||
token: loginData.token,
|
token: loginData.token,
|
||||||
refreshToken: loginData.refreshToken,
|
refreshToken: loginData.refreshToken,
|
||||||
user: {
|
user: {
|
||||||
id: loginData.user.id.toString(),
|
id: payload.id.toString(),
|
||||||
email: loginData.user.email,
|
email: payload.email,
|
||||||
firstName: loginData.user.profile.first_name,
|
firstName: '',
|
||||||
lastName: loginData.user.profile.last_name,
|
lastName: '',
|
||||||
role: loginData.user.role.code,
|
role: payload.roleCode,
|
||||||
avatarUrl: loginData.user.profile.avatar_url
|
avatarUrl: null
|
||||||
},
|
},
|
||||||
message: response.message || 'เข้าสู่ระบบสำเร็จ'
|
message: response.message || 'เข้าสู่ระบบสำเร็จ'
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -610,6 +610,19 @@ export const instructorService = {
|
||||||
{ method: 'DELETE' }
|
{ method: 'DELETE' }
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
async getMyStudentsStats(): Promise<{ total_students: number; total_completed: number }> {
|
||||||
|
const response = await authRequest<{
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
total_students: number;
|
||||||
|
total_completed: number;
|
||||||
|
}>('/api/instructors/courses/my-students');
|
||||||
|
return {
|
||||||
|
total_students: response.total_students,
|
||||||
|
total_completed: response.total_completed
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
async getCourseApprovalHistory(courseId: number): Promise<ApprovalHistory[]> {
|
async getCourseApprovalHistory(courseId: number): Promise<ApprovalHistory[]> {
|
||||||
const response = await authRequest<{
|
const response = await authRequest<{
|
||||||
code: number;
|
code: number;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { authService } from '~/services/auth.service';
|
import { authService } from '~/services/auth.service';
|
||||||
import { userService } from '~/services/user.service';
|
import { userService, type UserProfileResponse } from '~/services/user.service';
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -15,7 +15,8 @@ export const useAuthStore = defineStore('auth', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
user: null as User | null,
|
user: null as User | null,
|
||||||
token: null as string | null,
|
token: null as string | null,
|
||||||
isAuthenticated: false
|
isAuthenticated: false,
|
||||||
|
fullProfile: null as UserProfileResponse | null
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getters: {
|
getters: {
|
||||||
|
|
@ -61,6 +62,7 @@ export const useAuthStore = defineStore('auth', {
|
||||||
this.user = null;
|
this.user = null;
|
||||||
this.token = null;
|
this.token = null;
|
||||||
this.isAuthenticated = false;
|
this.isAuthenticated = false;
|
||||||
|
this.fullProfile = null;
|
||||||
|
|
||||||
// Clear cookies
|
// Clear cookies
|
||||||
const tokenCookie = useCookie('token');
|
const tokenCookie = useCookie('token');
|
||||||
|
|
@ -126,10 +128,16 @@ export const useAuthStore = defineStore('auth', {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetchUserProfile() {
|
async fetchUserProfile(force = false) {
|
||||||
|
// Skip if already cached (unless force refresh)
|
||||||
|
if (!force && this.fullProfile) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await userService.getProfile();
|
const response = await userService.getProfile();
|
||||||
|
|
||||||
|
// Cache raw API response
|
||||||
|
this.fullProfile = response;
|
||||||
|
|
||||||
// Update local user state
|
// Update local user state
|
||||||
this.user = {
|
this.user = {
|
||||||
id: response.id.toString(),
|
id: response.id.toString(),
|
||||||
|
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
import { defineStore } from 'pinia';
|
|
||||||
import { instructorService } from '~/services/instructor.service';
|
|
||||||
|
|
||||||
interface Course {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
students: number;
|
|
||||||
lessons: number;
|
|
||||||
icon: string;
|
|
||||||
thumbnail: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DashboardStats {
|
|
||||||
totalCourses: number;
|
|
||||||
totalStudents: number;
|
|
||||||
completedStudents: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CourseStatusCounts {
|
|
||||||
approved: number;
|
|
||||||
pending: number;
|
|
||||||
draft: number;
|
|
||||||
rejected: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useInstructorStore = defineStore('instructor', {
|
|
||||||
state: () => ({
|
|
||||||
stats: {
|
|
||||||
totalCourses: 0,
|
|
||||||
totalStudents: 0,
|
|
||||||
completedStudents: 0
|
|
||||||
} as DashboardStats,
|
|
||||||
|
|
||||||
courseStatusCounts: {
|
|
||||||
approved: 0,
|
|
||||||
pending: 0,
|
|
||||||
draft: 0,
|
|
||||||
rejected: 0
|
|
||||||
} as CourseStatusCounts,
|
|
||||||
|
|
||||||
recentCourses: [] as Course[],
|
|
||||||
loading: false
|
|
||||||
}),
|
|
||||||
|
|
||||||
getters: {
|
|
||||||
getDashboardStats: (state) => state.stats,
|
|
||||||
getRecentCourses: (state) => state.recentCourses
|
|
||||||
},
|
|
||||||
|
|
||||||
actions: {
|
|
||||||
async fetchDashboardData() {
|
|
||||||
this.loading = true;
|
|
||||||
try {
|
|
||||||
// Fetch real courses from API
|
|
||||||
const courses = await instructorService.getCourses();
|
|
||||||
|
|
||||||
// Fetch student counts for each course
|
|
||||||
let totalStudents = 0;
|
|
||||||
let completedStudents = 0;
|
|
||||||
const courseDetails: Course[] = [];
|
|
||||||
|
|
||||||
for (const course of courses.slice(0, 5)) {
|
|
||||||
try {
|
|
||||||
// Get student counts
|
|
||||||
const studentsResponse = await instructorService.getEnrolledStudents(course.id, 1, 1);
|
|
||||||
const courseStudents = studentsResponse.total || 0;
|
|
||||||
totalStudents += courseStudents;
|
|
||||||
|
|
||||||
// Get completed count from full list (if small) or estimate
|
|
||||||
if (courseStudents > 0 && courseStudents <= 100) {
|
|
||||||
const allStudents = await instructorService.getEnrolledStudents(course.id, 1, 100);
|
|
||||||
completedStudents += allStudents.data.filter(s => s.status === 'COMPLETED').length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get lesson count from course detail
|
|
||||||
const courseDetail = await instructorService.getCourseById(course.id);
|
|
||||||
const lessonCount = courseDetail.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0);
|
|
||||||
|
|
||||||
courseDetails.push({
|
|
||||||
id: course.id,
|
|
||||||
title: course.title.th,
|
|
||||||
students: courseStudents,
|
|
||||||
lessons: lessonCount,
|
|
||||||
icon: 'book',
|
|
||||||
thumbnail: course.thumbnail_url || null
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
// Course might not have students endpoint
|
|
||||||
courseDetails.push({
|
|
||||||
id: course.id,
|
|
||||||
title: course.title.th,
|
|
||||||
students: 0,
|
|
||||||
lessons: 0,
|
|
||||||
icon: 'book',
|
|
||||||
thumbnail: course.thumbnail_url || null
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update stats
|
|
||||||
this.stats.totalCourses = courses.length;
|
|
||||||
this.stats.totalStudents = totalStudents;
|
|
||||||
this.stats.completedStudents = completedStudents;
|
|
||||||
|
|
||||||
// Update course status counts
|
|
||||||
this.courseStatusCounts = {
|
|
||||||
approved: courses.filter(c => c.status === 'APPROVED').length,
|
|
||||||
pending: courses.filter(c => c.status === 'PENDING').length,
|
|
||||||
draft: courses.filter(c => c.status === 'DRAFT').length,
|
|
||||||
rejected: courses.filter(c => c.status === 'REJECTED').length
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update recent courses (first 3)
|
|
||||||
this.recentCourses = courseDetails.slice(0, 3);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch dashboard data:', error);
|
|
||||||
} finally {
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 212 KiB |