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)
const login = async (credentials: { email: string; password: string }) => {
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`, {
method: 'POST',
body: credentials
@ -49,16 +49,35 @@ export const useAuth = () => {
if (response && response.data) {
const data = response.data
// Validation: Ensure user and role exist, then check for Role 'STUDENT'
if (!data.user || !data.user.role || data.user.role.code !== 'STUDENT') {
// บันทึก Token ก่อน เพื่อใช้เรียก /user/me
token.value = data.token
refreshToken.value = data.refreshToken
// ดึงข้อมูลผู้ใช้จาก /user/me (เพราะ API login ไม่ส่ง 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 ไม่ถูกต้อง' }
}
token.value = data.token
refreshToken.value = data.refreshToken // บันทึก Refresh Token
// API ส่งข้อมูล profile มาใน user object
user.value = data.user
// เก็บข้อมูล 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 }
}

View file

@ -117,7 +117,11 @@
"foundTotal": "Found Total",
"items": "items",
"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": {
"title": "My Courses",

View file

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

View file

@ -27,7 +27,7 @@ const sortBy = ref('ยอดนิยม');
const sortOptions = ['ยอดนิยม', 'ล่าสุด', 'ราคาต่ำ-สูงสุด', 'ราคาสูง-ต่ำสุด'];
const categories = ref<any[]>([]);
const courses = ref<any[]>([]);
const allCourses = ref<any[]>([]); // client-side
const selectedCourse = ref<any>(null);
const isLoading = ref(false);
@ -76,20 +76,17 @@ const loadCategories = async () => {
if (res.success) categories.value = res.data || [];
};
const loadCourses = async (page = 1) => {
const loadCourses = async () => {
isLoading.value = true;
const categoryId = activeCategory.value === 'all' ? undefined : activeCategory.value as number;
// (limit client-side filter)
const res = await fetchCourses({
category_id: categoryId,
search: searchQuery.value,
page: page,
limit: itemsPerPage,
limit: 500,
forceRefresh: true,
});
if (res.success) {
courses.value = (res.data || []).map(c => {
allCourses.value = (res.data || []).map(c => {
const cat = categories.value.find(cat => cat.id === c.category_id);
return {
...c,
@ -100,12 +97,33 @@ const loadCourses = async (page = 1) => {
reviews_count: c.total_lessons ? c.total_lessons * 123 : Math.floor(Math.random() * 2000) + 100
}
});
totalPages.value = res.totalPages || 1;
currentPage.value = res.page || 1;
}
isLoading.value = false;
};
// Computed: real-time searchQuery + activeCategory
const filteredCourses = computed(() => {
let result = allCourses.value;
//
if (activeCategory.value !== 'all') {
result = result.filter(c => c.category_id === activeCategory.value);
}
// ( th en)
if (searchQuery.value.trim()) {
const query = searchQuery.value.trim().toLowerCase();
result = result.filter(c => {
const titleTh = (c.title?.th || '').toLowerCase();
const titleEn = (c.title?.en || '').toLowerCase();
const titleStr = (typeof c.title === 'string' ? c.title : '').toLowerCase();
return titleTh.includes(query) || titleEn.includes(query) || titleStr.includes(query);
});
}
return result;
});
const selectCourse = async (id: number) => {
isLoadingDetail.value = true;
selectedCourse.value = null;
@ -137,10 +155,10 @@ watch(
activeCategory,
() => {
currentPage.value = 1;
loadCourses(1);
}
);
onMounted(async () => {
await loadCategories();
@ -150,7 +168,7 @@ onMounted(async () => {
activeCategory.value = Number(route.query.category_id);
}
await loadCourses(1);
await loadCourses();
if (route.query.course_id) {
selectCourse(Number(route.query.course_id));
@ -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="max-w-[1240px] mx-auto">
<!-- วนของการคนหาคอร (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">
<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="relative w-full sm:w-[260px] flex-1">
<q-icon name="search" size="18px" class="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-[#3B6BE8]" />
<input v-model="searchQuery" @keyup.enter="loadCourses(1)" class="w-full bg-slate-100 dark:bg-slate-800 border-none rounded-xl py-2.5 pl-11 pr-4 text-sm font-medium text-slate-700 dark:text-slate-200 placeholder:text-slate-400 focus:ring-2 focus:ring-blue-500/20 outline-none transition-all shadow-sm" placeholder="ค้นหาคอร์ส..." />
<q-icon name="search" size="18px" class="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" />
<input v-model="searchQuery" class="w-full bg-slate-100 dark:bg-slate-800 border-none rounded-xl py-2.5 pl-11 pr-4 text-sm font-medium text-slate-700 dark:text-slate-200 placeholder:text-slate-400 focus:ring-2 focus:ring-blue-500/20 outline-none transition-all shadow-sm" :placeholder="$t('discovery.searchPlaceholder')" />
</div>
<div class="flex items-center gap-2 shrink-0">
<button @click="viewMode = 'grid'" :class="viewMode === 'grid' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-[#3B6BE8]' : 'bg-white border-slate-200 dark:bg-slate-800 dark:border-slate-700 text-slate-400 hover:bg-slate-50'" class="w-[42px] h-[42px] flex items-center justify-center rounded-xl border transition-colors outline-none"><q-icon name="grid_view" size="20px" /></button>
<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 = '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] 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>
@ -185,17 +203,17 @@ onMounted(async () => {
<div class="flex flex-wrap items-center gap-3 w-full xl:w-auto">
<button
@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">
<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
v-for="cat in categories" :key="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="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">
<q-icon :name="getCategoryIcon(cat.name)" size="18px" :class="activeCategory === cat.id ? 'text-[#3B6BE8]' : 'text-slate-600 dark:text-slate-400'"/>
: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">
<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) }}
</button>
</div>
@ -208,10 +226,10 @@ onMounted(async () => {
<q-spinner size="3rem" color="primary" />
</div>
<div v-else-if="courses.length > 0">
<div v-else-if="filteredCourses.length > 0">
<!-- GRID VIEW -->
<div v-if="viewMode === 'grid'" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<div v-for="course in courses" :key="course.id" class="flex flex-col rounded-[1.5rem] bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 overflow-hidden shadow-[0_2px_10px_rgb(0,0,0,0.03)] hover:shadow-[0_8px_30px_rgb(0,0,0,0.08)] transition-all duration-300 group cursor-pointer" @click="selectCourse(course.id)">
<div v-for="course in filteredCourses" :key="course.id" class="flex flex-col rounded-[1.5rem] bg-white dark:!bg-slate-900 border border-slate-100 dark:!border-slate-800 overflow-hidden shadow-[0_2px_10px_rgb(0,0,0,0.03)] hover:shadow-[0_8px_30px_rgb(0,0,0,0.08)] transition-all duration-300 group cursor-pointer" @click="selectCourse(course.id)">
<!-- Thumbnail -->
<div class="relative w-full aspect-[16/10] bg-slate-100 dark:bg-slate-800 overflow-hidden">
<img :src="course.thumbnail_url" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
@ -222,7 +240,7 @@ onMounted(async () => {
<!-- Body -->
<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'">
{{ course.formatted_price }}
</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-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">
<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">
<q-icon name="visibility" size="18px" />
</button>
</div>
@ -241,7 +258,7 @@ onMounted(async () => {
<!-- LIST VIEW -->
<div v-else class="flex flex-col gap-5">
<div v-for="course in courses" :key="course.id" class="flex flex-col sm:flex-row rounded-[1.5rem] bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 p-3 sm:p-4 gap-4 sm:gap-6 shadow-sm hover:shadow-[0_8px_30px_rgb(0,0,0,0.06)] transition-all duration-300 group cursor-pointer" @click="selectCourse(course.id)">
<div v-for="course in filteredCourses" :key="course.id" class="flex flex-col sm:flex-row rounded-[1.5rem] bg-white dark:!bg-slate-900 border border-slate-100 dark:!border-slate-800 p-3 sm:p-4 gap-4 sm:gap-6 shadow-sm hover:shadow-[0_8px_30px_rgb(0,0,0,0.06)] transition-all duration-300 group cursor-pointer" @click="selectCourse(course.id)">
<div class="relative w-full sm:w-[260px] aspect-[16/10] sm:aspect-auto sm:h-[160px] rounded-[1rem] bg-slate-100 dark:bg-slate-800 overflow-hidden shrink-0">
<img :src="course.thumbnail_url" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
<div v-if="course.category_name" class="absolute top-3 left-3 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md text-[#3B6BE8] dark:text-blue-400 font-bold text-[10px] px-3.5 py-1.5 rounded-full shadow-sm" style="border-radius: 9999px; padding: 4px 12px;">
@ -250,15 +267,15 @@ onMounted(async () => {
</div>
<div class="flex flex-col flex-1 py-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 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'">
{{ course.formatted_price }}
</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">
<q-icon name="visibility" size="16px" /> รายละเอยด
<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" /> {{ $t('discovery.viewDetails') }}
</button>
</div>
</div>
@ -272,9 +289,9 @@ onMounted(async () => {
</div>
<!-- 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" />
<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>
<button class="mt-6 font-bold text-blue-600 hover:text-blue-700 transition-colors" @click="searchQuery = ''; activeCategory = 'all';">
{{ $t("discovery.showAll") }}

View file

@ -13,6 +13,7 @@ useHead({
title: 'คอร์สทั้งหมด - E-Learning System'
})
const { t } = useI18n()
const searchQuery = ref('')
const { fetchCourses } = useCourse()
const { fetchCategories, categories } = useCategory()
@ -54,7 +55,7 @@ await useAsyncData('categories-list', () => fetchCategories())
const { data: coursesResponse, pending: isLoading, error, refresh } = await useAsyncData(
'browse-courses-list',
() => {
const params: any = {}
const params: any = { limit: 500 }
if (selectedCategory.value !== 'all') {
const category = categories.value.find(c => c.slug === selectedCategory.value)
if (category) {
@ -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">
<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="relative w-full sm:w-[260px] flex-1">
<q-icon name="search" size="18px" class="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-[#3B6BE8]" />
<input v-model="searchQuery" 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 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>
@ -151,7 +152,7 @@ const viewMode = ref<'grid' | 'list'>('grid')
@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="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
@ -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>
<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 class="flex items-center gap-1.5 mb-5">
<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-[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 class="mt-auto flex items-center justify-between">
@ -222,12 +223,12 @@ const viewMode = ref<'grid' | 'list'>('grid')
<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>
<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 class="flex items-center gap-1.5 mb-2">
<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-[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-4 sm:mt-auto flex items-center justify-between">
@ -235,7 +236,7 @@ const viewMode = ref<'grid' | 'list'>('grid')
{{ course.formatted_price }}
</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">
<q-icon name="visibility" size="16px" /> รายละเอยด
<q-icon name="visibility" size="16px" /> {{ $t('discovery.viewDetails') }}
</button>
</div>
</div>
@ -246,10 +247,10 @@ const viewMode = ref<'grid' | 'list'>('grid')
<!-- กรณไมพบขอมลคอร (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">
<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>
<p class="text-slate-500 dark:text-slate-400 text-center max-w-md">ลองใชคำคนหาอ หรอเลอกหมวดหมนเพอดคอรสทเรามใหบรการ</p>
<h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">{{ $t('discovery.emptyTitle') }}</h3>
<p class="text-slate-500 dark:text-slate-400 text-center max-w-md">{{ $t('discovery.emptyDesc') }}</p>
<button class="mt-6 font-bold text-blue-600 hover:text-blue-700 transition-colors" @click="searchQuery = ''; selectedCategory = 'all';">
แสดงคอรสทงหมด
{{ $t('discovery.showAll') }}
</button>
</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';
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);
}
// ---------------------------
// 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.');
}
import {
BASE_URL, TEST_EMAIL, TEST_PASSWORD, TIMEOUT,
waitAppSettled, expectAnyVisible,
emailLocator, passwordLocator, loginButtonLocator,
} from './helpers';
// ---------------------------
// Helpers: Register
@ -53,6 +26,7 @@ function regLoginLink(page: Page) { return page.getByRole('link', { name: 'เ
function regErrorBox(page: Page) {
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 = regPrefix(page);
await combo.selectOption({ label: value }).catch(async () => {
@ -60,6 +34,7 @@ async function pickPrefix(page: Page, value: 'นาย' | 'นาง' | 'นา
await page.getByRole('option', { name: value }).click();
});
}
function uniqueUser() {
const n = Date.now().toString().slice(-6);
const rand8 = Math.floor(Math.random() * 1e8).toString().padStart(8, '0');
@ -90,10 +65,12 @@ test.describe('ระบบยืนยันตัวตน (Authentication)',
test('Success Login แล้วเข้า /dashboard ได้', async ({ page }) => {
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
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 page.waitForURL('**/dashboard', { timeout: 25_000 });
await page.waitForURL('**/dashboard', { timeout: TIMEOUT.LOGIN });
await waitAppSettled(page);
const dashboardEvidence = [
@ -102,38 +79,45 @@ test.describe('ระบบยืนยันตัวตน (Authentication)',
page.locator('img[src*="avataaars"]').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 }) => {
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
await loginEmailLocator(page).fill('ทดสอบภาษาไทย');
await loginPasswordLocator(page).fill(LOGIN_PASSWORD);
const errorHint = page.getByText('ห้ามใส่ภาษาไทย');
await expect(errorHint.first()).toBeVisible({ timeout: 12_000 });
await emailLocator(page).fill('ทดสอบภาษาไทย');
await passwordLocator(page).fill(TEST_PASSWORD);
await expect(page.getByText('ห้ามใส่ภาษาไทย').first()).toBeVisible({ timeout: TIMEOUT.ELEMENT });
});
test('Invalid Email Format (อีเมลผิดรูปแบบ)', async ({ page }) => {
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
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 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 }) => {
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
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 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 }) => {
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
await expect(regHeading(page)).toBeVisible({ timeout: 15_000 });
await expect(regSubmit(page)).toBeVisible({ timeout: 15_000 });
await expect(regHeading(page)).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
await expect(regSubmit(page)).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
});
test('ลิงก์ "เข้าสู่ระบบ" ต้องกดแล้วไปหน้า login ได้', async ({ page }) => {
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
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 }) => {
const u = uniqueUser();
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
await regUsername(page).fill(u.username);
await regEmail(page).fill(u.email);
await pickPrefix(page, 'นาย');
@ -168,15 +153,18 @@ test.describe('ระบบยืนยันตัวตน (Authentication)',
await regSubmit(page).click();
await waitAppSettled(page);
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 = regErrorBox(page).first().waitFor({ state: 'visible', timeout: 25_000 }).then(() => 'error' as const).catch(() => null);
// รอ 3 สัญญาณ: redirect ไป login / success toast / error
const navToLogin = page.waitForURL('**/auth/login', { timeout: TIMEOUT.LOGIN, waitUntil: 'domcontentloaded' }).then(() => 'login' 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]);
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')) {
const hasSuccess = await page.getByText(/สมัครสมาชิกสำเร็จ/i, { exact: false }).first().isVisible().catch(() => false);
if (hasSuccess) {
@ -185,24 +173,28 @@ test.describe('ระบบยืนยันตัวตน (Authentication)',
}
}
await expect(page).toHaveURL(/\/auth\/login/i, { timeout: 15_000 });
await expect(page.getByRole('heading', { name: 'เข้าสู่ระบบ' })).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole('button', { name: 'เข้าสู่ระบบ' })).toBeVisible({ timeout: 15_000 });
await expect(page).toHaveURL(/\/auth\/login/i, { timeout: TIMEOUT.PAGE_LOAD });
await expect(page.getByRole('heading', { name: 'เข้าสู่ระบบ' })).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
await expect(page.getByRole('button', { name: 'เข้าสู่ระบบ' })).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
});
test('Invalid Email - ใส่ภาษาไทย ต้องขึ้น error', async ({ page }) => {
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
await regEmail(page).fill('ทดสอบภาษาไทย');
await regUsername(page).click();
const err = page.getByText(/ห้ามใส่ภาษาไทย|อีเมลไม่ถูกต้อง|รูปแบบอีเมล/i, { exact: false }).or(regErrorBox(page).filter({ hasText: /ไทย|อีเมล|email|invalid/i }));
await expect(err.first()).toBeVisible({ timeout: 12_000 });
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: TIMEOUT.ELEMENT });
});
test('Password ไม่ตรงกัน ต้องขึ้น error (ต้องกดสร้างบัญชีก่อน)', async ({ page }) => {
const u = uniqueUser();
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
await regUsername(page).fill(u.username);
await regEmail(page).fill(u.email);
await pickPrefix(page, 'นาย');
@ -210,11 +202,14 @@ test.describe('ระบบยืนยันตัวตน (Authentication)',
await regLastName(page).fill(u.lastName);
await regPhone(page).fill(u.phone);
await regPassword(page).fill('Admin12345!');
await regConfirmPassword(page).fill('Admin12345?');
await regConfirmPassword(page).fill('Admin12345?'); // mismatch
await regSubmit(page).click();
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 }) => {
await forgotEmail(page).fill('ฟฟฟฟ');
await page.getByRole('heading', { name: /ลืมรหัสผ่าน/i }).click();
const err = page.getByText(/ห้ามใส่ภาษาไทย/i).first();
await expect(err).toBeVisible({ timeout: 10_000 });
await expect(page.getByText(/ห้ามใส่ภาษาไทย/i).first()).toBeVisible({ timeout: TIMEOUT.ELEMENT });
});
test('กดลิงก์กลับไปหน้า Login ได้', async ({ page }) => {
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);
});
@ -257,9 +251,10 @@ test.describe('ระบบยืนยันตัวตน (Authentication)',
}
await route.continue();
});
await forgotEmail(page).fill('test@gmail.com');
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();
});
});

View file

@ -1,44 +1,77 @@
/**
* @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 { 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 page.waitForLoadState('domcontentloaded');
await page.waitForLoadState('networkidle').catch(() => {});
await page.waitForTimeout(200);
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: {}
})
});
});
}
// ฟังก์ชันจำลองล็อกอิน
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('ระบบห้องเรียนออนไลน์ (Classroom & Learning)', () => {
// ==========================================
// Tests
// ==========================================
test.describe('ระบบห้องเรียนออนไลน์และแบบทดสอบ (Classroom & Quiz)', () => {
test.beforeEach(async ({ page }) => {
await setupLogin(page);
});
// --------------------------------------------------
// Section 1: ห้องเรียน (Classroom & Learning)
// --------------------------------------------------
test.describe('ห้องเรียน (Classroom Layout & Access)', () => {
test('6.1 เข้าห้องเรียนหลัก (Classroom Basic Layout)', async ({ page }) => {
// สมมติว่ามี Course ID: 1 ทดสอบแบบเปิดหน้าตรงๆ
await page.goto(`${BASE_URL}/classroom/learning?course_id=1`);
// 1. โครงร่างของหน้า (Top Bar) ควรมีปุ่มกลับ กับไอคอนแผงด้านข้าง
// 1. โครงร่างของหน้า — ปุ่มกลับ + ไอคอนแผงด้านข้าง
const backBtn = page.getByRole('button').filter({ has: page.locator('i.q-icon', { hasText: 'arrow_back' }) }).first();
await expect(backBtn).toBeVisible({ timeout: 15_000 });
await expect(backBtn).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
const menuCurriculumBtn = page.getByRole('button').filter({ has: page.locator('i.q-icon', { hasText: 'menu_open' }) }).first();
await expect(menuCurriculumBtn).toBeVisible({ timeout: 15_000 });
await expect(menuCurriculumBtn).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
// 2. เช็คว่ามีพื้นที่ Sidebar หลักสูตร (CurriculumSidebar Component) โผล่ขึ้นมาหรือมีอยู่ใน DOM
// 2. Sidebar หลักสูตร
const sidebar = page.locator('.q-drawer').first();
if (!await sidebar.isVisible()) {
await menuCurriculumBtn.click();
@ -47,49 +80,96 @@ test.describe('ระบบห้องเรียนออนไลน์ (Cla
});
test('6.2 เช็คสถานะการเข้าถึงเนื้อหา (Access Control)', async ({ page }) => {
// ลองสุ่ม Course ID สูงๆ ที่อาจจะไม่อนุญาตให้เรียน (ไม่มีสิทธิ์) ควรรองรับกล่องแจ้งเตือนด้วย Alert ของระบบ
// ใน learning.vue จะมีการสั่ง `alert(msg)` แต่อาจจะต้องพึ่งกลไก Intercepter
page.on('dialog', async dialog => {
// หน้าต่าง Alert ถ้ามีสิทธิ์ไม่อนุญาตมันจะเด้งอันนี้
expect(dialog.message()).toBeTruthy();
await dialog.accept();
});
await page.goto(`${BASE_URL}/classroom/learning?course_id=99999`);
// รอดู Loading หายไป
const loadingMask = page.locator('.animate-pulse, .q-spinner');
await loadingMask.first().waitFor({ state: 'hidden', timeout: 20_000 }).catch(() => {});
await loadingMask.first().waitFor({ state: 'hidden', timeout: TIMEOUT.PAGE_LOAD }).catch(() => {});
});
test('6.3 การแสดงผลช่องวิดีโอ (Video Player) หรือ พื้นที่ทำข้อสอบ (Quiz)', async ({ page }) => {
// เข้าหน้าห้องเรียน Course id: 1
test('6.3 การแสดงผลช่องวิดีโอ หรือ พื้นที่ทำข้อสอบ (Video / Quiz)', async ({ page }) => {
await page.goto(`${BASE_URL}/classroom/learning?course_id=1`);
// กรณีที่ 1: อาจแสดง Video ถ้าเป็นบทเรียนวิดีโอ
const videoLocator = page.locator('video').first();
// กรณีที่ 2: ถ้าบทแรกเป็น Quiz จะแสดงไอคอนแบบทดสอบ
const quizLocator = page.getByText(/เริ่มทำแบบทดสอบ|แบบทดสอบ/i).first();
// กรณีที่ 3: ไม่มีบทเรียนเนื้อหาใดๆ เลยให้แสดง
const errorLocator = page.getByText(/ไม่สามารถเข้าถึง/i).first();
try {
await Promise.race([
videoLocator.waitFor({ state: 'visible', timeout: 20_000 }),
quizLocator.waitFor({ state: 'visible', timeout: 20_000 }),
errorLocator.waitFor({ state: 'visible', timeout: 20_000 })
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 {
// ถ้าไม่มีเลยใน 20 วิ ถือว่าหน้าอาจจะล้มเหลว หรือเป็น Content เปล่า
// ให้ลอง Capture เพื่อเก็บข้อมูลไปใช้งาน
await page.screenshot({ path: 'tests/e2e/screenshots/classroom-blank-state.png', fullPage: true });
}
});
});
// --------------------------------------------------
// Section 2: แบบทดสอบ (Quiz System)
// --------------------------------------------------
test.describe('แบบทดสอบ (Quiz System)', () => {
test('7.1 โหลดหน้า Quiz และเริ่มทำข้อสอบได้ (Start Screen)', 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();
// เช็คว่าหน้า Taking (คำถามข้อที่ 1) โผล่มา
const questionText = page.locator('h3').first();
await expect(questionText).toBeVisible({ timeout: TIMEOUT.ELEMENT });
});
test('7.2 แถบข้อสอบแบ่งหน้า (Pagination — เลื่อนซ้าย/ขวา)', async ({ page }) => {
await mockQuizData(page);
await page.goto(`${BASE_URL}/classroom/quiz?course_id=2&lesson_id=17`);
const startBtn = page.getByRole('button', { name: /เริ่มทำแบบทดสอบ|Start/i }).first();
await expect(startBtn).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
await startBtn.click();
// ลูกศรเลื่อนหน้าข้อสอบ
const nextPaginationPageBtn = page.locator('button').filter({ has: page.locator('i.q-icon:has-text("chevron_right")') }).first();
if (await nextPaginationPageBtn.isVisible()) {
await expect(nextPaginationPageBtn).toBeEnabled();
await nextPaginationPageBtn.click();
// ข้อที่ 11 ต้องแสดง
const question11Btn = page.locator('button').filter({ hasText: /^11$/ }).first();
await expect(question11Btn).toBeVisible();
}
});
test('7.3 การแสดงผลปุ่มถัดไป/ส่งคำตอบ (Submit & Navigation UI)', async ({ page }) => {
await mockQuizData(page);
await page.goto(`${BASE_URL}/classroom/quiz?course_id=2&lesson_id=17`);
const startBtn = page.getByRole('button', { name: /เริ่มทำแบบทดสอบ|Start/i }).first();
await expect(startBtn).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
await startBtn.click();
// รอคำถามโหลดเสร็จ
await expect(page.locator('h3').first()).toBeVisible({ timeout: TIMEOUT.ELEMENT });
const submitBtn = page.locator('button').filter({ hasText: /(ส่งคำตอบ|Submit)/i }).first();
const nextBtn = page.locator('button').filter({ hasText: /(ถัดไป|Next)/i }).first();
// ข้อแรกต้องมีปุ่มถัดไปหรือปุ่มส่ง
await expect(submitBtn.or(nextBtn)).toBeVisible({ timeout: TIMEOUT.ELEMENT });
});
});
});

View file

@ -1,14 +1,19 @@
/**
* @file discovery.spec.ts
* @description (Discovery & Browse)
*/
import { test, expect } from '@playwright/test';
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
import { BASE_URL, TIMEOUT, waitAppSettled } from './helpers';
test.describe('หมวดหน้าค้นหาคอร์สและผลลัพธ์ (Discovery & Browse)', () => {
test.describe('ส่วนหน้าแรก (Home)', () => {
test('โหลดหน้าแรก และตรวจสอบแสดงผลครบถ้วน (Hero, Cards, Categories)', async ({ page }) => {
await page.goto(BASE_URL);
await waitAppSettled(page);
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();
if (await ctaButton.isVisible()) {
@ -16,55 +21,63 @@ test.describe('หมวดหน้าค้นหาคอร์สและ
}
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();
await expect(allCategoryBtn).toBeVisible();
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);
await page.screenshot({ path: 'tests/e2e/screenshots/discovery-home.png', fullPage: true });
});
});
test.describe('ส่วนค้นหาและแคตตาล็อก (Browse)', () => {
test('ค้นหาหลักสูตร (Search Course)', async ({ page }) => {
await page.goto(`${BASE_URL}/browse`);
const searchInput = page.locator('input[placeholder="ค้นหาคอร์ส..."]').first();
await searchInput.fill('การเขียนโปรแกรม');
await searchInput.press('Enter');
await waitAppSettled(page);
// ในหน้า 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();
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 }) => {
await page.goto(`${BASE_URL}/browse`);
await waitAppSettled(page);
const categoryButton = page.locator('button').filter({ hasText: 'การออกแบบ' }).first();
if (await categoryButton.isVisible()) {
await categoryButton.click();
// ในหน้า browse จะใช้ <NuxtLink> ซึ่ง render เป็น tag <a>
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('โหลดเนื้อหาวิชา (Curriculum) แถบรายละเอียดขึ้นปกติ', async ({ page }) => {
await page.goto(`${BASE_URL}`);
const courseCard = page.locator('div.cursor-pointer').filter({ has: page.locator('img') }).first();
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}`);
await page.goto(`${BASE_URL}/course/1`);
await waitAppSettled(page);
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();
if (await curriculumTab.isVisible()) {
@ -73,19 +86,18 @@ test.describe('หมวดหน้าค้นหาคอร์สและ
const lessonItems = page.locator('.q-expansion-item, .lesson-item, [role="listitem"]');
await expect(lessonItems.first()).toBeVisible().catch(() => {});
await page.screenshot({ path: 'tests/e2e/screenshots/discovery-curriculum.png', fullPage: true });
});
test('การแสดงผลปุ่ม เข้าเรียน/ลงทะเบียน (Enroll / Start Learning)', async ({ page }) => {
await page.goto(`${BASE_URL}`);
const courseCard = page.locator('div.cursor-pointer').filter({ has: page.locator('img') }).first();
await expect(courseCard).toBeVisible({ timeout: 10_000 });
const targetUrl = '/course/1';
await page.goto(`${BASE_URL}${targetUrl}`);
await page.waitForLoadState('networkidle').catch(() => {});
await page.goto(`${BASE_URL}/course/1`);
await waitAppSettled(page);
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';
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);
}
import { BASE_URL, TIMEOUT, waitAppSettled, setupLogin } from './helpers';
test.describe('ระบบพื้นที่ส่วนตัวผู้เรียน (Student Account / Portal)', () => {
test.describe('การตั้งค่าและส่วนติดต่อผู้ใช้ (Settings & UI Theme)', () => {
test('เปลี่ยนภาษาการแสดงผล (Localisation/i18n)', async ({ page }) => {
await page.goto(BASE_URL);
const langBtn = page.getByRole('button', { name: 'Language' }).or(page.locator('button').filter({ hasText: /TH|EN/ })).first();
await waitAppSettled(page);
// หาปุ่มภาษา — ถ้าไม่เจอให้ test.skip แทนที่จะผ่านเงียบๆ
const langBtn = page.getByRole('button', { name: 'Language' })
.or(page.locator('button').filter({ hasText: /TH|EN/ }))
.first();
const isLangBtnVisible = await langBtn.isVisible().catch(() => false);
if (!isLangBtnVisible) {
test.skip(true, 'Language button not found on page — skipping');
return;
}
if (await langBtn.isVisible()) {
await langBtn.click();
const englishOpt = page.locator('text=English, text=EN').first();
await englishOpt.click();
const loginLink = page.locator('a[href*="login"], button').filter({ hasText: /Log in|Sign In/i });
await expect(loginLink).toBeVisible({ timeout: 5000 });
}
await expect(loginLink).toBeVisible({ timeout: TIMEOUT.ELEMENT });
await page.screenshot({ path: 'tests/e2e/screenshots/student-i18n.png', fullPage: true });
});
test('เปลี่ยนโหมดมืดสว่าง (Theme Switcher)', async ({ page }) => {
await page.goto(BASE_URL);
await waitAppSettled(page);
// หาปุ่ม Theme — ถ้าไม่เจอให้ test.skip แทนที่จะผ่านเงียบๆ
const themeBtn = page.locator('.dark-mode-toggle, button[aria-label*="theme"]').first();
if (await themeBtn.isVisible()) {
const isThemeBtnVisible = await themeBtn.isVisible().catch(() => false);
if (!isThemeBtnVisible) {
test.skip(true, 'Theme toggle button not found on page — skipping');
return;
}
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 }) => {
await page.goto(`${BASE_URL}/dashboard`);
await page.waitForTimeout(1000);
await waitAppSettled(page, 1000);
const welcomeText = page.getByText(/ยินดีต้อนรับกลับ/i, { exact: false });
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 }) => {
await page.goto(`${BASE_URL}/dashboard/my-courses`);
await waitAppSettled(page);
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();
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: 'view_list' }).first()).toBeVisible();
await page.screenshot({ path: 'tests/e2e/screenshots/student-my-courses.png', fullPage: true });
});
test('ลองค้นหาคอร์ส (Search Input) ไม่พบข้อมูล', async ({ page }) => {
await page.goto(`${BASE_URL}/dashboard/my-courses`);
await waitAppSettled(page);
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');
const emptyState = page.locator('h3').filter({ hasText: /ไม่พบ|ไม่เจอ|No result/i }).first()
.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 }) => {
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());
const isNameVisible = await nameInput.isVisible().catch(() => false);
if (!isNameVisible) {
test.skip(true, 'Profile name input not found — skipping');
return;
}
if (await nameInput.isVisible()) {
const oldName = await nameInput.inputValue();
await nameInput.clear();
await nameInput.fill(`${oldName}แก้ไข`);
const saveBtn = page.getByRole('button', { name: /บันทึก/i }).first();
if(await saveBtn.isVisible()) {
// ปุ่มบันทึก — รองรับทั้งภาษาไทยและอังกฤษ
const saveBtn = page.getByRole('button', { name: /บันทึก|Save Changes|Save/i }).first();
await expect(saveBtn).toBeVisible({ timeout: TIMEOUT.ELEMENT });
await saveBtn.click();
const successNotify = page.locator('.q-notification__message, text=อัปเดตข้อมูลสำเร็จ').first();
await expect(successNotify).toBeVisible({ timeout: 5000 }).catch(() => {});
}
}
});
// 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;
try {
// Convert local datetime to ISO string to preserve timezone
const payload = { ...form.value };
const payload: any = { ...form.value };
if (payload.published_at) {
const localDate = new Date(payload.published_at.replace(' ', 'T'));
payload.published_at = localDate.toISOString();
} else {
delete payload.published_at;
}
if (editing.value) {
@ -447,10 +449,7 @@ const deleteAttachment = async (attachmentId: number) => {
}
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString('th-TH', { day: 'numeric', month: 'short', year: 'numeric' });
};
// Date formatting function is auto-imported from utils/date.ts
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + ' B';

View file

@ -20,7 +20,7 @@
v-for="item in history"
:key="item.id"
:title="titleMap[item.action] || item.action"
:subtitle="formatDate(item.created_at)"
:subtitle="formatDateTime(item.created_at)"
:color="colorMap[item.action] || 'grey'"
:icon="iconMap[item.action] || 'circle'"
>
@ -91,12 +91,7 @@ const getActorName = (item: ApprovalHistory) => {
return actor.username || actor.email || 'Unknown User';
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('th-TH', {
dateStyle: 'medium',
timeStyle: 'short'
});
};
// Date formatting function is auto-imported from utils/date.ts
onMounted(() => {
fetchHistory();

View file

@ -450,14 +450,7 @@ const openStudentDetail = async (studentId: number) => {
const formatDate = (dateStr: string) => {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('th-TH', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
return formatDateTime(dateStr);
};
// Lifecycle

View file

@ -404,8 +404,7 @@ const getStudentStatusLabel = (status: string) => {
};
const formatEnrollDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString('th-TH', { day: 'numeric', month: 'short', year: 'numeric' });
return formatDate(dateStr);
};
const getLessonTypeIcon = (type: string) => {
@ -436,8 +435,7 @@ const formatVideoTime = (seconds: number) => {
const formatCompletedDate = (dateStr: string | null) => {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('th-TH', { day: 'numeric', month: 'short' });
return formatDate(dateStr);
};
// Fetch on mount

View file

@ -136,7 +136,7 @@
<!-- Created At Custom Column -->
<template v-slot:body-cell-created_at="props">
<q-td :props="props">
{{ formatDate(props.value) }}
{{ formatDateTime(props.value) }}
</q-td>
</template>
@ -168,8 +168,8 @@
<q-badge :color="getActionColor(selectedLog.action)">{{ selectedLog.action }}</q-badge>
</div>
<div>
<div class="text-subtitle2 text-grey">Time</div>
<div>{{ formatDate(selectedLog.created_at) }}</div>
<div class="text-subtitle2 text-grey">Date & Time</div>
<div>{{ formatDateTime(selectedLog.created_at) }}</div>
</div>
<div>
@ -241,7 +241,7 @@
</template>
<script setup lang="ts">
import { useQuasar } from 'quasar';
import { useQuasar, type QTableColumn } from 'quasar';
import { adminService, type AuditLog, type AuditLogStats } from '~/services/admin.service';
definePageMeta({
@ -284,15 +284,15 @@ const pagination = ref({
});
// Table setup
const columns = [
{ name: 'id', label: 'ID', field: 'id', align: 'left', style: 'width: 60px' },
{ name: 'action', label: 'Action', field: 'action', align: 'left' },
{ name: 'user', label: 'User', field: 'user', align: 'left' },
{ name: 'entity_type', label: 'Entity Type', field: 'entity_type', align: 'left' },
{ name: 'entity_id', label: 'Entity ID', field: 'entity_id', align: 'left' },
const columns: QTableColumn[] = [
{ name: 'id', label: 'ID', field: 'id', align: 'left' as const, style: 'width: 60px' },
{ name: 'action', label: 'Action', field: 'action', align: 'left' as const },
{ name: 'user', label: 'User', field: 'user', align: 'left' as const },
{ name: 'entity_type', label: 'Entity Type', field: 'entity_type', align: 'left' as const },
{ name: 'entity_id', label: 'Entity ID', field: 'entity_id', align: 'left' as const },
{ name: 'created_at', label: 'Time', field: 'created_at', align: 'left' },
{ name: 'actions', label: '', field: 'actions', align: 'center' }
{ name: 'created_at', label: 'Date & Time', field: 'created_at', align: 'left' as const },
{ name: 'actions', label: '', field: 'actions', align: 'center' as const }
];
// Actions options (for filtering)
@ -416,18 +416,25 @@ const tryFormatJson = (str: string | null) => {
}
};
const formatDate = (date: string) => {
if (!date) return '-';
return new Date(date).toLocaleString('th-TH');
// Date formatting function is auto-imported from utils/date.ts
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) => {
if (!action) return 'grey';
if (action.includes('DELETE') || action.includes('REJECT') || action.includes('DEACTIVATE') || action.includes('ERROR')) return 'negative';
if (action.includes('UPDATE') || action.includes('CHANGE')) return 'warning';
if (action.includes('CREATE') || action.includes('APPROVE') || action.includes('ACTIVATE')) return 'positive';
if (action.includes('LOGIN')) return 'info';
return 'grey-8';
const keyword = Object.keys(ACTION_COLOR_MAP).find((key) => action.includes(key));
return keyword ? ACTION_COLOR_MAP[keyword] : 'grey-8';
};
// Check for deep link to detail
@ -443,10 +450,12 @@ onMounted(() => {
:deep(input[type=number]::-webkit-outer-spin-button),
:deep(input[type=number]::-webkit-inner-spin-button) {
-webkit-appearance: none;
appearance: none;
margin: 0;
}
:deep(input[type=number]) {
-moz-appearance: textfield;
appearance: textfield;
}
</style>

View file

@ -233,13 +233,7 @@ const fetchCategories = async () => {
}
};
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('th-TH', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
};
// Date formatting function is auto-imported from utils/date.ts
const resetForm = () => {
form.value = {
@ -307,8 +301,17 @@ const handleSave = async () => {
const confirmDelete = (category: CategoryResponse) => {
$q.dialog({
title: 'ยืนยันการลบ',
message: `คุณต้องการลบหมวดหมู่ "${category.name.th}" หรือไม่?`,
cancel: true,
message: `คุณต้องการลบหมวดหมู่ "${category.name.th}" หรือไม่?<br><span style="color: red;">การลบหมวดหมู่นี้จะทำให้หมวดหมู่ถูกลบออกจากหลักสูตรทั้งหมดที่ใช้งานอยู่</span>`,
html: true,
cancel: {
label: 'ยกเลิก',
color: 'grey',
flat: true
},
ok: {
label: 'ลบหมวดหมู่',
color: 'negative'
},
persistent: true
}).onOk(async () => {
try {

View file

@ -56,7 +56,7 @@
<div class="p-6">
<div class="flex flex-wrap gap-2 mb-4">
<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-else color="blue" :label="`฿${course.price.toLocaleString()}`" />
<q-badge v-if="course.have_certificate" color="purple" label="มีใบประกาศนียบัตร" />
@ -356,23 +356,7 @@ const getActionColor = (action: string) => {
return colors[action] || 'grey';
};
const formatDate = (date: string) => {
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'
});
};
// Date formatting functions are auto-imported from utils/date.ts
const confirmApprove = () => {
if (!course.value) return;

View file

@ -135,7 +135,7 @@
<div v-if="course.latest_submission" class="mt-3 text-sm text-gray-500">
<q-icon name="send" size="16px" class="mr-1" />
งโดย {{ course.latest_submission.submitter.username }}
เม {{ formatDate(course.latest_submission.created_at) }}
เม {{ formatDateTime(course.latest_submission.created_at) }}
</div>
</div>
@ -203,7 +203,7 @@
<template v-slot:body-cell-submitted_at="props">
<q-td :props="props">
<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>
<span v-else>-</span>
@ -298,15 +298,7 @@ const getPrimaryInstructor = (course: PendingCourse) => {
return primary?.user.username || course.creator.username;
};
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('th-TH', {
day: 'numeric',
month: 'short',
year: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
// Date formatting function is auto-imported from utils/date.ts
const viewCourse = (course: PendingCourse) => {
router.push(`/admin/courses/${course.id}`);

View file

@ -136,7 +136,7 @@
<p class="text-xs text-gray-500 truncate">โดย {{ course.creator.username }}</p>
</div>
<div class="text-xs text-gray-400 whitespace-nowrap">
{{ formatDate(course.created_at) }}
{{ formatDateStr(course.created_at) }}
</div>
</div>
</div>
@ -170,7 +170,7 @@
<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>
</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>
@ -254,14 +254,7 @@ const fetchDashboardData = async () => {
};
// Utilities
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('th-TH', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
});
};
const formatDateStr = (date: string) => formatDateTime(date);
const getActionIcon = (action: string) => {
if (action.includes('create')) return 'add_circle';

View file

@ -301,7 +301,7 @@
<script setup lang="ts">
import { useQuasar } from 'quasar';
import { userService, type UserProfileResponse } from '~/services/user.service';
import { userService } from '~/services/user.service';
import { authService } from '~/services/auth.service';
definePageMeta({
@ -368,20 +368,8 @@ const getRoleLabel = (role: string) => {
return labels[role] || role;
};
const formatDate = (date: string, includeTime = true) => {
const options: Intl.DateTimeFormatOptions = {
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);
};
// Use formatting utilities from utils/date.ts
// Format functions are auto-imported
// Avatar upload
const avatarInputRef = ref<HTMLInputElement | null>(null);
@ -425,8 +413,8 @@ const handleAvatarUpload = async (event: Event) => {
try {
const response = await userService.uploadAvatar(file);
// Re-fetch profile to get presigned URL from backend
await fetchProfile();
// Force refresh profile cache and update local state
await fetchProfile(true);
$q.notify({
type: 'positive',
@ -457,8 +445,8 @@ const handleUpdateProfile = async () => {
phone: editForm.value.phone || null
});
// Refresh profile data from API
await fetchProfile();
// Force refresh profile cache and update local state
await fetchProfile(true);
$q.notify({
type: 'positive',
@ -546,13 +534,9 @@ watch(showEditModal, (newVal) => {
}
});
// Fetch profile from API
const fetchProfile = async () => {
loading.value = true;
try {
const data = await userService.getProfile();
// Map API response to profile
// Helper to map fullProfile to local profile state
const mapProfileData = (data: typeof authStore.fullProfile) => {
if (!data) return;
profile.value = {
fullName: `${data.profile.first_name} ${data.profile.last_name}`,
email: data.email,
@ -565,6 +549,14 @@ const fetchProfile = async () => {
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;
try {
await authStore.fetchUserProfile(force);
mapProfileData(authStore.fullProfile);
} catch (error) {
$q.notify({
type: 'negative',
@ -576,7 +568,7 @@ const fetchProfile = async () => {
}
};
// Load profile on mount
// Load profile on mount (uses cache if available)
onMounted(() => {
fetchProfile();
});

View file

@ -153,7 +153,8 @@
<!-- Category -->
<div class="bg-gray-50 p-4 rounded-lg gap-2">
<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>
<!-- Instructors -->
@ -262,7 +263,7 @@ const columns = [
field: (row: RecommendedCourse) => row.instructors?.find((i: any) => i.is_primary)?.user.username || '',
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: 'is_recommended', label: 'Recommended', field: 'is_recommended', sortable: true, 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';
};
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('th-TH', {
day: 'numeric',
month: 'short',
year: '2-digit'
});
};
// Date formatting function is auto-imported from utils/date.ts
const viewUser = (user: AdminUserResponse) => {
selectedUser.value = user;

View file

@ -449,13 +449,7 @@ const getStatusLabel = (status: string) => {
return labels[status] || status;
};
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('th-TH', {
day: 'numeric',
month: 'short',
year: '2-digit'
});
};
// Date formatting function is auto-imported from utils/date.ts
// Clone Dialog
const cloneDialog = 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">
<q-card class="p-6 text-center">
<div class="text-4xl font-bold text-primary-600 mb-2">
{{ instructorStore.stats.totalCourses }}
{{ stats.totalCourses }}
</div>
<div class="text-gray-600">หลกสตรทงหมด</div>
</q-card>
<q-card class="p-6 text-center">
<div class="text-4xl font-bold text-secondary-600 mb-2">
{{ instructorStore.stats.totalStudents }}
{{ stats.totalStudents }}
</div>
<div class="text-gray-600">เรยนทงหมด</div>
</q-card>
<q-card class="p-6 text-center">
<div class="text-4xl font-bold text-accent-600 mb-2">
{{ instructorStore.stats.completedStudents }}
{{ stats.completedStudents }}
</div>
<div class="text-gray-600">เรยนจบแล</div>
</q-card>
@ -96,28 +96,28 @@
<q-icon name="check_circle" color="green" size="24px" />
<span class="font-medium text-gray-700">เผยแพรแล</span>
</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 class="flex items-center justify-between p-3 bg-orange-50 rounded-lg">
<div class="flex items-center gap-3">
<q-icon name="hourglass_empty" color="orange" size="24px" />
<span class="font-medium text-gray-700">รอตรวจสอบ</span>
</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 class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div class="flex items-center gap-3">
<q-icon name="edit_note" color="grey" size="24px" />
<span class="font-medium text-gray-700">แบบราง</span>
</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 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">
<q-icon name="cancel" color="red" size="24px" />
<span class="font-medium text-gray-700">กปฏเสธ</span>
</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>
</q-card-section>
@ -138,7 +138,7 @@
<div class="space-y-4">
<q-card
v-for="course in instructorStore.recentCourses"
v-for="course in recentCourses"
:key="course.id"
class="cursor-pointer hover:shadow-md transition"
@click="router.push(`/instructor/courses/${course.id}`)"
@ -172,6 +172,7 @@
<script setup lang="ts">
import { useQuasar } from 'quasar';
import { instructorService } from '~/services/instructor.service';
definePageMeta({
layout: 'instructor',
@ -179,10 +180,32 @@ definePageMeta({
});
const authStore = useAuthStore();
const instructorStore = useInstructorStore();
const router = useRouter();
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
const goToProfile = () => {
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(() => {
instructorStore.fetchDashboardData();
authStore.fetchUserProfile();
fetchDashboardData();
});
</script>

View file

@ -301,7 +301,7 @@
<script setup lang="ts">
import { useQuasar } from 'quasar';
import { userService, type UserProfileResponse } from '~/services/user.service';
import { userService } from '~/services/user.service';
import { authService } from '~/services/auth.service';
definePageMeta({
@ -368,20 +368,8 @@ const getRoleLabel = (role: string) => {
return labels[role] || role;
};
const formatDate = (date: string, includeTime = true) => {
const options: Intl.DateTimeFormatOptions = {
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);
};
// Use formatting utilities from utils/date.ts
// Format functions are auto-imported
// Avatar upload
const avatarInputRef = ref<HTMLInputElement | null>(null);
@ -425,8 +413,8 @@ const handleAvatarUpload = async (event: Event) => {
try {
const response = await userService.uploadAvatar(file);
// Re-fetch profile to get presigned URL from backend
await fetchProfile();
// Force refresh profile cache and update local state
await fetchProfile(true);
$q.notify({
type: 'positive',
@ -457,8 +445,8 @@ const handleUpdateProfile = async () => {
phone: editForm.value.phone || null
});
// Refresh profile data from API
await fetchProfile();
// Force refresh profile cache and update local state
await fetchProfile(true);
$q.notify({
type: 'positive',
@ -546,13 +534,9 @@ watch(showEditModal, (newVal) => {
}
});
// Fetch profile from API
const fetchProfile = async () => {
loading.value = true;
try {
const data = await userService.getProfile();
// Map API response to profile
// Helper to map fullProfile to local profile state
const mapProfileData = (data: typeof authStore.fullProfile) => {
if (!data) return;
profile.value = {
fullName: `${data.profile.first_name} ${data.profile.last_name}`,
email: data.email,
@ -565,6 +549,14 @@ const fetchProfile = async () => {
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;
try {
await authStore.fetchUserProfile(force);
mapProfileData(authStore.fullProfile);
} catch (error) {
$q.notify({
type: 'negative',
@ -576,7 +568,7 @@ const fetchProfile = async () => {
}
};
// Load profile on mount
// Load profile on mount (uses cache if available)
onMounted(() => {
fetchProfile();
});

View file

@ -36,7 +36,7 @@
<q-input
v-model="form.email"
label="อีเมล *"
type="email"
type="text"
outlined
:rules="[
val => !!val || 'กรุณากรอกอีเมล',

View file

@ -34,11 +34,11 @@ export default defineConfig({
use: {
baseURL: 'http://localhost:3000/',// ปรับเป็น URL ที่ทดสอบ
headless: false, // false = เห็น browser ขณะรัน
screenshot: 'only-on-failure', // เก็บ screenshot เมื่อ fail
screenshot: 'on', // เก็บ screenshot
trace: 'retain-on-failure', // เก็บ trace เพื่อดีบักเมื่อ fail
// launchOptions: {
// slowMo: 1000,
// }, // ช้าลง 10 วินาที
launchOptions: {
slowMo: 500,
}, // ช้าลง 10 วินาที
},
/* ──── Setup Projects: login ครั้งเดียว แล้ว save cookies ──── */

View file

@ -3,35 +3,20 @@ export interface LoginRequest {
password: string;
}
// API Response structure (from backend)
// API Response structure (from backend) - new format: only token/refreshToken
export interface ApiLoginResponse {
token: string;
refreshToken: string;
user: {
}
// JWT Payload structure (decoded from token)
export interface JwtPayload {
id: number;
username: string;
email: string;
updated_at: string;
created_at: string;
role: {
code: string;
name: {
en: string;
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;
};
};
roleCode: string;
iat: number;
exp: number;
}
// Frontend User structure
@ -55,6 +40,21 @@ export interface ApiResponse<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 = {
async login(email: string, password: string): Promise<LoginResponse> {
const config = useRuntimeConfig();
@ -71,22 +71,26 @@ export const authService = {
const loginData = response.data;
// Decode JWT to get user info
const payload = decodeJwtPayload(loginData.token);
// Check if user role is STUDENT - block login
if (loginData.user.role.code === 'STUDENT') {
if (payload.roleCode === 'STUDENT') {
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 {
token: loginData.token,
refreshToken: loginData.refreshToken,
user: {
id: loginData.user.id.toString(),
email: loginData.user.email,
firstName: loginData.user.profile.first_name,
lastName: loginData.user.profile.last_name,
role: loginData.user.role.code,
avatarUrl: loginData.user.profile.avatar_url
id: payload.id.toString(),
email: payload.email,
firstName: '',
lastName: '',
role: payload.roleCode,
avatarUrl: null
},
message: response.message || 'เข้าสู่ระบบสำเร็จ'
};

View file

@ -610,6 +610,19 @@ export const instructorService = {
{ 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[]> {
const response = await authRequest<{
code: number;

View file

@ -1,6 +1,6 @@
import { defineStore } from 'pinia';
import { authService } from '~/services/auth.service';
import { userService } from '~/services/user.service';
import { userService, type UserProfileResponse } from '~/services/user.service';
interface User {
id: string;
@ -15,7 +15,8 @@ export const useAuthStore = defineStore('auth', {
state: () => ({
user: null as User | null,
token: null as string | null,
isAuthenticated: false
isAuthenticated: false,
fullProfile: null as UserProfileResponse | null
}),
getters: {
@ -61,6 +62,7 @@ export const useAuthStore = defineStore('auth', {
this.user = null;
this.token = null;
this.isAuthenticated = false;
this.fullProfile = null;
// Clear cookies
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 {
const response = await userService.getProfile();
// Cache raw API response
this.fullProfile = response;
// Update local user state
this.user = {
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