Compare commits

..

No commits in common. "dev" and "learner-dev-v1.1.6" have entirely different histories.

171 changed files with 4897 additions and 6980 deletions

View file

@ -7,8 +7,8 @@ WORKDIR /app
# คัดลอกไฟล์จัดการ dependencies # คัดลอกไฟล์จัดการ dependencies
COPY package*.json ./ COPY package*.json ./
# ติดตั้ง dependencies # ติดตั้ง dependencies (ใช้ npm ci เพื่อความแม่นยำของเวอร์ชัน)
RUN npm install RUN npm ci
# คัดลอกไฟล์ทั้งหมดในโปรเจกต์ # คัดลอกไฟล์ทั้งหมดในโปรเจกต์
COPY . . COPY . .

View file

@ -40,7 +40,7 @@ export const useAuth = () => {
// ฟังก์ชันเข้าสู่ระบบ (Login) // ฟังก์ชันเข้าสู่ระบบ (Login)
const login = async (credentials: { email: string; password: string }) => { const login = async (credentials: { email: string; password: string }) => {
try { try {
// API returns { code: 200, message: "...", data: { token, refreshToken } } // API returns { code: 200, message: "...", data: { token, user, ... } }
const response = await $fetch<any>(`${API_BASE_URL}/auth/login`, { const response = await $fetch<any>(`${API_BASE_URL}/auth/login`, {
method: 'POST', method: 'POST',
body: credentials body: credentials
@ -49,35 +49,16 @@ export const useAuth = () => {
if (response && response.data) { if (response && response.data) {
const data = response.data const data = response.data
// บันทึก Token ก่อน เพื่อใช้เรียก /user/me // Validation: Ensure user and role exist, then check for Role 'STUDENT'
token.value = data.token if (!data.user || !data.user.role || data.user.role.code !== 'STUDENT') {
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 ไม่ถูกต้อง' } return { success: false, error: 'Email ไม่ถูกต้อง' }
} }
// เก็บข้อมูล User ลง Cookie token.value = data.token
user.value = userData refreshToken.value = data.refreshToken // บันทึก Refresh Token
} catch (profileErr) {
// ดึงข้อมูลผู้ใช้ไม่สำเร็จ ให้ล้าง Token ออก // API ส่งข้อมูล profile มาใน user object
console.error('Failed to fetch user profile after login:', profileErr) user.value = data.user
token.value = null
refreshToken.value = null
return { success: false, error: 'ไม่สามารถดึงข้อมูลผู้ใช้ได้' }
}
return { success: true } return { success: true }
} }

View file

@ -117,11 +117,7 @@
"foundTotal": "Found Total", "foundTotal": "Found Total",
"items": "items", "items": "items",
"subtitle": "Choose to learn new skills from our curated quality courses", "subtitle": "Choose to learn new skills from our curated quality courses",
"searchBtn": "Search", "searchBtn": "Search"
"allCategory": "All",
"byInstructor": "by",
"students": "students",
"viewDetails": "View Details"
}, },
"myCourses": { "myCourses": {
"title": "My Courses", "title": "My Courses",

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -13,7 +13,6 @@ useHead({
title: 'คอร์สทั้งหมด - E-Learning System' title: 'คอร์สทั้งหมด - E-Learning System'
}) })
const { t } = useI18n()
const searchQuery = ref('') const searchQuery = ref('')
const { fetchCourses } = useCourse() const { fetchCourses } = useCourse()
const { fetchCategories, categories } = useCategory() const { fetchCategories, categories } = useCategory()
@ -55,7 +54,7 @@ await useAsyncData('categories-list', () => fetchCategories())
const { data: coursesResponse, pending: isLoading, error, refresh } = await useAsyncData( const { data: coursesResponse, pending: isLoading, error, refresh } = await useAsyncData(
'browse-courses-list', 'browse-courses-list',
() => { () => {
const params: any = { limit: 500 } const params: any = {}
if (selectedCategory.value !== 'all') { if (selectedCategory.value !== 'all') {
const category = categories.value.find(c => c.slug === selectedCategory.value) const category = categories.value.find(c => c.slug === selectedCategory.value)
if (category) { if (category) {
@ -132,11 +131,11 @@ const viewMode = ref<'grid' | 'list'>('grid')
<!-- วนหวและการคนหา --> <!-- วนหวและการคนหา -->
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8"> <div class="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
<h2 class="text-[1.35rem] font-bold text-slate-900 dark:text-white tracking-tight">{{ $t('discovery.title') }}</h2> <h2 class="text-[1.35rem] font-bold text-slate-900 dark:text-white tracking-tight">คอรสเรยนทงหมด</h2>
<div class="flex flex-wrap sm:flex-nowrap items-center gap-3 w-full md:w-auto"> <div class="flex flex-wrap sm:flex-nowrap items-center gap-3 w-full md:w-auto">
<div class="relative w-full sm:w-[260px] flex-1"> <div class="relative w-full sm:w-[260px] flex-1">
<q-icon name="search" size="18px" class="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-[#3B6BE8]" /> <q-icon name="search" size="18px" class="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-[#3B6BE8]" />
<input v-model="searchQuery" class="w-full bg-slate-100 dark:bg-slate-800 border-none rounded-xl py-2.5 pl-11 pr-4 text-sm font-medium text-slate-700 dark:text-slate-200 placeholder:text-slate-400 focus:ring-2 focus:ring-blue-500/20 outline-none transition-all shadow-sm" :placeholder="$t('discovery.searchPlaceholder')" /> <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="ค้นหาคอร์ส..." />
</div> </div>
<div class="flex items-center gap-2 shrink-0"> <div class="flex items-center gap-2 shrink-0">
<button @click="viewMode = 'grid'" :class="viewMode === 'grid' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-[#3B6BE8]' : 'bg-white border-slate-200 dark:bg-slate-800 dark:border-slate-700 text-slate-400 hover:bg-slate-50'" class="w-[42px] h-[42px] flex items-center justify-center rounded-xl border transition-colors outline-none"><q-icon name="grid_view" size="20px" /></button> <button @click="viewMode = 'grid'" :class="viewMode === 'grid' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-[#3B6BE8]' : 'bg-white border-slate-200 dark:bg-slate-800 dark:border-slate-700 text-slate-400 hover:bg-slate-50'" class="w-[42px] h-[42px] flex items-center justify-center rounded-xl border transition-colors outline-none"><q-icon name="grid_view" size="20px" /></button>
@ -152,7 +151,7 @@ const viewMode = ref<'grid' | 'list'>('grid')
@click="selectCategory('all')" @click="selectCategory('all')"
:class="selectedCategory === 'all' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-transparent font-bold' : 'bg-white dark:bg-transparent border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-300 hover:border-slate-300 font-medium'" :class="selectedCategory === 'all' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-transparent font-bold' : 'bg-white dark:bg-transparent border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-300 hover:border-slate-300 font-medium'"
class="px-5 py-2.5 rounded-full border text-[13px] sm:text-[14px] flex items-center justify-center gap-2 transition-all outline-none"> class="px-5 py-2.5 rounded-full border text-[13px] sm:text-[14px] flex items-center justify-center gap-2 transition-all outline-none">
<q-icon name="check_circle_outline" size="18px" :class="selectedCategory === 'all' ? 'text-[#3B6BE8]' : 'text-slate-400'"/> {{ $t('discovery.allCategory') }} <q-icon name="check_circle_outline" size="18px" :class="selectedCategory === 'all' ? 'text-[#3B6BE8]' : 'text-slate-400'"/> งหมด
</button> </button>
<button <button
@ -188,13 +187,13 @@ const viewMode = ref<'grid' | 'list'>('grid')
<h3 class="font-bold text-slate-900 dark:text-white text-[15px] leading-snug line-clamp-2 mb-2 group-hover:text-blue-600 transition-colors">{{ getLocalizedText(course.title) }}</h3> <h3 class="font-bold text-slate-900 dark:text-white text-[15px] leading-snug line-clamp-2 mb-2 group-hover:text-blue-600 transition-colors">{{ getLocalizedText(course.title) }}</h3>
<div class="flex items-center gap-2 mb-4"> <div class="flex items-center gap-2 mb-4">
<span class="text-[12px] text-slate-500 font-medium">{{ $t('discovery.byInstructor') }} {{ course.instructor_name }}</span> <span class="text-[12px] text-slate-500 font-medium">โดย {{ course.instructor_name }}</span>
</div> </div>
<div class="flex items-center gap-1.5 mb-5"> <div class="flex items-center gap-1.5 mb-5">
<q-icon name="star" class="text-amber-400" size="16px" /> <q-icon name="star" class="text-amber-400" size="16px" />
<span class="text-[13px] font-bold text-slate-800 dark:text-slate-200">{{ course.rating }}</span> <span class="text-[13px] font-bold text-slate-800 dark:text-slate-200">{{ course.rating }}</span>
<span class="text-[12px] text-slate-400">({{ course.reviews_count.toLocaleString() }} {{ $t('discovery.students') }})</span> <span class="text-[12px] text-slate-400">({{ course.reviews_count.toLocaleString() }} กเรยน)</span>
</div> </div>
<div class="mt-auto flex items-center justify-between"> <div class="mt-auto flex items-center justify-between">
@ -223,12 +222,12 @@ const viewMode = ref<'grid' | 'list'>('grid')
<div class="flex-1"> <div class="flex-1">
<h3 class="font-bold text-slate-900 dark:text-white text-[16px] md:text-[18px] leading-snug line-clamp-2 md:line-clamp-1 mb-2 group-hover:text-blue-600 transition-colors">{{ getLocalizedText(course.title) }}</h3> <h3 class="font-bold text-slate-900 dark:text-white text-[16px] md:text-[18px] leading-snug line-clamp-2 md:line-clamp-1 mb-2 group-hover:text-blue-600 transition-colors">{{ getLocalizedText(course.title) }}</h3>
<div class="flex items-center gap-2 mb-3"> <div class="flex items-center gap-2 mb-3">
<span class="text-[13px] text-slate-500 font-medium">{{ $t('discovery.byInstructor') }} {{ course.instructor_name }}</span> <span class="text-[13px] text-slate-500 font-medium">โดย {{ course.instructor_name }}</span>
</div> </div>
<div class="flex items-center gap-1.5 mb-2"> <div class="flex items-center gap-1.5 mb-2">
<q-icon name="star" class="text-amber-400" size="16px" /> <q-icon name="star" class="text-amber-400" size="16px" />
<span class="text-[13px] font-bold text-slate-800 dark:text-slate-200">{{ course.rating }}</span> <span class="text-[13px] font-bold text-slate-800 dark:text-slate-200">{{ course.rating }}</span>
<span class="text-[12px] text-slate-400">({{ course.reviews_count.toLocaleString() }} {{ $t('discovery.students') }})</span> <span class="text-[12px] text-slate-400">({{ course.reviews_count.toLocaleString() }} กเรยน)</span>
</div> </div>
</div> </div>
<div class="mt-4 sm:mt-auto flex items-center justify-between"> <div class="mt-4 sm:mt-auto flex items-center justify-between">
@ -236,7 +235,7 @@ const viewMode = ref<'grid' | 'list'>('grid')
{{ course.formatted_price }} {{ course.formatted_price }}
</div> </div>
<button class="px-6 py-2 rounded-full bg-slate-50 text-slate-600 dark:bg-slate-800 dark:text-slate-300 font-bold text-[13px] flex items-center gap-2 hover:bg-blue-50 border border-slate-100 dark:border-slate-700 hover:text-blue-600 transition-colors"> <button class="px-6 py-2 rounded-full bg-slate-50 text-slate-600 dark:bg-slate-800 dark:text-slate-300 font-bold text-[13px] flex items-center gap-2 hover:bg-blue-50 border border-slate-100 dark:border-slate-700 hover:text-blue-600 transition-colors">
<q-icon name="visibility" size="16px" /> {{ $t('discovery.viewDetails') }} <q-icon name="visibility" size="16px" /> รายละเอยด
</button> </button>
</div> </div>
</div> </div>
@ -247,10 +246,10 @@ const viewMode = ref<'grid' | 'list'>('grid')
<!-- กรณไมพบขอมลคอร (Empty State) --> <!-- กรณไมพบขอมลคอร (Empty State) -->
<div v-else class="flex flex-col items-center justify-center py-20 bg-white dark:bg-slate-900/40 rounded-3xl border border-dashed border-slate-200 dark:border-slate-800"> <div v-else class="flex flex-col items-center justify-center py-20 bg-white dark:bg-slate-900/40 rounded-3xl border border-dashed border-slate-200 dark:border-slate-800">
<q-icon name="search_off" size="64px" class="text-slate-300 dark:text-slate-600 mb-4" /> <q-icon name="search_off" size="64px" class="text-slate-300 dark:text-slate-600 mb-4" />
<h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">{{ $t('discovery.emptyTitle') }}</h3> <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">{{ $t('discovery.emptyDesc') }}</p> <p class="text-slate-500 dark:text-slate-400 text-center max-w-md">ลองใชคำคนหาอ หรอเลอกหมวดหมนเพอดคอรสทเรามใหบรการ</p>
<button class="mt-6 font-bold text-blue-600 hover:text-blue-700 transition-colors" @click="searchQuery = ''; selectedCategory = 'all';"> <button class="mt-6 font-bold text-blue-600 hover:text-blue-700 transition-colors" @click="searchQuery = ''; selectedCategory = 'all';">
{{ $t('discovery.showAll') }} แสดงคอรสทงหมด
</button> </button>
</div> </div>
</div> </div>

File diff suppressed because one or more lines are too long

View file

@ -1,13 +1,40 @@
/**
* @file auth.spec.ts
* @description (Authentication) Login, Register, Forgot Password
*/
import { test, expect, type Page, type Locator } from '@playwright/test'; import { test, expect, type Page, type Locator } from '@playwright/test';
import {
BASE_URL, TEST_EMAIL, TEST_PASSWORD, TIMEOUT, const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
waitAppSettled, expectAnyVisible,
emailLocator, passwordLocator, loginButtonLocator, async function waitAppSettled(page: Page) {
} from './helpers'; 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.');
}
// --------------------------- // ---------------------------
// Helpers: Register // Helpers: Register
@ -26,7 +53,6 @@ function regLoginLink(page: Page) { return page.getByRole('link', { name: 'เ
function regErrorBox(page: Page) { function regErrorBox(page: Page) {
return page.locator(['.q-field__messages', '.q-field__bottom', '.text-negative', '.q-notification', '.q-banner', '[role="alert"]'].join(', ')); return page.locator(['.q-field__messages', '.q-field__bottom', '.text-negative', '.q-notification', '.q-banner', '[role="alert"]'].join(', '));
} }
async function pickPrefix(page: Page, value: 'นาย' | 'นาง' | 'นางสาว' = 'นาย') { async function pickPrefix(page: Page, value: 'นาย' | 'นาง' | 'นางสาว' = 'นาย') {
const combo = regPrefix(page); const combo = regPrefix(page);
await combo.selectOption({ label: value }).catch(async () => { await combo.selectOption({ label: value }).catch(async () => {
@ -34,7 +60,6 @@ async function pickPrefix(page: Page, value: 'นาย' | 'นาง' | 'นา
await page.getByRole('option', { name: value }).click(); await page.getByRole('option', { name: value }).click();
}); });
} }
function uniqueUser() { function uniqueUser() {
const n = Date.now().toString().slice(-6); const n = Date.now().toString().slice(-6);
const rand8 = Math.floor(Math.random() * 1e8).toString().padStart(8, '0'); const rand8 = Math.floor(Math.random() * 1e8).toString().padStart(8, '0');
@ -65,12 +90,10 @@ test.describe('ระบบยืนยันตัวตน (Authentication)',
test('Success Login แล้วเข้า /dashboard ได้', async ({ page }) => { test('Success Login แล้วเข้า /dashboard ได้', async ({ page }) => {
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' }); await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page); await waitAppSettled(page);
await loginEmailLocator(page).fill(LOGIN_EMAIL);
await emailLocator(page).fill(TEST_EMAIL); await loginPasswordLocator(page).fill(LOGIN_PASSWORD);
await passwordLocator(page).fill(TEST_PASSWORD);
await loginButtonLocator(page).click(); await loginButtonLocator(page).click();
await page.waitForURL('**/dashboard', { timeout: 25_000 });
await page.waitForURL('**/dashboard', { timeout: TIMEOUT.LOGIN });
await waitAppSettled(page); await waitAppSettled(page);
const dashboardEvidence = [ const dashboardEvidence = [
@ -79,45 +102,38 @@ test.describe('ระบบยืนยันตัวตน (Authentication)',
page.locator('img[src*="avataaars"]').first(), page.locator('img[src*="avataaars"]').first(),
page.locator('img[alt],[alt="User Avatar"]').first() page.locator('img[alt],[alt="User Avatar"]').first()
]; ];
await expectAnyVisible(page, dashboardEvidence, TIMEOUT.PAGE_LOAD); await expectAnyVisible(page, dashboardEvidence, 20_000);
}); });
test('Invalid Email - Thai characters (พิมพ์ภาษาไทย)', async ({ page }) => { test('Invalid Email - Thai characters (พิมพ์ภาษาไทย)', async ({ page }) => {
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' }); await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page); await waitAppSettled(page);
await loginEmailLocator(page).fill('ทดสอบภาษาไทย');
await emailLocator(page).fill('ทดสอบภาษาไทย'); await loginPasswordLocator(page).fill(LOGIN_PASSWORD);
await passwordLocator(page).fill(TEST_PASSWORD); const errorHint = page.getByText('ห้ามใส่ภาษาไทย');
await expect(errorHint.first()).toBeVisible({ timeout: 12_000 });
await expect(page.getByText('ห้ามใส่ภาษาไทย').first()).toBeVisible({ timeout: TIMEOUT.ELEMENT });
}); });
test('Invalid Email Format (อีเมลผิดรูปแบบ)', async ({ page }) => { test('Invalid Email Format (อีเมลผิดรูปแบบ)', async ({ page }) => {
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' }); await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page); await waitAppSettled(page);
await loginEmailLocator(page).fill('test@domain');
await emailLocator(page).fill('test@domain'); await loginPasswordLocator(page).fill(LOGIN_PASSWORD);
await passwordLocator(page).fill(TEST_PASSWORD);
await loginButtonLocator(page).click(); await loginButtonLocator(page).click();
await waitAppSettled(page); await waitAppSettled(page);
const errorHint = page.getByText('กรุณากรอกอีเมลให้ถูกต้อง (you@example.com)');
await expect( await expect(errorHint.first()).toBeVisible({ timeout: 12_000 });
page.getByText('กรุณากรอกอีเมลให้ถูกต้อง (you@example.com)').first()
).toBeVisible({ timeout: TIMEOUT.ELEMENT });
}); });
test('Wrong Password (รหัสผ่านผิด หรืออีเมลไม่ถูกต้องในระบบ)', async ({ page }) => { test('Wrong Password (รหัสผ่านผิด หรืออีเมลไม่ถูกต้องในระบบ)', async ({ page }) => {
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' }); await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page); await waitAppSettled(page);
await loginEmailLocator(page).fill(LOGIN_EMAIL);
await emailLocator(page).fill(TEST_EMAIL); await loginPasswordLocator(page).fill('wrong-password-123');
await passwordLocator(page).fill('wrong-password-123');
await loginButtonLocator(page).click(); await loginButtonLocator(page).click();
await waitAppSettled(page); await waitAppSettled(page);
const errorHint = page.getByText('กรุณาเช็ค Email หรือ รหัสผ่านใหม่อีกครั้ง');
await expect( await expect(errorHint.first()).toBeVisible({ timeout: 12_000 });
page.getByText('กรุณาเช็ค Email หรือ รหัสผ่านใหม่อีกครั้ง').first()
).toBeVisible({ timeout: TIMEOUT.ELEMENT });
}); });
}); });
@ -126,22 +142,21 @@ test.describe('ระบบยืนยันตัวตน (Authentication)',
test('หน้า Register ต้องโหลดได้ (Smoke)', async ({ page }) => { test('หน้า Register ต้องโหลดได้ (Smoke)', async ({ page }) => {
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' }); await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page); await waitAppSettled(page);
await expect(regHeading(page)).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD }); await expect(regHeading(page)).toBeVisible({ timeout: 15_000 });
await expect(regSubmit(page)).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD }); await expect(regSubmit(page)).toBeVisible({ timeout: 15_000 });
}); });
test('ลิงก์ "เข้าสู่ระบบ" ต้องกดแล้วไปหน้า login ได้', async ({ page }) => { test('ลิงก์ "เข้าสู่ระบบ" ต้องกดแล้วไปหน้า login ได้', async ({ page }) => {
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' }); await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page); await waitAppSettled(page);
await regLoginLink(page).click(); await regLoginLink(page).click();
await page.waitForURL('**/auth/login', { timeout: TIMEOUT.PAGE_LOAD }); await page.waitForURL('**/auth/login', { timeout: 15_000 });
}); });
test('สมัครสมาชิกสำเร็จ (Happy Path) → redirect ไป /auth/login', async ({ page }) => { test('สมัครสมาชิกสำเร็จ (Happy Path) → redirect ไป /auth/login', async ({ page }) => {
const u = uniqueUser(); const u = uniqueUser();
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' }); await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page); await waitAppSettled(page);
await regUsername(page).fill(u.username); await regUsername(page).fill(u.username);
await regEmail(page).fill(u.email); await regEmail(page).fill(u.email);
await pickPrefix(page, 'นาย'); await pickPrefix(page, 'นาย');
@ -153,18 +168,15 @@ test.describe('ระบบยืนยันตัวตน (Authentication)',
await regSubmit(page).click(); await regSubmit(page).click();
await waitAppSettled(page); await waitAppSettled(page);
// รอ 3 สัญญาณ: redirect ไป login / success toast / error const navToLogin = page.waitForURL('**/auth/login', { timeout: 25_000, waitUntil: 'domcontentloaded' }).then(() => 'login' as const).catch(() => null);
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: 25_000 }).then(() => 'success' 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: 25_000 }).then(() => 'error' as const).catch(() => null);
const anyError = regErrorBox(page).first().waitFor({ state: 'visible', timeout: TIMEOUT.LOGIN }).then(() => 'error' as const).catch(() => null);
const result = await Promise.race([navToLogin, successToast, anyError]); const result = await Promise.race([navToLogin, successToast, anyError]);
if (result === 'error') { if (result === 'error') {
const errs = await regErrorBox(page).allInnerTexts().catch(() => []); throw new Error('Register errors visible');
throw new Error(`Register failed with errors: ${errs.join(' | ')}`);
} }
// ถ้ามี toast แต่ยัง redirect ไม่ไป ให้ navigate เอง
if (!page.url().includes('/auth/login')) { if (!page.url().includes('/auth/login')) {
const hasSuccess = await page.getByText(/สมัครสมาชิกสำเร็จ/i, { exact: false }).first().isVisible().catch(() => false); const hasSuccess = await page.getByText(/สมัครสมาชิกสำเร็จ/i, { exact: false }).first().isVisible().catch(() => false);
if (hasSuccess) { if (hasSuccess) {
@ -173,28 +185,24 @@ test.describe('ระบบยืนยันตัวตน (Authentication)',
} }
} }
await expect(page).toHaveURL(/\/auth\/login/i, { timeout: TIMEOUT.PAGE_LOAD }); await expect(page).toHaveURL(/\/auth\/login/i, { timeout: 15_000 });
await expect(page.getByRole('heading', { name: 'เข้าสู่ระบบ' })).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD }); await expect(page.getByRole('heading', { name: 'เข้าสู่ระบบ' })).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole('button', { name: 'เข้าสู่ระบบ' })).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD }); await expect(page.getByRole('button', { name: 'เข้าสู่ระบบ' })).toBeVisible({ timeout: 15_000 });
}); });
test('Invalid Email - ใส่ภาษาไทย ต้องขึ้น error', async ({ page }) => { test('Invalid Email - ใส่ภาษาไทย ต้องขึ้น error', async ({ page }) => {
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' }); await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page); await waitAppSettled(page);
await regEmail(page).fill('ทดสอบภาษาไทย'); await regEmail(page).fill('ทดสอบภาษาไทย');
await regUsername(page).click(); // blur trigger await regUsername(page).click();
const err = page.getByText(/ห้ามใส่ภาษาไทย|อีเมลไม่ถูกต้อง|รูปแบบอีเมล/i, { exact: false }).or(regErrorBox(page).filter({ hasText: /ไทย|อีเมล|email|invalid/i }));
const err = page await expect(err.first()).toBeVisible({ timeout: 12_000 });
.getByText(/ห้ามใส่ภาษาไทย|อีเมลไม่ถูกต้อง|รูปแบบอีเมล/i, { exact: false })
.or(regErrorBox(page).filter({ hasText: /ไทย|อีเมล|email|invalid/i }));
await expect(err.first()).toBeVisible({ timeout: TIMEOUT.ELEMENT });
}); });
test('Password ไม่ตรงกัน ต้องขึ้น error (ต้องกดสร้างบัญชีก่อน)', async ({ page }) => { test('Password ไม่ตรงกัน ต้องขึ้น error (ต้องกดสร้างบัญชีก่อน)', async ({ page }) => {
const u = uniqueUser(); const u = uniqueUser();
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' }); await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page); await waitAppSettled(page);
await regUsername(page).fill(u.username); await regUsername(page).fill(u.username);
await regEmail(page).fill(u.email); await regEmail(page).fill(u.email);
await pickPrefix(page, 'นาย'); await pickPrefix(page, 'นาย');
@ -202,14 +210,11 @@ test.describe('ระบบยืนยันตัวตน (Authentication)',
await regLastName(page).fill(u.lastName); await regLastName(page).fill(u.lastName);
await regPhone(page).fill(u.phone); await regPhone(page).fill(u.phone);
await regPassword(page).fill('Admin12345!'); await regPassword(page).fill('Admin12345!');
await regConfirmPassword(page).fill('Admin12345?'); // mismatch await regConfirmPassword(page).fill('Admin12345?');
await regSubmit(page).click(); await regSubmit(page).click();
await waitAppSettled(page); await waitAppSettled(page);
const mismatchErr = page.getByText(/รหัสผ่านไม่ตรงกัน/i, { exact: false }).or(regErrorBox(page).filter({ hasText: /รหัสผ่านไม่ตรงกัน/i }));
const mismatchErr = page await expect(mismatchErr.first()).toBeVisible({ timeout: 12_000 });
.getByText(/รหัสผ่านไม่ตรงกัน/i, { exact: false })
.or(regErrorBox(page).filter({ hasText: /รหัสผ่านไม่ตรงกัน/i }));
await expect(mismatchErr.first()).toBeVisible({ timeout: TIMEOUT.ELEMENT });
}); });
}); });
@ -230,12 +235,13 @@ test.describe('ระบบยืนยันตัวตน (Authentication)',
test('Validation: ใส่อีเมลภาษาไทยแล้วขึ้น Error', async ({ page }) => { test('Validation: ใส่อีเมลภาษาไทยแล้วขึ้น Error', async ({ page }) => {
await forgotEmail(page).fill('ฟฟฟฟ'); await forgotEmail(page).fill('ฟฟฟฟ');
await page.getByRole('heading', { name: /ลืมรหัสผ่าน/i }).click(); await page.getByRole('heading', { name: /ลืมรหัสผ่าน/i }).click();
await expect(page.getByText(/ห้ามใส่ภาษาไทย/i).first()).toBeVisible({ timeout: TIMEOUT.ELEMENT }); const err = page.getByText(/ห้ามใส่ภาษาไทย/i).first();
await expect(err).toBeVisible({ timeout: 10_000 });
}); });
test('กดลิงก์กลับไปหน้า Login ได้', async ({ page }) => { test('กดลิงก์กลับไปหน้า Login ได้', async ({ page }) => {
await forgotBackLink(page).click(); await forgotBackLink(page).click();
await page.waitForURL('**/auth/login', { timeout: TIMEOUT.ELEMENT }); await page.waitForURL('**/auth/login', { timeout: 10_000 });
await expect(page).toHaveURL(/\/auth\/login/i); await expect(page).toHaveURL(/\/auth\/login/i);
}); });
@ -251,10 +257,9 @@ test.describe('ระบบยืนยันตัวตน (Authentication)',
} }
await route.continue(); await route.continue();
}); });
await forgotEmail(page).fill('test@gmail.com'); await forgotEmail(page).fill('test@gmail.com');
await forgotSubmit(page).click(); await forgotSubmit(page).click();
await expect(page.getByText(/ส่งลิงก์เรียบร้อยแล้ว/i, { exact: false })).toBeVisible({ timeout: TIMEOUT.ELEMENT }); await expect(page.getByText(/ส่งลิงก์เรียบร้อยแล้ว/i, { exact: false })).toBeVisible({ timeout: 10_000 });
await expect(page.getByText(/กรุณาตรวจสอบกล่องจดหมาย/i, { exact: false })).toBeVisible(); await expect(page.getByText(/กรุณาตรวจสอบกล่องจดหมาย/i, { exact: false })).toBeVisible();
}); });
}); });

View file

@ -1,77 +1,44 @@
/**
* @file classroom.spec.ts
* @description
* (Classroom, Learning & Quiz System)
*
* 2 module:
* - Classroom & Learning (Layout, Access Control, Video/Quiz area)
* - Quiz System (Start Screen, Pagination, Submit & Navigation)
*/
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { BASE_URL, TIMEOUT, waitAppSettled, setupLogin } from './helpers';
// ========================================== const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
// Mock: ข้อมูล Quiz สำหรับ test
// ==========================================
async function mockQuizData(page: any) {
await page.route('**/lessons/*', async (route: any) => {
const mockQuestions = Array.from({ length: 15 }, (_, i) => ({
id: i + 1,
question: { th: `คำถามข้อที่ ${i + 1}?`, en: `Question ${i + 1}?` },
text: { th: `คำถามข้อที่ ${i + 1}?`, en: `Question ${i + 1}?` },
choices: [
{ id: i * 10 + 1, text: { th: 'ก', en: 'A' } },
{ id: i * 10 + 2, text: { th: 'ข', en: 'B' } },
{ id: i * 10 + 3, text: { th: 'ค', en: 'C' } }
]
}));
await route.fulfill({ async function waitAppSettled(page: any) {
status: 200, await page.waitForLoadState('domcontentloaded');
contentType: 'application/json', await page.waitForLoadState('networkidle').catch(() => {});
body: JSON.stringify({ await page.waitForTimeout(200);
success: true,
data: {
id: 17,
type: 'QUIZ',
quiz: {
id: 99,
title: { th: 'แบบทดสอบปลายภาค (Mock)', en: 'Final Exam (Mock)' },
time_limit: 30,
questions: mockQuestions
}
},
progress: {}
})
});
});
} }
// ========================================== // ฟังก์ชันจำลองล็อกอิน
// Tests async function setupLogin(page: any) {
// ========================================== await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
test.describe('ระบบห้องเรียนออนไลน์และแบบทดสอบ (Classroom & Quiz)', () => { 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)', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await setupLogin(page); await setupLogin(page);
}); });
// --------------------------------------------------
// Section 1: ห้องเรียน (Classroom & Learning)
// --------------------------------------------------
test.describe('ห้องเรียน (Classroom Layout & Access)', () => {
test('6.1 เข้าห้องเรียนหลัก (Classroom Basic Layout)', async ({ page }) => { test('6.1 เข้าห้องเรียนหลัก (Classroom Basic Layout)', async ({ page }) => {
// สมมติว่ามี Course ID: 1 ทดสอบแบบเปิดหน้าตรงๆ
await page.goto(`${BASE_URL}/classroom/learning?course_id=1`); await page.goto(`${BASE_URL}/classroom/learning?course_id=1`);
// 1. โครงร่างของหน้า — ปุ่มกลับ + ไอคอนแผงด้านข้าง // 1. โครงร่างของหน้า (Top Bar) ควรมีปุ่มกลับ กับไอคอนแผงด้านข้าง
const backBtn = page.getByRole('button').filter({ has: page.locator('i.q-icon', { hasText: 'arrow_back' }) }).first(); const backBtn = page.getByRole('button').filter({ has: page.locator('i.q-icon', { hasText: 'arrow_back' }) }).first();
await expect(backBtn).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD }); await expect(backBtn).toBeVisible({ timeout: 15_000 });
const menuCurriculumBtn = page.getByRole('button').filter({ has: page.locator('i.q-icon', { hasText: 'menu_open' }) }).first(); const menuCurriculumBtn = page.getByRole('button').filter({ has: page.locator('i.q-icon', { hasText: 'menu_open' }) }).first();
await expect(menuCurriculumBtn).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD }); await expect(menuCurriculumBtn).toBeVisible({ timeout: 15_000 });
// 2. Sidebar หลักสูตร // 2. เช็คว่ามีพื้นที่ Sidebar หลักสูตร (CurriculumSidebar Component) โผล่ขึ้นมาหรือมีอยู่ใน DOM
const sidebar = page.locator('.q-drawer').first(); const sidebar = page.locator('.q-drawer').first();
if (!await sidebar.isVisible()) { if (!await sidebar.isVisible()) {
await menuCurriculumBtn.click(); await menuCurriculumBtn.click();
@ -80,96 +47,49 @@ test.describe('ระบบห้องเรียนออนไลน์แ
}); });
test('6.2 เช็คสถานะการเข้าถึงเนื้อหา (Access Control)', async ({ page }) => { test('6.2 เช็คสถานะการเข้าถึงเนื้อหา (Access Control)', async ({ page }) => {
// ลองสุ่ม Course ID สูงๆ ที่อาจจะไม่อนุญาตให้เรียน (ไม่มีสิทธิ์) ควรรองรับกล่องแจ้งเตือนด้วย Alert ของระบบ
// ใน learning.vue จะมีการสั่ง `alert(msg)` แต่อาจจะต้องพึ่งกลไก Intercepter
page.on('dialog', async dialog => { page.on('dialog', async dialog => {
// หน้าต่าง Alert ถ้ามีสิทธิ์ไม่อนุญาตมันจะเด้งอันนี้
expect(dialog.message()).toBeTruthy(); expect(dialog.message()).toBeTruthy();
await dialog.accept(); await dialog.accept();
}); });
await page.goto(`${BASE_URL}/classroom/learning?course_id=99999`); await page.goto(`${BASE_URL}/classroom/learning?course_id=99999`);
// รอดู Loading หายไป
const loadingMask = page.locator('.animate-pulse, .q-spinner'); const loadingMask = page.locator('.animate-pulse, .q-spinner');
await loadingMask.first().waitFor({ state: 'hidden', timeout: TIMEOUT.PAGE_LOAD }).catch(() => {}); await loadingMask.first().waitFor({ state: 'hidden', timeout: 20_000 }).catch(() => {});
}); });
test('6.3 การแสดงผลช่องวิดีโอ หรือ พื้นที่ทำข้อสอบ (Video / Quiz)', async ({ page }) => { test('6.3 การแสดงผลช่องวิดีโอ (Video Player) หรือ พื้นที่ทำข้อสอบ (Quiz)', async ({ page }) => {
// เข้าหน้าห้องเรียน Course id: 1
await page.goto(`${BASE_URL}/classroom/learning?course_id=1`); await page.goto(`${BASE_URL}/classroom/learning?course_id=1`);
// กรณีที่ 1: อาจแสดง Video ถ้าเป็นบทเรียนวิดีโอ
const videoLocator = page.locator('video').first(); const videoLocator = page.locator('video').first();
// กรณีที่ 2: ถ้าบทแรกเป็น Quiz จะแสดงไอคอนแบบทดสอบ
const quizLocator = page.getByText(/เริ่มทำแบบทดสอบ|แบบทดสอบ/i).first(); const quizLocator = page.getByText(/เริ่มทำแบบทดสอบ|แบบทดสอบ/i).first();
// กรณีที่ 3: ไม่มีบทเรียนเนื้อหาใดๆ เลยให้แสดง
const errorLocator = page.getByText(/ไม่สามารถเข้าถึง/i).first(); const errorLocator = page.getByText(/ไม่สามารถเข้าถึง/i).first();
try { try {
await Promise.race([ await Promise.race([
videoLocator.waitFor({ state: 'visible', timeout: TIMEOUT.PAGE_LOAD }), videoLocator.waitFor({ state: 'visible', timeout: 20_000 }),
quizLocator.waitFor({ state: 'visible', timeout: TIMEOUT.PAGE_LOAD }), quizLocator.waitFor({ state: 'visible', timeout: 20_000 }),
errorLocator.waitFor({ state: 'visible', timeout: TIMEOUT.PAGE_LOAD }) errorLocator.waitFor({ state: 'visible', timeout: 20_000 })
]); ]);
const isOkay = (await videoLocator.isVisible()) || (await quizLocator.isVisible()) || (await errorLocator.isVisible()); const isOkay = (await videoLocator.isVisible()) || (await quizLocator.isVisible()) || (await errorLocator.isVisible());
expect(isOkay).toBeTruthy(); expect(isOkay).toBeTruthy();
} catch { } catch {
// ถ้าไม่มีเลยใน 20 วิ ถือว่าหน้าอาจจะล้มเหลว หรือเป็น Content เปล่า
// ให้ลอง Capture เพื่อเก็บข้อมูลไปใช้งาน
await page.screenshot({ path: 'tests/e2e/screenshots/classroom-blank-state.png', fullPage: true }); 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,19 +1,14 @@
/**
* @file discovery.spec.ts
* @description (Discovery & Browse)
*/
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { BASE_URL, TIMEOUT, waitAppSettled } from './helpers';
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
test.describe('หมวดหน้าค้นหาคอร์สและผลลัพธ์ (Discovery & Browse)', () => { test.describe('หมวดหน้าค้นหาคอร์สและผลลัพธ์ (Discovery & Browse)', () => {
test.describe('ส่วนหน้าแรก (Home)', () => { test.describe('ส่วนหน้าแรก (Home)', () => {
test('โหลดหน้าแรก และตรวจสอบแสดงผลครบถ้วน (Hero, Cards, Categories)', async ({ page }) => { test('โหลดหน้าแรก และตรวจสอบแสดงผลครบถ้วน (Hero, Cards, Categories)', async ({ page }) => {
await page.goto(BASE_URL); await page.goto(BASE_URL);
await waitAppSettled(page);
const heroTitle = page.locator('h1, h2, .hero-title').first(); const heroTitle = page.locator('h1, h2, .hero-title').first();
await expect(heroTitle).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD }); await expect(heroTitle).toBeVisible({ timeout: 15_000 });
const ctaButton = page.locator('a[href="/browse"]').first(); const ctaButton = page.locator('a[href="/browse"]').first();
if (await ctaButton.isVisible()) { if (await ctaButton.isVisible()) {
@ -21,63 +16,55 @@ test.describe('หมวดหน้าค้นหาคอร์สและ
} }
const courseSectionHeading = page.getByText(/คอร์สออนไลน์|Online Courses/i).first(); const courseSectionHeading = page.getByText(/คอร์สออนไลน์|Online Courses/i).first();
await expect(courseSectionHeading).toBeVisible({ timeout: TIMEOUT.ELEMENT }); await expect(courseSectionHeading).toBeVisible({ timeout: 10_000 });
const allCategoryBtn = page.getByRole('button', { name: /ทั้งหมด|All/i }).first(); const allCategoryBtn = page.getByRole('button', { name: /ทั้งหมด|All/i }).first();
await expect(allCategoryBtn).toBeVisible(); await expect(allCategoryBtn).toBeVisible();
const courseCards = page.locator('div.cursor-pointer').filter({ has: page.locator('img') }); const courseCards = page.locator('div.cursor-pointer').filter({ has: page.locator('img') });
await expect(courseCards.first()).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD }); await expect(courseCards.first()).toBeVisible({ timeout: 15_000 });
expect(await courseCards.count()).toBeGreaterThan(0); expect(await courseCards.count()).toBeGreaterThan(0);
await page.screenshot({ path: 'tests/e2e/screenshots/discovery-home.png', fullPage: true });
}); });
}); });
test.describe('ส่วนค้นหาและแคตตาล็อก (Browse)', () => { test.describe('ส่วนค้นหาและแคตตาล็อก (Browse)', () => {
test('ค้นหาหลักสูตร (Search Course)', async ({ page }) => { test('ค้นหาหลักสูตร (Search Course)', async ({ page }) => {
await page.goto(`${BASE_URL}/browse`); await page.goto(`${BASE_URL}/browse`);
await waitAppSettled(page);
const searchInput = page.locator('input[placeholder="ค้นหาคอร์ส..."]').first(); const searchInput = page.locator('input[placeholder="ค้นหาคอร์ส..."]').first();
await expect(searchInput).toBeVisible({ timeout: TIMEOUT.ELEMENT }); await searchInput.fill('การเขียนโปรแกรม');
await searchInput.fill('Python');
await searchInput.press('Enter'); await searchInput.press('Enter');
await waitAppSettled(page);
// ต้องเจออย่างใดอย่างหนึ่ง: ผลลัพธ์คอร์ส หรือ empty state // ในหน้า browse จะใช้ <NuxtLink> ซึ่ง render เป็น tag <a>
const searchResults = page.locator('a[href*="/course/"]').filter({ has: page.locator('img') }).first(); const searchResults = page.locator('a[href*="/course/"]').filter({ has: page.locator('img') }).first();
const emptyState = page.getByText(/ไม่พบ|ไม่เจอ|No result|not found/i).first() await expect(searchResults).toBeVisible({ timeout: 15_000 });
.or(page.locator('i.q-icon').filter({ hasText: 'search_off' }).first());
await expect(searchResults.or(emptyState)).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
await page.screenshot({ path: 'tests/e2e/screenshots/discovery-search.png', fullPage: true });
}); });
test('ตัวกรองหมวดหมู่คอร์ส (Category Filter)', async ({ page }) => { test('ตัวกรองหมวดหมู่คอร์ส (Category Filter)', async ({ page }) => {
await page.goto(`${BASE_URL}/browse`); await page.goto(`${BASE_URL}/browse`);
await waitAppSettled(page);
const categoryButton = page.locator('button').filter({ hasText: 'การออกแบบ' }).first(); const categoryButton = page.locator('button').filter({ hasText: 'การออกแบบ' }).first();
if (await categoryButton.isVisible()) { if (await categoryButton.isVisible()) {
await categoryButton.click(); await categoryButton.click();
// ในหน้า browse จะใช้ <NuxtLink> ซึ่ง render เป็น tag <a>
const courseCard = page.locator('a[href*="/course/"]').filter({ has: page.locator('img') }).first(); const courseCard = page.locator('a[href*="/course/"]').filter({ has: page.locator('img') }).first();
await expect(courseCard).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD }); await expect(courseCard).toBeVisible({ timeout: 15_000 });
} }
await page.screenshot({ path: 'tests/e2e/screenshots/discovery-filter.png', fullPage: true });
}); });
}); });
test.describe('หน้ารายละเอียดคอร์ส (Course Detail)', () => { test.describe('หน้ารายละเอียดคอร์ส (Course Detail)', () => {
test('โหลดเนื้อหาวิชา (Curriculum) แถบรายละเอียดขึ้นปกติ', async ({ page }) => { test('โหลดเนื้อหาวิชา (Curriculum) แถบรายละเอียดขึ้นปกติ', async ({ page }) => {
await page.goto(`${BASE_URL}/course/1`); await page.goto(`${BASE_URL}`);
await waitAppSettled(page); 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}`);
const courseTitle = page.locator('h1').first(); const courseTitle = page.locator('h1').first();
await expect(courseTitle).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD }); await expect(courseTitle).toBeVisible({ timeout: 15_000 });
const curriculumTab = page.getByRole('tab', { name: /เนื้อหาวิชา|ส่วนหลักสูตร|Curriculum/i }).first(); const curriculumTab = page.getByRole('tab', { name: /เนื้อหาวิชา|ส่วนหลักสูตร|Curriculum/i }).first();
if (await curriculumTab.isVisible()) { if (await curriculumTab.isVisible()) {
@ -86,18 +73,19 @@ test.describe('หมวดหน้าค้นหาคอร์สและ
const lessonItems = page.locator('.q-expansion-item, .lesson-item, [role="listitem"]'); const lessonItems = page.locator('.q-expansion-item, .lesson-item, [role="listitem"]');
await expect(lessonItems.first()).toBeVisible().catch(() => {}); await expect(lessonItems.first()).toBeVisible().catch(() => {});
await page.screenshot({ path: 'tests/e2e/screenshots/discovery-curriculum.png', fullPage: true });
}); });
test('การแสดงผลปุ่ม เข้าเรียน/ลงทะเบียน (Enroll / Start Learning)', async ({ page }) => { test('การแสดงผลปุ่ม เข้าเรียน/ลงทะเบียน (Enroll / Start Learning)', async ({ page }) => {
await page.goto(`${BASE_URL}/course/1`); await page.goto(`${BASE_URL}`);
await waitAppSettled(page); 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(() => {});
const enrollStartBtn = page.locator('button').filter({ hasText: /(เข้าสู่ระบบ|ลงทะเบียน|เข้าเรียน|เรียนต่อ|ซื้อ|ฟรี|Enroll|Start|Buy|Login)/i }).first(); const enrollStartBtn = page.locator('button').filter({ hasText: /(เข้าสู่ระบบ|ลงทะเบียน|เข้าเรียน|เรียนต่อ|ซื้อ|ฟรี|Enroll|Start|Buy|Login)/i }).first();
await expect(enrollStartBtn).toBeVisible({ timeout: TIMEOUT.ELEMENT }); await expect(enrollStartBtn).toBeVisible({ timeout: 10_000 });
await page.screenshot({ path: 'tests/e2e/screenshots/discovery-enroll-btn.png', fullPage: true });
}); });
}); });
}); });

View file

@ -0,0 +1,102 @@
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

@ -1,129 +0,0 @@
/**
* @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

@ -0,0 +1,122 @@
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

@ -0,0 +1,125 @@
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

@ -0,0 +1,241 @@
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,58 +1,54 @@
/**
* @file student-account.spec.ts
* @description (Student Account / Portal)
*/
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { BASE_URL, TIMEOUT, waitAppSettled, setupLogin } from './helpers';
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
async function waitAppSettled(page: any) {
await page.waitForLoadState('domcontentloaded');
await page.waitForLoadState('networkidle').catch(() => {});
await page.waitForTimeout(200);
}
// ฟังก์ชันจำลองล็อกอิน (เพื่อที่จะเข้า Dashboard ได้)
async function setupLogin(page: any) {
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
await page.locator('input[type="email"]').or(page.getByRole('textbox', { name: /อีเมล|email/i })).first().fill('studentedtest@example.com');
await page.locator('input[type="password"]').or(page.getByRole('textbox', { name: /รหัสผ่าน|password/i })).first().fill('admin123');
await page.getByRole('button', { name: /เข้าสู่ระบบ|login/i }).or(page.locator('button[type="submit"]')).first().click();
await page.waitForURL('**/dashboard', { timeout: 15_000 }).catch(() => {});
await waitAppSettled(page);
}
test.describe('ระบบพื้นที่ส่วนตัวผู้เรียน (Student Account / Portal)', () => { test.describe('ระบบพื้นที่ส่วนตัวผู้เรียน (Student Account / Portal)', () => {
test.describe('การตั้งค่าและส่วนติดต่อผู้ใช้ (Settings & UI Theme)', () => { test.describe('การตั้งค่าและส่วนติดต่อผู้ใช้ (Settings & UI Theme)', () => {
test('เปลี่ยนภาษาการแสดงผล (Localisation/i18n)', async ({ page }) => { test('เปลี่ยนภาษาการแสดงผล (Localisation/i18n)', async ({ page }) => {
await page.goto(BASE_URL); await page.goto(BASE_URL);
await waitAppSettled(page); const langBtn = page.getByRole('button', { name: 'Language' }).or(page.locator('button').filter({ hasText: /TH|EN/ })).first();
// หาปุ่มภาษา — ถ้าไม่เจอให้ 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(); await langBtn.click();
const englishOpt = page.locator('text=English, text=EN').first(); const englishOpt = page.locator('text=English, text=EN').first();
await englishOpt.click(); await englishOpt.click();
const loginLink = page.locator('a[href*="login"], button').filter({ hasText: /Log in|Sign In/i }); const loginLink = page.locator('a[href*="login"], button').filter({ hasText: /Log in|Sign In/i });
await expect(loginLink).toBeVisible({ timeout: TIMEOUT.ELEMENT }); await expect(loginLink).toBeVisible({ timeout: 5000 });
}
await page.screenshot({ path: 'tests/e2e/screenshots/student-i18n.png', fullPage: true });
}); });
test('เปลี่ยนโหมดมืดสว่าง (Theme Switcher)', async ({ page }) => { test('เปลี่ยนโหมดมืดสว่าง (Theme Switcher)', async ({ page }) => {
await page.goto(BASE_URL); await page.goto(BASE_URL);
await waitAppSettled(page);
// หาปุ่ม Theme — ถ้าไม่เจอให้ test.skip แทนที่จะผ่านเงียบๆ
const themeBtn = page.locator('.dark-mode-toggle, button[aria-label*="theme"]').first(); const themeBtn = page.locator('.dark-mode-toggle, button[aria-label*="theme"]').first();
const isThemeBtnVisible = await themeBtn.isVisible().catch(() => false); if (await themeBtn.isVisible()) {
if (!isThemeBtnVisible) {
test.skip(true, 'Theme toggle button not found on page — skipping');
return;
}
const htmlBefore = await page.evaluate(() => document.documentElement.className); const htmlBefore = await page.evaluate(() => document.documentElement.className);
await themeBtn.click(); await themeBtn.click();
await page.waitForTimeout(500); await page.waitForTimeout(500);
const htmlAfter = await page.evaluate(() => document.documentElement.className); const htmlAfter = await page.evaluate(() => document.documentElement.className);
expect(htmlBefore).not.toEqual(htmlAfter); expect(htmlBefore).not.toEqual(htmlAfter);
}
await page.screenshot({ path: 'tests/e2e/screenshots/student-theme.png', fullPage: true });
}); });
}); });
@ -63,77 +59,60 @@ test.describe('ระบบพื้นที่ส่วนตัวผู้
test('หน้าแรกของ Dashboard โหลดได้ปกติ', async ({ page }) => { test('หน้าแรกของ Dashboard โหลดได้ปกติ', async ({ page }) => {
await page.goto(`${BASE_URL}/dashboard`); await page.goto(`${BASE_URL}/dashboard`);
await waitAppSettled(page, 1000); await page.waitForTimeout(1000);
const welcomeText = page.getByText(/ยินดีต้อนรับกลับ/i, { exact: false }); const welcomeText = page.getByText(/ยินดีต้อนรับกลับ/i, { exact: false });
const profileSummary = page.locator('.q-avatar, img[alt*="Profile"], img[src*="avatar"]').first(); const profileSummary = page.locator('.q-avatar, img[alt*="Profile"], img[src*="avatar"]').first();
await expect(welcomeText.first().or(profileSummary)).toBeVisible({ timeout: TIMEOUT.ELEMENT }); await expect(welcomeText.first().or(profileSummary)).toBeVisible({ timeout: 10_000 });
await page.screenshot({ path: 'tests/e2e/screenshots/student-dashboard.png', fullPage: true });
}); });
test('โหลดหน้า คอร์สของฉัน (My Courses)', async ({ page }) => { test('โหลดหน้า คอร์สของฉัน (My Courses)', async ({ page }) => {
await page.goto(`${BASE_URL}/dashboard/my-courses`); await page.goto(`${BASE_URL}/dashboard/my-courses`);
await waitAppSettled(page);
const heading = page.locator('h2').filter({ hasText: /คอร์สของฉัน|My Courses/i }).first(); const heading = page.locator('h2').filter({ hasText: /คอร์สของฉัน|My Courses/i }).first();
await expect(heading).toBeVisible({ timeout: TIMEOUT.ELEMENT }); await expect(heading).toBeVisible();
const searchInput = page.getByPlaceholder(/ค้นหา|Search/i).first(); const searchInput = page.getByPlaceholder(/ค้นหา|Search/i).first();
await expect(searchInput).toBeVisible({ timeout: TIMEOUT.ELEMENT }); await expect(searchInput).toBeVisible({ timeout: 10_000 });
await expect(page.locator('i.q-icon').filter({ hasText: 'grid_view' }).first()).toBeVisible(); await expect(page.locator('i.q-icon').filter({ hasText: 'grid_view' }).first()).toBeVisible();
await expect(page.locator('i.q-icon').filter({ hasText: 'view_list' }).first()).toBeVisible(); await expect(page.locator('i.q-icon').filter({ hasText: 'view_list' }).first()).toBeVisible();
await page.screenshot({ path: 'tests/e2e/screenshots/student-my-courses.png', fullPage: true });
}); });
test('ลองค้นหาคอร์ส (Search Input) ไม่พบข้อมูล', async ({ page }) => { test('ลองค้นหาคอร์ส (Search Input) ไม่พบข้อมูล', async ({ page }) => {
await page.goto(`${BASE_URL}/dashboard/my-courses`); await page.goto(`${BASE_URL}/dashboard/my-courses`);
await waitAppSettled(page);
const searchInput = page.getByPlaceholder(/ค้นหา|Search/i).first(); const searchInput = page.getByPlaceholder(/ค้นหา|Search/i).first();
await expect(searchInput).toBeVisible({ timeout: TIMEOUT.ELEMENT }); await expect(searchInput).toBeVisible({ timeout: 10_000 });
await searchInput.fill('คอร์สที่ไม่มีอยู่จริงแน่นอน1234'); await searchInput.fill('คอร์สที่ไม่มีอยู่จริงแน่นอน1234');
const emptyState = page.locator('h3').filter({ hasText: /ไม่พบ|ไม่เจอ|No result/i }).first() const emptyState = page.locator('h3').filter({ hasText: /ไม่พบ|ไม่เจอ|No result/i }).first()
.or(page.locator('i.q-icon').filter({ hasText: 'search_off' })); .or(page.locator('i.q-icon').filter({ hasText: 'search_off' }));
await expect(emptyState.first()).toBeVisible({ timeout: TIMEOUT.ELEMENT }); await expect(emptyState.first()).toBeVisible({ timeout: 10_000 });
await page.screenshot({ path: 'tests/e2e/screenshots/student-search-empty.png', fullPage: true });
}); });
test('แก้ไขและบันทึกข้อมูลส่วนตัว (Edit Profile)', async ({ page }) => { test('แก้ไขและบันทึกข้อมูลส่วนตัว (Edit Profile)', async ({ page }) => {
await page.goto(`${BASE_URL}/dashboard/profile`); await page.goto(`${BASE_URL}/dashboard/profile`);
await waitAppSettled(page, 1000);
// หา input ชื่อ — ใช้ textbox "First Name" หรือ input[type="text"] ตัวแรก const nameInput = page.locator('input[type="text"]').first();
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(); const oldName = await nameInput.inputValue();
await nameInput.clear(); await nameInput.clear();
await nameInput.fill(`${oldName}แก้ไข`); await nameInput.fill(`${oldName}แก้ไข`);
// ปุ่มบันทึก — รองรับทั้งภาษาไทยและอังกฤษ const saveBtn = page.getByRole('button', { name: /บันทึก/i }).first();
const saveBtn = page.getByRole('button', { name: /บันทึก|Save Changes|Save/i }).first(); if(await saveBtn.isVisible()) {
await expect(saveBtn).toBeVisible({ timeout: TIMEOUT.ELEMENT });
await saveBtn.click(); await saveBtn.click();
const successNotify = page.locator('.q-notification__message, text=อัปเดตข้อมูลสำเร็จ').first();
// Toast สำเร็จ — รองรับทั้ง 2 ภาษา await expect(successNotify).toBeVisible({ timeout: 5000 }).catch(() => {});
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,12 +343,10 @@ const save = async () => {
saving.value = true; saving.value = true;
try { try {
// Convert local datetime to ISO string to preserve timezone // Convert local datetime to ISO string to preserve timezone
const payload: any = { ...form.value }; const payload = { ...form.value };
if (payload.published_at) { if (payload.published_at) {
const localDate = new Date(payload.published_at.replace(' ', 'T')); const localDate = new Date(payload.published_at.replace(' ', 'T'));
payload.published_at = localDate.toISOString(); payload.published_at = localDate.toISOString();
} else {
delete payload.published_at;
} }
if (editing.value) { if (editing.value) {
@ -449,7 +447,10 @@ const deleteAttachment = async (attachmentId: number) => {
} }
}; };
// Date formatting function is auto-imported from utils/date.ts const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString('th-TH', { day: 'numeric', month: 'short', year: 'numeric' });
};
const formatFileSize = (bytes: number) => { const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + ' B'; if (bytes < 1024) return bytes + ' B';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -136,7 +136,7 @@
<p class="text-xs text-gray-500 truncate">โดย {{ course.creator.username }}</p> <p class="text-xs text-gray-500 truncate">โดย {{ course.creator.username }}</p>
</div> </div>
<div class="text-xs text-gray-400 whitespace-nowrap"> <div class="text-xs text-gray-400 whitespace-nowrap">
{{ formatDateStr(course.created_at) }} {{ formatDate(course.created_at) }}
</div> </div>
</div> </div>
</div> </div>
@ -170,7 +170,7 @@
<span class="text-gray-600 mx-1">{{ formatAction(log.action) }}</span> <span class="text-gray-600 mx-1">{{ formatAction(log.action) }}</span>
<span class="text-primary-700 font-medium">{{ log.entity_type }} #{{ log.entity_id }}</span> <span class="text-primary-700 font-medium">{{ log.entity_type }} #{{ log.entity_id }}</span>
</p> </p>
<p class="text-xs text-gray-400 mt-0.5">{{ formatDateStr(log.created_at) }}</p> <p class="text-xs text-gray-400 mt-0.5">{{ formatDate(log.created_at) }}</p>
</div> </div>
</div> </div>
</div> </div>
@ -254,7 +254,14 @@ const fetchDashboardData = async () => {
}; };
// Utilities // Utilities
const formatDateStr = (date: string) => formatDateTime(date); const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('th-TH', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
});
};
const getActionIcon = (action: string) => { const getActionIcon = (action: string) => {
if (action.includes('create')) return 'add_circle'; if (action.includes('create')) return 'add_circle';

View file

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

View file

@ -153,8 +153,7 @@
<!-- Category --> <!-- Category -->
<div class="bg-gray-50 p-4 rounded-lg gap-2"> <div class="bg-gray-50 p-4 rounded-lg gap-2">
<div class="font-bold mb-2">หมวดหม (Category):</div> <div class="font-bold mb-2">หมวดหม (Category):</div>
<div v-if="selectedCourse.category" class="text-gray-700 mb-2">{{ selectedCourse.category.name.th }} ({{ selectedCourse.category.name.en }})</div> <div class="text-gray-700 mb-2">{{ selectedCourse.category.name.th }} ({{ selectedCourse.category.name.en }})</div>
<div v-else class="text-gray-400 italic mb-2">ไมหมวดหม</div>
</div> </div>
<!-- Instructors --> <!-- Instructors -->
@ -263,7 +262,7 @@ const columns = [
field: (row: RecommendedCourse) => row.instructors?.find((i: any) => i.is_primary)?.user.username || '', field: (row: RecommendedCourse) => row.instructors?.find((i: any) => i.is_primary)?.user.username || '',
align: 'left' as const align: 'left' as const
}, },
{ name: 'category', label: 'Category', field: (row: RecommendedCourse) => row.category?.name?.th || 'ไม่มีหมวดหมู่', sortable: true, align: 'left' as const }, { name: 'category', label: 'Category', field: (row: RecommendedCourse) => row.category.name.th, sortable: true, align: 'left' as const },
{ name: 'price', label: 'Price', field: 'price', sortable: true, align: 'right' as const }, { name: 'price', label: 'Price', field: 'price', sortable: true, align: 'right' as const },
{ name: 'is_recommended', label: 'Recommended', field: 'is_recommended', sortable: true, align: 'center' as const }, { name: 'is_recommended', label: 'Recommended', field: 'is_recommended', sortable: true, align: 'center' as const },
{ name: 'actions', label: 'Actions', field: 'actions', align: 'center' as const }, { name: 'actions', label: 'Actions', field: 'actions', align: 'center' as const },

View file

@ -324,7 +324,13 @@ const getRoleBadgeColor = (roleCode: string) => {
return colors[roleCode] || 'grey'; return colors[roleCode] || 'grey';
}; };
// Date formatting function is auto-imported from utils/date.ts const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('th-TH', {
day: 'numeric',
month: 'short',
year: '2-digit'
});
};
const viewUser = (user: AdminUserResponse) => { const viewUser = (user: AdminUserResponse) => {
selectedUser.value = user; selectedUser.value = user;

View file

@ -449,7 +449,13 @@ const getStatusLabel = (status: string) => {
return labels[status] || status; return labels[status] || status;
}; };
// Date formatting function is auto-imported from utils/date.ts const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('th-TH', {
day: 'numeric',
month: 'short',
year: '2-digit'
});
};
// Clone Dialog // Clone Dialog
const cloneDialog = ref(false); const cloneDialog = ref(false);
const cloneLoading = ref(false); const cloneLoading = ref(false);

View file

@ -64,21 +64,21 @@
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8"> <div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<q-card class="p-6 text-center"> <q-card class="p-6 text-center">
<div class="text-4xl font-bold text-primary-600 mb-2"> <div class="text-4xl font-bold text-primary-600 mb-2">
{{ stats.totalCourses }} {{ instructorStore.stats.totalCourses }}
</div> </div>
<div class="text-gray-600">หลกสตรทงหมด</div> <div class="text-gray-600">หลกสตรทงหมด</div>
</q-card> </q-card>
<q-card class="p-6 text-center"> <q-card class="p-6 text-center">
<div class="text-4xl font-bold text-secondary-600 mb-2"> <div class="text-4xl font-bold text-secondary-600 mb-2">
{{ stats.totalStudents }} {{ instructorStore.stats.totalStudents }}
</div> </div>
<div class="text-gray-600">เรยนทงหมด</div> <div class="text-gray-600">เรยนทงหมด</div>
</q-card> </q-card>
<q-card class="p-6 text-center"> <q-card class="p-6 text-center">
<div class="text-4xl font-bold text-accent-600 mb-2"> <div class="text-4xl font-bold text-accent-600 mb-2">
{{ stats.completedStudents }} {{ instructorStore.stats.completedStudents }}
</div> </div>
<div class="text-gray-600">เรยนจบแล</div> <div class="text-gray-600">เรยนจบแล</div>
</q-card> </q-card>
@ -96,28 +96,28 @@
<q-icon name="check_circle" color="green" size="24px" /> <q-icon name="check_circle" color="green" size="24px" />
<span class="font-medium text-gray-700">เผยแพรแล</span> <span class="font-medium text-gray-700">เผยแพรแล</span>
</div> </div>
<span class="text-2xl font-bold text-green-600">{{ courseStatusCounts.approved }}</span> <span class="text-2xl font-bold text-green-600">{{ instructorStore.courseStatusCounts.approved }}</span>
</div> </div>
<div class="flex items-center justify-between p-3 bg-orange-50 rounded-lg"> <div class="flex items-center justify-between p-3 bg-orange-50 rounded-lg">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<q-icon name="hourglass_empty" color="orange" size="24px" /> <q-icon name="hourglass_empty" color="orange" size="24px" />
<span class="font-medium text-gray-700">รอตรวจสอบ</span> <span class="font-medium text-gray-700">รอตรวจสอบ</span>
</div> </div>
<span class="text-2xl font-bold text-orange-600">{{ courseStatusCounts.pending }}</span> <span class="text-2xl font-bold text-orange-600">{{ instructorStore.courseStatusCounts.pending }}</span>
</div> </div>
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"> <div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<q-icon name="edit_note" color="grey" size="24px" /> <q-icon name="edit_note" color="grey" size="24px" />
<span class="font-medium text-gray-700">แบบราง</span> <span class="font-medium text-gray-700">แบบราง</span>
</div> </div>
<span class="text-2xl font-bold text-gray-600">{{ courseStatusCounts.draft }}</span> <span class="text-2xl font-bold text-gray-600">{{ instructorStore.courseStatusCounts.draft }}</span>
</div> </div>
<div v-if="courseStatusCounts.rejected > 0" class="flex items-center justify-between p-3 bg-red-50 rounded-lg"> <div v-if="instructorStore.courseStatusCounts.rejected > 0" class="flex items-center justify-between p-3 bg-red-50 rounded-lg">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<q-icon name="cancel" color="red" size="24px" /> <q-icon name="cancel" color="red" size="24px" />
<span class="font-medium text-gray-700">กปฏเสธ</span> <span class="font-medium text-gray-700">กปฏเสธ</span>
</div> </div>
<span class="text-2xl font-bold text-red-600">{{ courseStatusCounts.rejected }}</span> <span class="text-2xl font-bold text-red-600">{{ instructorStore.courseStatusCounts.rejected }}</span>
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
@ -138,7 +138,7 @@
<div class="space-y-4"> <div class="space-y-4">
<q-card <q-card
v-for="course in recentCourses" v-for="course in instructorStore.recentCourses"
:key="course.id" :key="course.id"
class="cursor-pointer hover:shadow-md transition" class="cursor-pointer hover:shadow-md transition"
@click="router.push(`/instructor/courses/${course.id}`)" @click="router.push(`/instructor/courses/${course.id}`)"
@ -172,7 +172,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
import { instructorService } from '~/services/instructor.service';
definePageMeta({ definePageMeta({
layout: 'instructor', layout: 'instructor',
@ -180,32 +179,10 @@ definePageMeta({
}); });
const authStore = useAuthStore(); const authStore = useAuthStore();
const instructorStore = useInstructorStore();
const router = useRouter(); const router = useRouter();
const $q = useQuasar(); const $q = useQuasar();
// Dashboard local state
const stats = ref({
totalCourses: 0,
totalStudents: 0,
completedStudents: 0
});
const courseStatusCounts = ref({
approved: 0,
pending: 0,
draft: 0,
rejected: 0
});
const recentCourses = ref<{
id: number;
title: string;
students: number;
lessons: number;
icon: string;
thumbnail: string | null;
}[]>([]);
// Navigation functions // Navigation functions
const goToProfile = () => { const goToProfile = () => {
router.push('/instructor/profile'); router.push('/instructor/profile');
@ -235,41 +212,9 @@ const handleLogout = () => {
}); });
}; };
// Fetch dashboard data // Fetch dashboard data on mount
const fetchDashboardData = async () => {
try {
const [courses, studentStats] = await Promise.all([
instructorService.getCourses(),
instructorService.getMyStudentsStats()
]);
stats.value.totalCourses = courses.length;
stats.value.totalStudents = studentStats.total_students;
stats.value.completedStudents = studentStats.total_completed;
courseStatusCounts.value = {
approved: courses.filter(c => c.status === 'APPROVED').length,
pending: courses.filter(c => c.status === 'PENDING').length,
draft: courses.filter(c => c.status === 'DRAFT').length,
rejected: courses.filter(c => c.status === 'REJECTED').length
};
recentCourses.value = courses.slice(0, 3).map(course => ({
id: course.id,
title: course.title.th,
students: 0,
lessons: 0,
icon: 'book',
thumbnail: course.thumbnail_url || null
}));
} catch (error) {
console.error('Failed to fetch dashboard data:', error);
}
};
// Fetch data on mount
onMounted(() => { onMounted(() => {
instructorStore.fetchDashboardData();
authStore.fetchUserProfile(); authStore.fetchUserProfile();
fetchDashboardData();
}); });
</script> </script>

View file

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

View file

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

View file

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

View file

@ -3,20 +3,35 @@ export interface LoginRequest {
password: string; password: string;
} }
// API Response structure (from backend) - new format: only token/refreshToken // API Response structure (from backend)
export interface ApiLoginResponse { export interface ApiLoginResponse {
token: string; token: string;
refreshToken: string; refreshToken: string;
} user: {
// JWT Payload structure (decoded from token)
export interface JwtPayload {
id: number; id: number;
username: string; username: string;
email: string; email: string;
roleCode: string; updated_at: string;
iat: number; created_at: string;
exp: number; 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;
};
};
} }
// Frontend User structure // Frontend User structure
@ -40,21 +55,6 @@ export interface ApiResponse<T> {
data: T; data: T;
} }
/**
* Decode JWT payload without verification (read-only)
* Verification is handled by the backend on each request
*/
function decodeJwtPayload(token: string): JwtPayload {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(
atob(base64).split('').map(c =>
'%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
).join('')
);
return JSON.parse(jsonPayload);
}
export const authService = { export const authService = {
async login(email: string, password: string): Promise<LoginResponse> { async login(email: string, password: string): Promise<LoginResponse> {
const config = useRuntimeConfig(); const config = useRuntimeConfig();
@ -71,26 +71,22 @@ export const authService = {
const loginData = response.data; const loginData = response.data;
// Decode JWT to get user info
const payload = decodeJwtPayload(loginData.token);
// Check if user role is STUDENT - block login // Check if user role is STUDENT - block login
if (payload.roleCode === 'STUDENT') { if (loginData.user.role.code === 'STUDENT') {
throw new Error('ไม่สามารถเข้าสู่ระบบได้ ระบบนี้สำหรับผู้สอนและผู้ดูแลระบบเท่านั้น'); throw new Error('ไม่สามารถเข้าสู่ระบบได้ ระบบนี้สำหรับผู้สอนและผู้ดูแลระบบเท่านั้น');
} }
// Return basic user info from JWT payload // Transform API response to frontend format
// Full profile will be fetched via fetchUserProfile() in the auth store
return { return {
token: loginData.token, token: loginData.token,
refreshToken: loginData.refreshToken, refreshToken: loginData.refreshToken,
user: { user: {
id: payload.id.toString(), id: loginData.user.id.toString(),
email: payload.email, email: loginData.user.email,
firstName: '', firstName: loginData.user.profile.first_name,
lastName: '', lastName: loginData.user.profile.last_name,
role: payload.roleCode, role: loginData.user.role.code,
avatarUrl: null avatarUrl: loginData.user.profile.avatar_url
}, },
message: response.message || 'เข้าสู่ระบบสำเร็จ' message: response.message || 'เข้าสู่ระบบสำเร็จ'
}; };

View file

@ -610,19 +610,6 @@ export const instructorService = {
{ method: 'DELETE' } { method: 'DELETE' }
); );
}, },
async getMyStudentsStats(): Promise<{ total_students: number; total_completed: number }> {
const response = await authRequest<{
code: number;
message: string;
total_students: number;
total_completed: number;
}>('/api/instructors/courses/my-students');
return {
total_students: response.total_students,
total_completed: response.total_completed
};
},
async getCourseApprovalHistory(courseId: number): Promise<ApprovalHistory[]> { async getCourseApprovalHistory(courseId: number): Promise<ApprovalHistory[]> {
const response = await authRequest<{ const response = await authRequest<{
code: number; code: number;

View file

@ -1,6 +1,6 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { authService } from '~/services/auth.service'; import { authService } from '~/services/auth.service';
import { userService, type UserProfileResponse } from '~/services/user.service'; import { userService } from '~/services/user.service';
interface User { interface User {
id: string; id: string;
@ -15,8 +15,7 @@ export const useAuthStore = defineStore('auth', {
state: () => ({ state: () => ({
user: null as User | null, user: null as User | null,
token: null as string | null, token: null as string | null,
isAuthenticated: false, isAuthenticated: false
fullProfile: null as UserProfileResponse | null
}), }),
getters: { getters: {
@ -62,7 +61,6 @@ export const useAuthStore = defineStore('auth', {
this.user = null; this.user = null;
this.token = null; this.token = null;
this.isAuthenticated = false; this.isAuthenticated = false;
this.fullProfile = null;
// Clear cookies // Clear cookies
const tokenCookie = useCookie('token'); const tokenCookie = useCookie('token');
@ -128,16 +126,10 @@ export const useAuthStore = defineStore('auth', {
} }
}, },
async fetchUserProfile(force = false) { async fetchUserProfile() {
// Skip if already cached (unless force refresh)
if (!force && this.fullProfile) return;
try { try {
const response = await userService.getProfile(); const response = await userService.getProfile();
// Cache raw API response
this.fullProfile = response;
// Update local user state // Update local user state
this.user = { this.user = {
id: response.id.toString(), id: response.id.toString(),

View file

@ -0,0 +1,122 @@
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;
}
}
}
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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