Compare commits

...

8 commits

Author SHA1 Message Date
supalerk-ar66
e02da48f7c feat: Implement the course discovery and catalog page, including filtering, search, and course detail view.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 16s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 4s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 1s
2026-03-06 17:34:27 +07:00
Missez
ae32cfebe4 feat: add utils/date.ts and stores api/user/me
All checks were successful
Build and Deploy Frontend Management to Dev Server / Build Frontend Management Docker Image (push) Successful in 56s
Build and Deploy Frontend Management to Dev Server / Deploy E-learning Frontend Management to Dev Server (push) Successful in 4s
Build and Deploy Frontend Management to Dev Server / Notify Deployment Status (push) Successful in 1s
2026-03-06 17:33:01 +07:00
Missez
ea442d7815 del readme 2026-03-06 15:58:58 +07:00
Missez
ac768a3df4 add readme testresult
All checks were successful
Build and Deploy Frontend Management to Dev Server / Build Frontend Management Docker Image (push) Successful in 53s
Build and Deploy Frontend Management to Dev Server / Deploy E-learning Frontend Management to Dev Server (push) Successful in 4s
Build and Deploy Frontend Management to Dev Server / Notify Deployment Status (push) Successful in 1s
2026-03-06 15:53:13 +07:00
Missez
9e4fcbf04e Add tests result 2026-03-06 15:47:43 +07:00
supalerk-ar66
853c141910 feat: Add i18n support with English and Thai locales and introduce new browse pages.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 51s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 4s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 1s
2026-03-06 13:33:58 +07:00
supalerk-ar66
b0b665f588 feat: Implement E2E tests for authentication, student account, discovery, and classroom features, alongside new browse pages and a useAuth composable.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 48s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 4s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 1s
2026-03-06 12:43:49 +07:00
Missez
0205aab461 feat: Introduce core authentication service, several new admin management pages, and instructor feature tests.
All checks were successful
Build and Deploy Frontend Management to Dev Server / Build Frontend Management Docker Image (push) Successful in 52s
Build and Deploy Frontend Management to Dev Server / Deploy E-learning Frontend Management to Dev Server (push) Successful in 4s
Build and Deploy Frontend Management to Dev Server / Notify Deployment Status (push) Successful in 1s
2026-03-06 11:24:10 +07:00
169 changed files with 2652 additions and 1338 deletions

View file

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

View file

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

View file

@ -117,7 +117,11 @@
"foundTotal": "พบทั้งหมด", "foundTotal": "พบทั้งหมด",
"items": "รายการ", "items": "รายการ",
"subtitle": "เลือกเรียนรู้ทักษะใหม่ๆ จากหลักสูตรคุณภาพที่คัดสรรมาเพื่อคุณ", "subtitle": "เลือกเรียนรู้ทักษะใหม่ๆ จากหลักสูตรคุณภาพที่คัดสรรมาเพื่อคุณ",
"searchBtn": "ค้นหา" "searchBtn": "ค้นหา",
"allCategory": "ทั้งหมด",
"byInstructor": "โดย",
"students": "นักเรียน",
"viewDetails": "ดูรายละเอียด"
}, },
"myCourses": { "myCourses": {
"title": "คอร์สของฉัน", "title": "คอร์สของฉัน",

View file

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

View file

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

File diff suppressed because one or more lines are too long

View file

@ -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();
}); });
}); });

View file

@ -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) ควรมีปุ่มกลับ กับไอคอนแผงด้านข้าง test('6.1 เข้าห้องเรียนหลัก (Classroom Basic Layout)', async ({ page }) => {
const backBtn = page.getByRole('button').filter({ has: page.locator('i.q-icon', { hasText: 'arrow_back' }) }).first(); await page.goto(`${BASE_URL}/classroom/learning?course_id=1`);
await expect(backBtn).toBeVisible({ timeout: 15_000 });
const menuCurriculumBtn = page.getByRole('button').filter({ has: page.locator('i.q-icon', { hasText: 'menu_open' }) }).first(); // 1. โครงร่างของหน้า — ปุ่มกลับ + ไอคอนแผงด้านข้าง
await expect(menuCurriculumBtn).toBeVisible({ timeout: 15_000 }); const backBtn = page.getByRole('button').filter({ has: page.locator('i.q-icon', { hasText: 'arrow_back' }) }).first();
await expect(backBtn).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
// 2. เช็คว่ามีพื้นที่ Sidebar หลักสูตร (CurriculumSidebar Component) โผล่ขึ้นมาหรือมีอยู่ใน DOM const menuCurriculumBtn = page.getByRole('button').filter({ has: page.locator('i.q-icon', { hasText: 'menu_open' }) }).first();
const sidebar = page.locator('.q-drawer').first(); await expect(menuCurriculumBtn).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
if (!await sidebar.isVisible()) {
await menuCurriculumBtn.click();
}
await expect(sidebar).toBeVisible();
});
test('6.2 เช็คสถานะการเข้าถึงเนื้อหา (Access Control)', async ({ page }) => { // 2. Sidebar หลักสูตร
// ลองสุ่ม Course ID สูงๆ ที่อาจจะไม่อนุญาตให้เรียน (ไม่มีสิทธิ์) ควรรองรับกล่องแจ้งเตือนด้วย Alert ของระบบ const sidebar = page.locator('.q-drawer').first();
// ใน learning.vue จะมีการสั่ง `alert(msg)` แต่อาจจะต้องพึ่งกลไก Intercepter if (!await sidebar.isVisible()) {
await menuCurriculumBtn.click();
page.on('dialog', async dialog => { }
// หน้าต่าง Alert ถ้ามีสิทธิ์ไม่อนุญาตมันจะเด้งอันนี้ await expect(sidebar).toBeVisible();
expect(dialog.message()).toBeTruthy();
await dialog.accept();
}); });
await page.goto(`${BASE_URL}/classroom/learning?course_id=99999`); test('6.2 เช็คสถานะการเข้าถึงเนื้อหา (Access Control)', async ({ page }) => {
page.on('dialog', async dialog => {
expect(dialog.message()).toBeTruthy();
await dialog.accept();
});
// รอดู Loading หายไป 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: 20_000 }).catch(() => {}); 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 ถ้าเป็นบทเรียนวิดีโอ test('7.1 โหลดหน้า Quiz และเริ่มทำข้อสอบได้ (Start Screen)', async ({ page }) => {
const videoLocator = page.locator('video').first(); await mockQuizData(page);
await page.goto(`${BASE_URL}/classroom/quiz?course_id=2&lesson_id=17`);
// กรณีที่ 2: ถ้าบทแรกเป็น Quiz จะแสดงไอคอนแบบทดสอบ const startBtn = page.getByRole('button', { name: /เริ่มทำแบบทดสอบ|Start/i }).first();
const quizLocator = page.getByText(/เริ่มทำแบบทดสอบ|แบบทดสอบ/i).first(); await expect(startBtn).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
// กรณีที่ 3: ไม่มีบทเรียนเนื้อหาใดๆ เลยให้แสดง // กดเริ่มทำ
const errorLocator = page.getByText(/ไม่สามารถเข้าถึง/i).first(); await startBtn.click();
try { // เช็คว่าหน้า Taking (คำถามข้อที่ 1) โผล่มา
await Promise.race([ const questionText = page.locator('h3').first();
videoLocator.waitFor({ state: 'visible', timeout: 20_000 }), await expect(questionText).toBeVisible({ timeout: TIMEOUT.ELEMENT });
quizLocator.waitFor({ state: 'visible', timeout: 20_000 }), });
errorLocator.waitFor({ state: 'visible', timeout: 20_000 })
]);
const isOkay = (await videoLocator.isVisible()) || (await quizLocator.isVisible()) || (await errorLocator.isVisible()); test('7.2 แถบข้อสอบแบ่งหน้า (Pagination — เลื่อนซ้าย/ขวา)', async ({ page }) => {
expect(isOkay).toBeTruthy(); await mockQuizData(page);
} catch { await page.goto(`${BASE_URL}/classroom/quiz?course_id=2&lesson_id=17`);
// ถ้าไม่มีเลยใน 20 วิ ถือว่าหน้าอาจจะล้มเหลว หรือเป็น Content เปล่า
// ให้ลอง Capture เพื่อเก็บข้อมูลไปใช้งาน const startBtn = page.getByRole('button', { name: /เริ่มทำแบบทดสอบ|Start/i }).first();
await page.screenshot({ path: 'tests/e2e/screenshots/classroom-blank-state.png', fullPage: true }); 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 });
});
}); });
}); });

View file

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

View file

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

View 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);
}
}

View file

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

View file

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

View file

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

View file

@ -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()) { // หาปุ่มภาษา — ถ้าไม่เจอให้ test.skip แทนที่จะผ่านเงียบๆ
await langBtn.click(); const langBtn = page.getByRole('button', { name: 'Language' })
const englishOpt = page.locator('text=English, text=EN').first(); .or(page.locator('button').filter({ hasText: /TH|EN/ }))
await englishOpt.click(); .first();
const loginLink = page.locator('a[href*="login"], button').filter({ hasText: /Log in|Sign In/i }); const isLangBtnVisible = await langBtn.isVisible().catch(() => false);
await expect(loginLink).toBeVisible({ timeout: 5000 }); 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()
.or(page.locator('input[type="text"]').first());
if (await nameInput.isVisible()) { const isNameVisible = await nameInput.isVisible().catch(() => false);
const oldName = await nameInput.inputValue(); if (!isNameVisible) {
test.skip(true, 'Profile name input not found — skipping');
await nameInput.clear(); return;
await nameInput.fill(`${oldName}แก้ไข`);
const saveBtn = page.getByRole('button', { name: /บันทึก/i }).first();
if(await saveBtn.isVisible()) {
await saveBtn.click();
const successNotify = page.locator('.q-notification__message, text=อัปเดตข้อมูลสำเร็จ').first();
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 });
});
}); });
}); });

View file

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

View file

@ -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();

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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}`);

View file

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

View file

@ -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();
}); });

View file

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

View file

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

View file

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

View file

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

View file

@ -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();
}); });

View file

@ -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 || 'กรุณากรอกอีเมล',

View file

@ -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 ──── */

View file

@ -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 || 'เข้าสู่ระบบสำเร็จ'
}; };

View file

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

View file

@ -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(),

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show more