feat: Implement initial core features including course browsing, authentication, user dashboard, and internationalization.

This commit is contained in:
supalerk-ar66 2026-02-24 11:12:26 +07:00
parent 031ca5c984
commit 797e3db644
19 changed files with 401 additions and 399 deletions

View file

@ -1,20 +1,27 @@
<script setup> <script setup lang="ts">
// Authentication /**
const { fetchUserProfile, isAuthenticated } = useAuth() * @file app.vue
* @description Root application component.
* Handles initialization of authentication and theme settings.
*/
// App (Mounted) // Initialize composables
const { fetchUserProfile, isAuthenticated } = useAuth()
const { isDark, set: setTheme } = useThemeMode()
// App initialization logic
onMounted(() => { onMounted(() => {
// 1. Login ( Token) Profile // 1. Fetch user profile if tokens exist
if (isAuthenticated.value) { if (isAuthenticated.value) {
fetchUserProfile() fetchUserProfile()
} }
// 2. Theme (Dark/Light) LocalStorage // 2. Initialize theme from persistent storage or system preference
const savedTheme = localStorage.getItem('theme') const savedTheme = localStorage.getItem('theme')
if (savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) { if (savedTheme) {
document.documentElement.classList.add('dark') setTheme(savedTheme === 'dark')
} else { } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.remove('dark') setTheme(true)
} }
}) })
</script> </script>

View file

@ -57,7 +57,7 @@ const displayCategory = computed(() => getLocalizedText(props.category))
</script> </script>
<template> <template>
<div class="group relative flex flex-col bg-white dark:!bg-[#0f172a] rounded-3xl overflow-hidden border border-slate-200 dark:border-slate-800 shadow-sm hover:shadow-xl dark:shadow-none hover:-translate-y-1 transition-all duration-300 h-full"> <div class="group relative flex flex-col bg-white dark:!bg-slate-900 rounded-3xl overflow-hidden border border-slate-200 dark:border-white/5 shadow-sm hover:shadow-xl dark:shadow-none hover:-translate-y-1 transition-all duration-300 h-full">
<!-- Thumbnail Section --> <!-- Thumbnail Section -->
<div class="relative w-full aspect-video overflow-hidden"> <div class="relative w-full aspect-video overflow-hidden">
@ -125,7 +125,7 @@ const displayCategory = computed(() => getLocalizedText(props.category))
v-if="showViewDetails && !completed && !progress" v-if="showViewDetails && !completed && !progress"
flat flat
rounded rounded
class="w-full font-bold !text-blue-600 !bg-blue-50 hover:!bg-blue-100 dark:!bg-blue-900/40 dark:!text-blue-300 dark:hover:!bg-blue-900/60" class="w-full font-bold !text-blue-600 !bg-blue-50 hover:!bg-blue-100 dark:!bg-blue-500/10 dark:!text-blue-400 dark:hover:!bg-blue-500/20"
:label="$t('menu.viewDetails')" :label="$t('menu.viewDetails')"
:to="`/course/${id}`" :to="`/course/${id}`"
/> />

View file

@ -36,8 +36,8 @@ const showConfirmPassword = ref(false);
<template> <template>
<div :class="[!flat ? 'card-premium p-6 md:p-8' : '']" class="h-fit"> <div :class="[!flat ? 'card-premium p-6 md:p-8' : '']" class="h-fit">
<div v-if="!flat" class="flex items-center gap-3 mb-8"> <div v-if="!flat" class="flex items-center gap-3 mb-8">
<div class="w-10 h-10 rounded-xl bg-amber-50 dark:bg-amber-900/30 flex items-center justify-center"> <div class="w-10 h-10 rounded-xl bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center">
<q-icon name="lock" class="text-amber-600 dark:text-amber-400 text-xl" /> <q-icon name="lock" class="text-blue-600 dark:text-blue-400 text-xl" />
</div> </div>
<h2 class="text-xl font-black text-slate-900 dark:text-white"> <h2 class="text-xl font-black text-slate-900 dark:text-white">
{{ $t('profile.security') }} {{ $t('profile.security') }}
@ -118,8 +118,8 @@ const showConfirmPassword = ref(false);
type="submit" type="submit"
unelevated unelevated
rounded rounded
class="w-full py-3 font-bold text-base shadow-lg shadow-amber-500/20" class="w-full py-3 font-bold text-base shadow-lg shadow-blue-500/20"
style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); color: white;" style="background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: white;"
:label="$t('profile.changePasswordBtn')" :label="$t('profile.changePasswordBtn')"
:loading="loading" :loading="loading"
/> />

View file

@ -1,45 +1,4 @@
import type { User, LoginResponse, RegisterPayload } from '@/types/auth'
// Interface สำหรับข้อมูลผู้ใช้งาน (User)
interface User {
id: number
username: string
email: string
email_verified_at?: string | null
created_at?: string
updated_at?: string
role: {
code: string // เช่น 'STUDENT', 'INSTRUCTOR', 'ADMIN'
name: { th: string; en: string }
}
profile?: {
prefix: { th: string; en: string }
first_name: string
last_name: string
phone: string | null
avatar_url: string | null
}
}
// Interface สำหรับข้อมูลตอบกลับตอน Login
interface loginResponse {
token: string
refreshToken: string
user: User
profile: User['profile']
}
// Interface สำหรับข้อมูลที่ใช้ลงทะเบียน
interface RegisterPayload {
username: string
email: string
password: string
first_name: string
last_name: string
prefix: { th: string; en: string }
phone: string
}
// ========================================== // ==========================================
// Composable: useAuth // Composable: useAuth

View file

@ -1,151 +1,14 @@
// Interface สำหรับข้อมูลคอร์สเรียน (Public Course Data) import type {
export interface Course { Course,
id: number CourseResponse,
title: string | { th: string; en: string } // รองรับ 2 ภาษา SingleCourseResponse,
slug: string EnrolledCourse,
description: string | { th: string; en: string } EnrolledCourseResponse,
thumbnail_url: string QuizAnswerSubmission,
price: string QuizSubmitRequest,
is_free: boolean QuizResult,
original_price?: string Certificate
have_certificate: boolean } from '@/types/course'
status: string // DRAFT, PUBLISHED
category_id: number
created_at?: string
updated_at?: string
created_by?: number
updated_by?: number
approved_at?: string
approved_by?: number
rejection_reason?: string
enrolled?: boolean
total_lessons?: number
rating?: string
lessons?: number | string
levelType?: 'neutral' | 'warning' | 'success' // ใช้สำหรับการแสดงผล Badge ระดับความยาก (Frontend Logic)
// โครงสร้างบทเรียน (Chapters & Lessons)
chapters?: {
id: number
title: string | { th: string; en: string }
lessons: {
id: number
title: string | { th: string; en: string }
duration_minutes: number
video_url?: string
}[]
}[]
// ข้อมูลผู้สอนและเจ้าของคอร์ส
creator?: {
id: number
username: string
email: string
profile: {
first_name: string
last_name: string
avatar_url: string
}
}
instructors?: {
user_id: number
is_primary: boolean
user: {
id: number
username: string
email: string
profile: {
first_name: string
last_name: string
avatar_url: string
}
}
}[]
}
interface CourseResponse {
code: number
message: string
data: Course[]
total: number
page?: number
limit?: number
totalPages?: number
}
interface SingleCourseResponse {
code: number
message: string
data: Course
}
// Interface สำหรับคอร์สที่ผู้ใช้ลงทะเบียนเรียนแล้ว (My Course)
export interface EnrolledCourse {
id: number
course_id: number
course: Course
status: 'ENROLLED' | 'IN_PROGRESS' | 'COMPLETED' | 'DROPPED'
progress_percentage: number
enrolled_at: string
started_at?: string
completed_at?: string
last_accessed_at?: string
}
interface EnrolledCourseResponse {
code: number
message: string
data: EnrolledCourse[]
total: number
page: number
limit: number
}
// Interface สำหรับการส่งคำตอบแบบทดสอบ (Quiz Submission)
export interface QuizAnswerSubmission {
question_id: number
choice_id: number
}
export interface QuizSubmitRequest {
answers: QuizAnswerSubmission[]
}
// Interface สำหรับผลลัพธ์การสอบ (Quiz Result)
export interface QuizResult {
answers_review: {
score: number
is_correct: boolean
correct_choice_id: number
selected_choice_id: number
question_id: number
}[]
completed_at: string
started_at: string
attempt_number: number
passing_score: number
is_passed: boolean
correct_answers: number
total_questions: number
total_score: number
score: number
quiz_id: number
attempt_id: number
}
// Interface สำหรับ Certificate
export interface Certificate {
certificate_id: number
course_id: number
course_title: {
en: string
th: string
}
issued_at: string
download_url: string
}
// ========================================== // ==========================================
// Composable: useCourse // Composable: useCourse

View file

@ -0,0 +1,55 @@
/**
* @file landing.ts
* @description Static data for the landing page.
*/
export const CATEGORY_CARDS = [
{
title: 'โปรแกรมมิ่ง',
desc: 'เชี่ยวชาญการเขียนโค้ดและพัฒนาซอฟต์แวร์',
icon: 'code',
slug: 'programming',
iconColor: 'text-blue-600',
iconBg: 'bg-blue-600/5'
},
{
title: 'การออกแบบ',
desc: 'ทักษะ UI/UX และการออกแบบระดับมือโปร',
icon: 'palette',
slug: 'design',
iconColor: 'text-indigo-600',
iconBg: 'bg-indigo-600/5'
},
{
title: 'ธุรกิจ',
desc: 'ทักษะการจัดการและความเป็นผู้นำสากล',
icon: 'business_center',
slug: 'business',
iconColor: 'text-blue-700',
iconBg: 'bg-blue-700/5'
}
]
export const WHY_CHOOSE_US = [
{
title: 'ผู้สอนเชี่ยวชาญ',
desc: 'เรียนรู้จากผู้นำในอุตสาหกรรมที่มีประสบการณ์การทำงานหลายปีในบริษัทเทคโนโลยีชั้นนำระดับโลก',
icon: 'groups',
iconBg: 'bg-blue-600/10',
iconColor: 'text-blue-600'
},
{
title: 'การเรียนรู้ที่ยืดหยุ่น',
desc: 'เรียนตามจังหวะของคุณเอง ได้ทุกที่ทุกเวลา เข้าถึงเนื้อหาคอร์สที่สมัครเรียนได้ตลอดชีพ',
icon: 'schedule',
iconBg: 'bg-indigo-600/10',
iconColor: 'text-indigo-600'
},
{
title: 'ประกาศนียบัตรเมื่อเรียนจบ',
desc: 'รับวุฒิบัตรที่เป็นที่ยอมรับเพื่อเสริมพอร์ตโฟลิโอระดับมืออาชีพของคุณและแชร์ลง LinkedIn ได้โดยตรง',
icon: 'verified',
iconBg: 'bg-blue-600/10',
iconColor: 'text-blue-600'
}
]

View file

@ -87,7 +87,7 @@
"overview": "Home", "overview": "Home",
"myCourses": "My Courses", "myCourses": "My Courses",
"browseCourses": "Browse Courses", "browseCourses": "Browse Courses",
"onlineCourses": "Online Courses", "onlineCourses": "All Courses",
"recommendedCourses": "Recommended Courses", "recommendedCourses": "Recommended Courses",
"announcements": "Announcements", "announcements": "Announcements",
"profile": "My Profile" "profile": "My Profile"
@ -107,9 +107,14 @@
"backToCatalog": "Back to Catalog", "backToCatalog": "Back to Catalog",
"selectable": "Selected", "selectable": "Selected",
"foundTotal": "Found Total", "foundTotal": "Found Total",
"items": "items" "items": "items",
"subtitle": "Choose to learn new skills from our curated quality courses",
"searchBtn": "Search"
}, },
"myCourses": { "myCourses": {
"title": "My Courses",
"subtitle": "Track your progress and continue learning from where you left off",
"searchPlaceholder": "Search my courses...",
"filterAll": "All", "filterAll": "All",
"filterProgress": "In Progress", "filterProgress": "In Progress",
"filterCompleted": "Completed", "filterCompleted": "Completed",

View file

@ -87,7 +87,7 @@
"overview": "หน้าหลัก", "overview": "หน้าหลัก",
"myCourses": "คอร์สของฉัน", "myCourses": "คอร์สของฉัน",
"browseCourses": "ค้นหาคอร์ส", "browseCourses": "ค้นหาคอร์ส",
"onlineCourses": "คอร์สออนไลน์", "onlineCourses": "คอร์สเรียนทั้งหมด",
"recommendedCourses": "คอร์สเรียนแนะนำ", "recommendedCourses": "คอร์สเรียนแนะนำ",
"announcements": "ข่าวประกาศ", "announcements": "ข่าวประกาศ",
"profile": "บัญชีผู้ใช้" "profile": "บัญชีผู้ใช้"
@ -108,9 +108,13 @@
"selectable": "รายการที่เลือก", "selectable": "รายการที่เลือก",
"foundTotal": "พบทั้งหมด", "foundTotal": "พบทั้งหมด",
"items": "รายการ", "items": "รายการ",
"subtitle": "เลือกเรียนรู้ทักษะใหม่ๆ จากหลักสูตรคุณภาพที่คัดสรรมาเพื่อคุณ" "subtitle": "เลือกเรียนรู้ทักษะใหม่ๆ จากหลักสูตรคุณภาพที่คัดสรรมาเพื่อคุณ",
"searchBtn": "ค้นหา"
}, },
"myCourses": { "myCourses": {
"title": "คอร์สของฉัน",
"subtitle": "ติดตามความคืบหน้าและเรียนรู้ต่อจากจุดที่ค้างไว้",
"searchPlaceholder": "ค้นหาชื่อคอร์สของฉัน...",
"filterAll": "ทั้งหมด", "filterAll": "ทั้งหมด",
"filterProgress": "กำลังเรียน", "filterProgress": "กำลังเรียน",
"filterCompleted": "เรียนจบแล้ว", "filterCompleted": "เรียนจบแล้ว",
@ -187,7 +191,7 @@
"logout": "ออกจากระบบ" "logout": "ออกจากระบบ"
}, },
"landing": { "landing": {
"allCourses": "คอร์สทั้งหมด", "allCourses": "คอร์สเรียนทั้งหมด",
"discovery": "ค้นพบ", "discovery": "ค้นพบ",
"goToDashboard": "เข้าสู่หน้าจัดการเรียน" "goToDashboard": "เข้าสู่หน้าจัดการเรียน"
}, },

View file

@ -58,13 +58,12 @@ const shouldHideSidebar = computed(() => {
v-model="rightDrawerOpen" v-model="rightDrawerOpen"
side="right" side="right"
overlay overlay
bordered
class="bg-white dark:!bg-[#0f172a]" class="bg-white dark:!bg-[#0f172a]"
:width="300" :width="300"
> >
<div class="flex flex-col h-full bg-white dark:bg-[#0f172a]"> <div class="flex flex-col h-full bg-white dark:!bg-[#0f172a] text-slate-900 dark:!text-slate-100">
<!-- 1. Account Section --> <!-- 1. Account Section -->
<div class="p-6 bg-slate-50/50 dark:bg-slate-800/30 border-b border-slate-100 dark:border-slate-800"> <div class="p-6 bg-slate-50/50 dark:!bg-slate-800/20">
<div class="flex items-center justify-between mb-8"> <div class="flex items-center justify-between mb-8">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center text-white font-black">E</div> <div class="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center text-white font-black">E</div>
@ -78,39 +77,37 @@ const shouldHideSidebar = computed(() => {
<img :src="currentUser?.photoURL || 'https://cdn.quasar.dev/img/avatar.png'" /> <img :src="currentUser?.photoURL || 'https://cdn.quasar.dev/img/avatar.png'" />
</q-avatar> </q-avatar>
<div class="overflow-hidden"> <div class="overflow-hidden">
<p class="font-bold text-slate-900 dark:text-white mb-0 truncate text-lg"> <p class="font-bold text-slate-900 dark:!text-white mb-0 truncate text-lg">
{{ currentUser?.firstName || 'Guest' }} {{ currentUser?.lastName || '' }} {{ currentUser?.firstName || 'Guest' }} {{ currentUser?.lastName || '' }}
</p> </p>
<p class="text-xs text-slate-500 dark:text-slate-400 truncate">{{ currentUser?.email || 'e-learning@platform.com' }}</p> <p class="text-xs text-slate-500 dark:!text-slate-400 truncate">{{ currentUser?.email || 'e-learning@platform.com' }}</p>
</div> </div>
</div> </div>
</div> </div>
<!-- 2. Integrated Content Hub --> <!-- 2. Integrated Content Hub -->
<div class="flex-grow overflow-y-auto pt-4"> <div class="flex-grow overflow-y-auto pt-4">
<q-list padding class="text-slate-600 dark:text-slate-300"> <q-list padding class="text-slate-600 dark:!text-slate-300">
<!-- Navigation --> <!-- Navigation -->
<q-item-label header class="text-[11px] font-black tracking-[0.2em] text-slate-400 uppercase px-6 pb-2">เมนหล</q-item-label> <q-item-label header class="text-[11px] font-black tracking-[0.2em] text-slate-400 uppercase px-6 pb-2">เมนหล</q-item-label>
<q-item to="/dashboard" clickable v-ripple class="px-6 py-4" active-class="bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400 font-bold" @click="rightDrawerOpen = false"> <q-item to="/dashboard" clickable v-ripple class="px-6 py-4" active-class="bg-blue-50 text-blue-600 dark:!bg-blue-900/30 dark:!text-blue-400 font-bold" @click="rightDrawerOpen = false">
<q-item-section avatar><q-icon name="dashboard" size="24px" /></q-item-section> <q-item-section avatar><q-icon name="dashboard" size="24px" /></q-item-section>
<q-item-section><span class="text-[15px] font-bold">{{ $t("sidebar.overview") }}</span></q-item-section> <q-item-section><span class="text-[15px] font-bold">{{ $t("sidebar.overview") }}</span></q-item-section>
</q-item> </q-item>
<q-item to="/browse/discovery" clickable v-ripple class="px-6 py-4" active-class="bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400 font-bold" @click="rightDrawerOpen = false"> <q-item to="/browse/discovery" clickable v-ripple class="px-6 py-4" active-class="bg-blue-50 text-blue-600 dark:!bg-blue-900/30 dark:!text-blue-400 font-bold" @click="rightDrawerOpen = false">
<q-item-section avatar><q-icon name="explore" size="24px" /></q-item-section> <q-item-section avatar><q-icon name="explore" size="24px" /></q-item-section>
<q-item-section><span class="text-[15px] font-bold">{{ $t("landing.allCourses") }}</span></q-item-section> <q-item-section><span class="text-[15px] font-bold">{{ $t("landing.allCourses") }}</span></q-item-section>
</q-item> </q-item>
<q-item to="/dashboard/my-courses" clickable v-ripple class="px-6 py-4" active-class="bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400 font-bold" @click="rightDrawerOpen = false"> <q-item to="/dashboard/my-courses" clickable v-ripple class="px-6 py-4" active-class="bg-blue-50 text-blue-600 dark:!bg-blue-900/30 dark:!text-blue-400 font-bold" @click="rightDrawerOpen = false">
<q-item-section avatar><q-icon name="school" size="24px" /></q-item-section> <q-item-section avatar><q-icon name="school" size="24px" /></q-item-section>
<q-item-section><span class="text-[15px] font-bold">{{ $t("sidebar.myCourses") || 'คอร์สเรียนของฉัน' }}</span></q-item-section> <q-item-section><span class="text-[15px] font-bold">{{ $t("sidebar.myCourses") || 'คอร์สเรียนของฉัน' }}</span></q-item-section>
</q-item> </q-item>
<q-separator class="my-4 mx-6 opacity-50" />
<!-- Tools & Settings --> <!-- Tools & Settings -->
<q-item-label header class="text-[11px] font-black tracking-[0.2em] text-slate-400 uppercase px-6 pb-2">เครองมอและการตงค</q-item-label> <q-item-label header class="text-[11px] font-black tracking-[0.2em] text-slate-400 uppercase px-6 pb-2 mt-4">เครองมอและการตงค</q-item-label>
<!-- Language Selection --> <!-- Language Selection -->
<q-item class="px-6 py-2"> <q-item class="px-6 py-2">
@ -140,16 +137,16 @@ const shouldHideSidebar = computed(() => {
<q-item clickable v-ripple @click="navigateTo('/dashboard/profile'); rightDrawerOpen = false" class="px-6 py-4"> <q-item clickable v-ripple @click="navigateTo('/dashboard/profile'); rightDrawerOpen = false" class="px-6 py-4">
<q-item-section avatar><q-icon name="person_outline" size="24px" /></q-item-section> <q-item-section avatar><q-icon name="person_outline" size="24px" /></q-item-section>
<q-item-section><span class="font-bold text-[15px]">ดการโปรไฟล</span></q-item-section> <q-item-section><span class="font-bold text-[15px] dark:!text-slate-300">ดการโปรไฟล</span></q-item-section>
</q-item> </q-item>
</q-list> </q-list>
</div> </div>
<!-- 3. Bottom Actions --> <!-- 3. Bottom Actions -->
<div class="p-6 mt-auto border-t border-slate-100 dark:border-slate-800"> <div class="p-6 mt-auto border-t border-slate-100 dark:!border-white/10">
<q-btn <q-btn
unelevated unelevated
class="full-width rounded-xl bg-red-50 text-red-600 dark:bg-red-900/20 dark:text-red-400 font-bold py-3 no-caps transition-all active:scale-95" class="full-width rounded-xl bg-red-50 text-red-600 dark:!bg-red-900/20 dark:!text-red-400 font-bold py-3 no-caps transition-all active:scale-95"
@click="logout" @click="logout"
> >
<q-icon name="logout" size="20px" class="mr-2" /> <q-icon name="logout" size="20px" class="mr-2" />

View file

@ -237,17 +237,24 @@ onMounted(() => {
<div v-else class="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div> <div v-else class="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
</button> </button>
</form> <!-- Test Credentials Box -->
<div class="mt-4 p-5 bg-blue-50/50 border border-blue-100 rounded-2xl flex flex-col items-center gap-2 animate-fade-in">
<!-- Divider --> <div class="text-[11px] font-black uppercase tracking-[0.2em] text-blue-600 mb-1">ญชสำหรบทดสอบ (Test Account)</div>
<div class="my-8 flex items-center gap-4"> <div class="flex flex-col items-center gap-1">
<div class="h-px bg-slate-200 flex-1"></div> <div class="text-base font-black text-slate-900 select-all cursor-copy hover:text-blue-600 transition-colors">
<span class="text-slate-400 text-xs font-medium uppercase tracking-wider">หร</span> studentedtest@example.com
<div class="h-px bg-slate-200 flex-1"></div> </div>
<div class="flex items-center gap-2">
<span class="text-[11px] font-black uppercase tracking-wider text-slate-600">Password:</span>
<span class="text-base font-black select-all cursor-copy hover:text-blue-600 transition-colors text-slate-900">admin123</span>
</div>
</div>
</div> </div>
</form>
<!-- Register Link --> <!-- Register Link -->
<div class="text-center"> <div class="text-center mt-8">
<p class="text-slate-600 text-sm"> <p class="text-slate-600 text-sm">
งไมญชสมาช? งไมญชสมาช?
<NuxtLink to="/auth/register" class="font-bold text-blue-600 hover:text-blue-700 transition-colors ml-1"> <NuxtLink to="/auth/register" class="font-bold text-blue-600 hover:text-blue-700 transition-colors ml-1">

View file

@ -159,7 +159,6 @@ onMounted(() => {
<!-- New Enhanced Search Section (Image 1 Style) --> <!-- New Enhanced Search Section (Image 1 Style) -->
<div class="bg-blue-50/50 dark:bg-blue-900/10 rounded-[2.5rem] p-8 md:p-10 mb-8 border border-blue-100/50 dark:border-blue-500/10 transition-colors duration-300"> <div class="bg-blue-50/50 dark:bg-blue-900/10 rounded-[2.5rem] p-8 md:p-10 mb-8 border border-blue-100/50 dark:border-blue-500/10 transition-colors duration-300">
<div class="flex items-center gap-4 mb-2"> <div class="flex items-center gap-4 mb-2">
<span class="w-2 h-8 bg-blue-600 rounded-full shadow-lg shadow-blue-500/30"></span>
<h1 class="text-2xl md:text-3xl font-black text-slate-900 dark:text-white"> <h1 class="text-2xl md:text-3xl font-black text-slate-900 dark:text-white">
{{ $t("discovery.title") }} {{ $t("discovery.title") }}
</h1> </h1>
@ -178,7 +177,7 @@ onMounted(() => {
v-model="searchQuery" v-model="searchQuery"
type="text" type="text"
:placeholder="$t('discovery.searchPlaceholder') || 'ค้นหาคอร์สที่น่าสนใจที่นี่...'" :placeholder="$t('discovery.searchPlaceholder') || 'ค้นหาคอร์สที่น่าสนใจที่นี่...'"
class="w-full pl-14 pr-6 py-3.5 bg-white dark:bg-slate-800 border-2 border-transparent rounded-2xl text-slate-900 dark:text-white placeholder-slate-400 focus:outline-none focus:border-blue-500/20 focus:ring-4 focus:ring-blue-500/5 transition-all text-base font-medium shadow-sm" class="w-full pl-14 pr-6 py-3.5 bg-white dark:!bg-slate-900/80 border-2 border-transparent dark:border-white/5 rounded-2xl text-slate-900 dark:text-white placeholder-slate-400 focus:outline-none focus:border-blue-500/20 focus:ring-4 focus:ring-blue-500/5 transition-all text-base font-medium shadow-sm"
@keyup.enter="loadCourses(1)" @keyup.enter="loadCourses(1)"
/> />
</div> </div>
@ -193,13 +192,13 @@ onMounted(() => {
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<q-icon name="search" size="20px" /> <q-icon name="search" size="20px" />
<span class="text-base">นหา</span> <span class="text-base">{{ $t("discovery.searchBtn") }}</span>
</div> </div>
</q-btn> </q-btn>
</div> </div>
</div> </div>
<div class="flex items-center justify-between mb-4 px-2"> <div class="flex items-center justify-between mb-8 px-2">
<div class="text-slate-500 dark:text-slate-400 text-sm font-bold uppercase tracking-wider"> <div class="text-slate-500 dark:text-slate-400 text-sm font-bold uppercase tracking-wider">
{{ $t("discovery.foundTotal") }} <span class="text-blue-600">{{ filteredCourses.length }}</span> {{ $t("discovery.items") }} {{ $t("discovery.foundTotal") }} <span class="text-blue-600">{{ filteredCourses.length }}</span> {{ $t("discovery.items") }}
</div> </div>
@ -207,7 +206,7 @@ onMounted(() => {
<!-- Unified Filter Section: Categories --> <!-- Unified Filter Section: Categories -->
<div <div
class="bg-white dark:bg-slate-900/50 p-2 rounded-2xl border border-slate-100 dark:border-white/5 inline-flex flex-wrap items-center gap-1.5 shadow-sm" class="bg-white dark:!bg-slate-900/50 p-2 rounded-2xl border border-slate-100 dark:border-white/5 inline-flex flex-wrap items-center gap-1.5 shadow-sm mb-12"
> >
<q-btn <q-btn
flat flat
@ -217,7 +216,7 @@ onMounted(() => {
:class=" :class="
selectedCategoryIds.length === 0 selectedCategoryIds.length === 0
? 'bg-blue-600 text-white shadow-md shadow-blue-600/20' ? 'bg-blue-600 text-white shadow-md shadow-blue-600/20'
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800' : 'text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:!hover:bg-slate-800/50'
" "
@click="selectedCategoryIds = []" @click="selectedCategoryIds = []"
:label="$t('discovery.showAll')" :label="$t('discovery.showAll')"
@ -232,7 +231,7 @@ onMounted(() => {
:class=" :class="
selectedCategoryIds.includes(cat.id) selectedCategoryIds.includes(cat.id)
? 'bg-blue-600 text-white shadow-md shadow-blue-600/20' ? 'bg-blue-600 text-white shadow-md shadow-blue-600/20'
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800' : 'text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:!hover:bg-slate-800/50'
" "
@click="toggleCategory(cat.id)" @click="toggleCategory(cat.id)"
:label="getLocalizedText(cat.name)" :label="getLocalizedText(cat.name)"

View file

@ -237,7 +237,7 @@ const sideCourses = computed(() => enrolledCourses.value.slice(1, 3));
<div <div
v-for="course in sideCourses" v-for="course in sideCourses"
:key="course.id" :key="course.id"
class="flex-1 bg-white dark:bg-[#1e293b] rounded-2xl p-4 border border-gray-100 dark:border-slate-700 shadow-sm hover:shadow-md transition-all flex gap-4 items-center" class="flex-1 bg-white dark:!bg-slate-900/40 rounded-2xl p-4 border border-slate-100 dark:border-white/5 shadow-sm hover:shadow-md transition-all flex gap-4 items-center"
> >
<div class="w-32 h-20 rounded-xl overflow-hidden flex-shrink-0"> <div class="w-32 h-20 rounded-xl overflow-hidden flex-shrink-0">
<img <img
@ -291,7 +291,7 @@ const sideCourses = computed(() => enrolledCourses.value.slice(1, 3));
<!-- Empty State Placeholder if less than 2 side courses --> <!-- Empty State Placeholder if less than 2 side courses -->
<div <div
v-if="sideCourses.length < 2" v-if="sideCourses.length < 2"
class="flex-1 bg-gray-50 dark:bg-[#1e293b]/50 rounded-2xl border border-dashed border-gray-200 dark:border-slate-700 flex items-center justify-center text-gray-400 dark:text-slate-500 text-sm transition-colors" class="flex-1 bg-slate-50 dark:!bg-slate-900/30 rounded-2xl border border-dashed border-slate-200 dark:border-slate-800 flex items-center justify-center text-slate-400 dark:text-slate-600 text-sm transition-colors"
> >
{{ $t("dashboard.startNewCourse") }} {{ $t("dashboard.startNewCourse") }}
</div> </div>
@ -326,9 +326,8 @@ const sideCourses = computed(() => enrolledCourses.value.slice(1, 3));
class="h-full md:col-span-1" class="h-full md:col-span-1"
/> />
<!-- CTA Card (Large) -->
<div <div
class="bg-white dark:bg-[#1e293b] rounded-3xl border border-gray-100 dark:border-slate-700 shadow-sm p-8 flex flex-col items-center justify-center text-center h-full min-h-[300px] hover:shadow-md transition-all group" class="bg-white dark:!bg-slate-900/40 rounded-3xl border border-slate-100 dark:border-white/5 shadow-sm p-8 flex flex-col items-center justify-center text-center h-full min-h-[300px] hover:shadow-md transition-all group"
> >
<p class="text-gray-600 dark:text-slate-300 font-medium mb-6 mt-4 transition-colors"> <p class="text-gray-600 dark:text-slate-300 font-medium mb-6 mt-4 transition-colors">
{{ $t("dashboard.chooseLibrary") }} {{ $t("dashboard.chooseLibrary") }}
@ -346,10 +345,9 @@ const sideCourses = computed(() => enrolledCourses.value.slice(1, 3));
</div> </div>
</div> </div>
<!-- Empty State when no courses -->
<div <div
v-else v-else
class="bg-white dark:bg-[#1e293b] rounded-3xl border border-dashed border-gray-200 dark:border-slate-700 p-12 flex flex-col items-center justify-center text-center min-h-[300px] transition-colors" class="bg-white dark:!bg-slate-900/40 rounded-3xl border border-dashed border-slate-200 dark:border-slate-800 p-12 flex flex-col items-center justify-center text-center min-h-[300px] transition-colors"
> >
<div class="bg-blue-50 dark:bg-blue-900/20 p-6 rounded-full mb-6 transition-colors"> <div class="bg-blue-50 dark:bg-blue-900/20 p-6 rounded-full mb-6 transition-colors">
<q-icon name="school" size="48px" class="text-blue-200 dark:text-blue-400" /> <q-icon name="school" size="48px" class="text-blue-200 dark:text-blue-400" />

View file

@ -11,8 +11,10 @@ definePageMeta({
middleware: 'auth' middleware: 'auth'
}) })
const { t, locale } = useI18n()
useHead({ useHead({
title: 'คอร์สของฉัน - e-Learning' title: `${t('sidebar.myCourses')} - e-Learning`
}) })
const route = useRoute() const route = useRoute()
@ -28,7 +30,6 @@ onMounted(() => {
} }
}) })
const { locale } = useI18n()
// Helper to get localized text // Helper to get localized text
const getLocalizedText = (text: string | { th: string; en: string } | undefined) => { const getLocalizedText = (text: string | { th: string; en: string } | undefined) => {
@ -153,8 +154,8 @@ const validCourseId = computed(() => {
<!-- Page Header & Filters (Unified Layout) --> <!-- Page Header & Filters (Unified Layout) -->
<!-- New Enhanced Search Section (Image 2 Style) --> <!-- New Enhanced Search Section (Image 2 Style) -->
<div class="bg-blue-50/50 dark:bg-blue-900/10 rounded-[2.5rem] p-8 md:p-10 mb-6 border border-blue-100/50 dark:border-blue-500/10"> <div class="bg-blue-50/50 dark:bg-blue-900/10 rounded-[2.5rem] p-8 md:p-10 mb-6 border border-blue-100/50 dark:border-blue-500/10">
<h2 class="text-2xl md:text-3xl font-black text-slate-900 dark:text-white mb-2">คอรสของฉ</h2> <h2 class="text-2xl md:text-3xl font-black text-slate-900 dark:text-white mb-2">{{ $t('myCourses.title') }}</h2>
<p class="text-slate-500 dark:text-slate-400 font-medium mb-8">ดตามความคบหนาและเรยนรอจากจดทางไว</p> <p class="text-slate-500 dark:text-slate-400 font-medium mb-8">{{ $t('myCourses.subtitle') }}</p>
<div class="flex flex-col md:flex-row gap-4"> <div class="flex flex-col md:flex-row gap-4">
<!-- Search Input --> <!-- Search Input -->
@ -165,8 +166,8 @@ const validCourseId = computed(() => {
<input <input
v-model="searchQuery" v-model="searchQuery"
type="text" type="text"
placeholder="ค้นหาชื่อคอร์สของฉัน..." :placeholder="$t('myCourses.searchPlaceholder')"
class="w-full pl-14 pr-6 py-3.5 bg-white dark:bg-slate-800 border-2 border-transparent rounded-2xl text-slate-900 dark:text-white placeholder-slate-400 focus:outline-none focus:border-blue-500/20 focus:ring-4 focus:ring-blue-500/5 transition-all text-base font-medium shadow-sm" class="w-full pl-14 pr-6 py-3.5 bg-white dark:!bg-slate-900/80 border-2 border-transparent dark:border-white/5 rounded-2xl text-slate-900 dark:text-white placeholder-slate-400 focus:outline-none focus:border-blue-500/20 focus:ring-4 focus:ring-blue-500/5 transition-all text-base font-medium shadow-sm"
/> />
</div> </div>
@ -179,15 +180,15 @@ const validCourseId = computed(() => {
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<q-icon name="search" size="20px" /> <q-icon name="search" size="20px" />
<span class="text-base">นหา</span> <span class="text-base">{{ $t("discovery.searchBtn") }}</span>
</div> </div>
</q-btn> </q-btn>
</div> </div>
</div> </div>
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8"> <div class="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-12">
<!-- Filter Tabs (Horizontal Bar) --> <!-- Filter Tabs (Horizontal Bar) -->
<div class="bg-white dark:bg-slate-900/50 p-1.5 rounded-2xl border border-slate-100 dark:border-white/5 inline-flex items-center gap-1 shadow-sm"> <div class="bg-white dark:!bg-slate-900/50 p-1.5 rounded-2xl border border-slate-100 dark:border-white/5 inline-flex items-center gap-1 shadow-sm">
<q-btn <q-btn
v-for="filter in ['all', 'progress', 'completed']" v-for="filter in ['all', 'progress', 'completed']"
:key="filter" :key="filter"
@ -196,7 +197,7 @@ const validCourseId = computed(() => {
rounded rounded
dense dense
class="px-5 py-2 font-bold transition-all text-[11px] uppercase tracking-wider" class="px-5 py-2 font-bold transition-all text-[11px] uppercase tracking-wider"
:class="activeFilter === filter ? 'bg-blue-600 text-white shadow-md shadow-blue-600/20' : 'text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800'" :class="activeFilter === filter ? 'bg-blue-600 text-white shadow-md shadow-blue-600/20' : 'text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:!hover:bg-slate-800/50'"
:label="$t(`myCourses.filter${filter.charAt(0).toUpperCase() + filter.slice(1)}`)" :label="$t(`myCourses.filter${filter.charAt(0).toUpperCase() + filter.slice(1)}`)"
/> />
</div> </div>
@ -236,7 +237,7 @@ const validCourseId = computed(() => {
</div> </div>
<!-- Empty State --> <!-- Empty State -->
<div v-if="!isLoading && filteredEnrolledCourses.length === 0" class="flex flex-col items-center justify-center py-20 bg-slate-50 dark:bg-slate-800/50 rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-700 mt-4"> <div v-if="!isLoading && enrolledCourses.length === 0" 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-white/5 mt-4">
<q-icon v-if="searchQuery" name="search_off" size="64px" class="text-slate-300 dark:text-slate-600 mb-4" /> <q-icon v-if="searchQuery" 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"> <h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">
{{ searchQuery ? $t('discovery.emptyTitle') : $t('myCourses.emptyTitle') }} {{ searchQuery ? $t('discovery.emptyTitle') : $t('myCourses.emptyTitle') }}

View file

@ -216,7 +216,6 @@ onMounted(async () => {
@click="toggleEdit(false)" @click="toggleEdit(false)"
/> />
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
<span class="w-1.5 h-10 md:h-12 bg-blue-600 rounded-full shadow-lg shadow-blue-500/50 mt-1 flex-shrink-0"></span>
<div> <div>
<h1 class="text-3xl md:text-4xl font-black text-slate-900 dark:text-white leading-tight"> <h1 class="text-3xl md:text-4xl font-black text-slate-900 dark:text-white leading-tight">
{{ (isHydrated && isEditing) ? $t('profile.editProfile') : $t('profile.myProfile') }} {{ (isHydrated && isEditing) ? $t('profile.editProfile') : $t('profile.myProfile') }}
@ -250,7 +249,7 @@ onMounted(async () => {
<div v-else class="max-w-4xl mx-auto pb-20"> <div v-else class="max-w-4xl mx-auto pb-20">
<!-- VIEW MODE: Premium Card with Banner --> <!-- VIEW MODE: Premium Card with Banner -->
<div v-if="!isEditing" class="bg-white dark:bg-[#1e293b] border border-slate-200 dark:border-slate-700/50 rounded-3xl shadow-xl dark:shadow-none overflow-hidden fade-in min-h-[500px] flex flex-col transition-colors duration-300"> <div v-if="!isEditing" class="bg-white dark:!bg-slate-900/50 border border-slate-200 dark:border-white/5 rounded-3xl shadow-xl dark:shadow-none overflow-hidden fade-in min-h-[500px] flex flex-col transition-colors duration-300">
<!-- Identity Header (Banner & Avatar) --> <!-- Identity Header (Banner & Avatar) -->
<div class="relative"> <div class="relative">
@ -269,7 +268,7 @@ onMounted(async () => {
:first-name="userData.firstName" :first-name="userData.firstName"
:last-name="userData.lastName" :last-name="userData.lastName"
size="140" size="140"
class="border-[6px] border-white dark:border-[#1e293b] shadow-2xl rounded-[2.5rem] bg-white dark:bg-slate-800 transition-colors duration-300" class="border-[6px] border-white dark:border-slate-900 shadow-2xl rounded-[2.5rem] bg-white dark:bg-slate-800 transition-colors duration-300"
/> />
</div> </div>
@ -278,11 +277,11 @@ onMounted(async () => {
{{ userData.firstName }} {{ userData.lastName }} {{ userData.firstName }} {{ userData.lastName }}
</h2> </h2>
<div class="flex flex-wrap items-center justify-center md:justify-start gap-4"> <div class="flex flex-wrap items-center justify-center md:justify-start gap-4">
<div class="flex items-center gap-2 text-slate-500 dark:text-slate-400 font-bold bg-slate-50 dark:bg-slate-800/50 px-3 py-1.5 rounded-xl border border-slate-100 dark:border-slate-700"> <div class="flex items-center gap-2 text-slate-500 dark:text-slate-400 font-bold bg-slate-50 dark:bg-slate-900/50 px-3 py-1.5 rounded-xl border border-slate-100 dark:border-white/5">
<q-icon name="alternate_email" size="xs" class="text-blue-500" /> <q-icon name="alternate_email" size="xs" class="text-blue-500" />
<span class="text-sm">{{ userData.email }}</span> <span class="text-sm">{{ userData.email }}</span>
</div> </div>
<div class="flex items-center gap-2 text-slate-500 dark:text-slate-400 font-bold bg-slate-50 dark:bg-slate-800/50 px-3 py-1.5 rounded-xl border border-slate-100 dark:border-slate-700"> <div class="flex items-center gap-2 text-slate-500 dark:text-slate-400 font-bold bg-slate-50 dark:bg-slate-900/50 px-3 py-1.5 rounded-xl border border-slate-100 dark:border-white/5">
<q-icon name="verified_user" size="xs" :class="userData.emailVerifiedAt ? 'text-green-500' : 'text-amber-500'" /> <q-icon name="verified_user" size="xs" :class="userData.emailVerifiedAt ? 'text-green-500' : 'text-amber-500'" />
<span class="text-sm">{{ userData.emailVerifiedAt ? $t('profile.emailVerified') : $t('profile.verifyEmail') }}</span> <span class="text-sm">{{ userData.emailVerifiedAt ? $t('profile.emailVerified') : $t('profile.verifyEmail') }}</span>
</div> </div>
@ -325,7 +324,7 @@ onMounted(async () => {
<div v-else class="fade-in"> <div v-else class="fade-in">
<!-- Tab Selector --> <!-- Tab Selector -->
<div class="flex justify-center mb-8"> <div class="flex justify-center mb-8">
<div class="bg-white dark:bg-[#1e293b] p-1.5 rounded-2xl flex items-center gap-1 border border-slate-200 dark:border-slate-700 shadow-sm"> <div class="bg-white dark:!bg-slate-900/50 p-1.5 rounded-2xl flex items-center gap-1 border border-slate-200 dark:border-white/5 shadow-sm">
<button <button
@click="activeTab = 'general'" @click="activeTab = 'general'"
class="px-6 md:px-8 py-3 rounded-xl font-black text-xs uppercase tracking-widest transition-all flex items-center gap-2" class="px-6 md:px-8 py-3 rounded-xl font-black text-xs uppercase tracking-widest transition-all flex items-center gap-2"
@ -336,7 +335,7 @@ onMounted(async () => {
<button <button
@click="activeTab = 'security'" @click="activeTab = 'security'"
class="px-6 md:px-8 py-3 rounded-xl font-black text-xs uppercase tracking-widest transition-all flex items-center gap-2" class="px-6 md:px-8 py-3 rounded-xl font-black text-xs uppercase tracking-widest transition-all flex items-center gap-2"
:class="activeTab === 'security' ? 'bg-amber-50 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400 shadow-sm scale-100' : 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 scale-95 opacity-70'" :class="activeTab === 'security' ? 'bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 shadow-sm scale-100' : 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 scale-95 opacity-70'"
> >
<q-icon name="lock_open" size="18px" /> {{ $t('profile.security') }} <q-icon name="lock_open" size="18px" /> {{ $t('profile.security') }}
</button> </button>
@ -345,7 +344,7 @@ onMounted(async () => {
<!-- Edit Content --> <!-- Edit Content -->
<div class="max-w-3xl mx-auto"> <div class="max-w-3xl mx-auto">
<div v-if="activeTab === 'general'" class="bg-white dark:bg-[#1e293b] border border-slate-200 dark:border-slate-700/50 rounded-3xl shadow-xl dark:shadow-none p-6 md:p-10"> <div v-if="activeTab === 'general'" class="bg-white dark:!bg-slate-900/50 border border-slate-200 dark:border-white/5 rounded-3xl shadow-xl dark:shadow-none p-6 md:p-10">
<ProfileEditForm <ProfileEditForm
v-model="userData" v-model="userData"
:loading="isProfileSaving" :loading="isProfileSaving"
@ -355,7 +354,7 @@ onMounted(async () => {
@verify="handleSendVerifyEmail" @verify="handleSendVerifyEmail"
/> />
</div> </div>
<div v-else class="bg-white dark:bg-[#1e293b] border border-slate-200 dark:border-slate-700/50 rounded-3xl shadow-xl dark:shadow-none p-6 md:p-10"> <div v-else class="bg-white dark:!bg-slate-900/50 border border-slate-200 dark:border-white/5 rounded-3xl shadow-xl dark:shadow-none p-6 md:p-10">
<PasswordChangeForm <PasswordChangeForm
v-model="passwordForm" v-model="passwordForm"
:loading="isPasswordSaving" :loading="isPasswordSaving"

View file

@ -13,37 +13,14 @@ useHead({
title: 'E-Learning System - ระบบการเรียนการสอนออนไลน์' title: 'E-Learning System - ระบบการเรียนการสอนออนไลน์'
}) })
import { CATEGORY_CARDS, WHY_CHOOSE_US } from '@/constants/landing'
const { fetchCategories } = useCategory() const { fetchCategories } = useCategory()
const { fetchCourses, getLocalizedText } = useCourse() const { fetchCourses, getLocalizedText } = useCourse()
const { user } = useAuth() const { user } = useAuth()
const stepOneCards = [ const categoryCards = CATEGORY_CARDS
{ title: 'AI Foundations', desc: 'เข้าใจพื้นฐาน AI ใช้งานจริงได้ทุกสายงาน', bgClass: 'bg-slate-900', textClass: 'text-white', arrowClass: 'text-white/60 border-white/20 group-hover:text-slate-900', categorySlug: 'programming' }, const whyChooseUs = WHY_CHOOSE_US
{ title: 'Data Analyst', desc: 'เรียนจนทำ Dashboard วิเคราะห์ Data ได้เลย', bgClass: 'bg-amber-500', textClass: 'text-slate-900', arrowClass: 'text-slate-900/40 border-slate-900/10 group-hover:text-amber-500', categorySlug: 'business' },
{ title: 'Front-End Web Developer', desc: 'เขียนเว็บสวย ใช้งานได้จริงตั้งแต่หน้าแรก', bgClass: 'bg-orange-500', textClass: 'text-white', arrowClass: 'text-white/60 border-white/20 group-hover:text-orange-500', categorySlug: 'programming' },
{ title: 'UX/UI Designer', desc: 'ต่อยอดทำ Portfolio ไม่มีประสบการณ์ก็เรียนได้', bgClass: 'bg-pink-600', textClass: 'text-white', arrowClass: 'text-white/60 border-white/20 group-hover:text-pink-600', categorySlug: 'design' },
{ title: 'Product Manager', desc: 'เก็บทุกทักษะ ปั้น Product วางแผนแบบมือโปร', bgClass: 'bg-teal-500', textClass: 'text-white', arrowClass: 'text-white/60 border-white/20 group-hover:text-teal-500', categorySlug: 'business' },
{ title: 'Back-End Developer', desc: 'เข้าใจโครงสร้างระบบและฐานข้อมูลหลังบ้าน', bgClass: 'bg-blue-600', textClass: 'text-white', arrowClass: 'text-white/60 border-white/20 group-hover:text-blue-600', categorySlug: 'programming' },
{ title: 'Supply Chain & Logistics', desc: 'ใช้ Data วางแผนโลจิสติกส์ได้อย่างมีประสิทธิภาพ', bgClass: 'bg-slate-700', textClass: 'text-white', arrowClass: 'text-white/60 border-white/20 group-hover:text-slate-700', categorySlug: 'business' }
]
const learningStyles = [
{
title: 'คอร์สออนไลน์', icon: 'desktop_windows', type: 'ONLINE',
subtitle: 'เรียนได้ทุกที่ ทุกเวลา', desc: 'คัดสรรเนื้อหาคุณภาพจากผู้เชี่ยวชาญ\nพร้อมให้คุณเริ่มต้นเรียนรู้ได้ทันที',
time: 'เข้าถึงได้ตลอดชีพ',
features: ['เนื้อหาครบทุกประเด็นสำคัญ', 'โจทย์ตัวอย่างและแบบฝึกหัด', 'เรียนซ้ำได้ไม่จำกัด', 'ใบเซอร์ทิฟิเคตหลังเรียนจบ'],
iconBg: 'bg-blue-50', iconColor: 'text-blue-600', titleClass: 'text-blue-700',
btnClass: 'bg-indigo-900 text-white hover:bg-indigo-800'
}
]
const promoCategories = [
{ title: 'Data', desc: 'เรียนรู้และฝึกฝนกระบวนการคิดสร้างมูลค่าให้ธุรกิจด้วยข้อมูล', icon: 'analytics' },
{ title: 'Design', desc: 'ออกแบบ Digital Product เพื่อให้ผู้ใช้งานได้รับประสบการณ์ที่ดีที่สุด', icon: 'palette' },
{ title: 'Tech', desc: 'พร้อมเป็นที่ต้องการของตลาดแรงงานด้วยทักษะการเขียนโปรแกรม', icon: 'code' },
{ title: 'Business', desc: 'พลิกโฉมธุรกิจในยุคดิจิทัลด้วยการเข้าถึงลูกค้าในช่องทางและเวลาที่เหมาะสม', icon: 'trending_up' }
]
const categories = ref<any[]>([]) const categories = ref<any[]>([])
const topCourses = ref<any[]>([]) const topCourses = ref<any[]>([])
@ -173,133 +150,79 @@ onMounted(() => {
</div> </div>
</header> </header>
<section class="pt-16 pb-12 md:pt-24 md:pb-20 bg-white"> <!-- Why Choose Us Section -->
<section class="pt-20 pb-12 bg-white relative">
<div class="container mx-auto px-6 lg:px-12"> <div class="container mx-auto px-6 lg:px-12">
<div class="text-center mb-16 slide-up"> <div class="text-center mb-16 slide-up">
<h2 class="text-3xl md:text-5xl font-bold text-slate-900 mb-6 px-4"> <h2 class="text-3xl md:text-5xl font-black text-slate-900 mb-6">
เพราะ าวแรก ของการพฒนาตวเอง าทายเสมอ ทำไมตองเลอกแพลตฟอรมของเรา?
</h2> </h2>
<p class="text-slate-500 text-lg md:text-xl font-medium max-w-3xl mx-auto leading-relaxed"> <p class="text-slate-500 text-lg md:text-xl font-medium max-w-3xl mx-auto leading-relaxed">
เรางตงใจออกแบบบทเรยนให <span class="text-blue-600 font-bold">เขาใจงาย</span> และ <span class="text-blue-600 font-bold">นำไปใชไดจร</span> เพอใหกกาวของค นคงและไปถงเปาหมายไดสำเร เราเครองมอและความเชยวชาญทจะชวยใหณประสบความสำเรจในการเปลยนสายอาชพและการสรางทกษะระดบมออาช
</p> </p>
</div> </div>
<!-- Grid Container (Bento Layout) --> <div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<div class="relative"> <div v-for="(item, i) in whyChooseUs" :key="i"
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> class="slide-up p-10 rounded-[2.5rem] bg-slate-50/50 border border-slate-100 hover:bg-white hover:shadow-2xl hover:shadow-blue-600/5 transition-all duration-500 group"
<div v-for="(card, i) in stepOneCards" :key="i" :style="`animation-delay: ${i * 0.1}s`"
class="group cursor-pointer rounded-3xl p-6 flex flex-col justify-between transition-all hover:-translate-y-1 shadow-lg hover:shadow-2xl overflow-hidden relative"
:class="[
card.bgClass,
i === 0 ? 'lg:row-span-2 min-h-[380px]' : 'min-h-[220px]'
]"
@click="goBrowse(card.categorySlug)"
> >
<!-- Background Accent --> <div class="w-16 h-16 rounded-3xl flex items-center justify-center mb-8 transition-transform group-hover:scale-110 duration-500"
<div class="absolute top-0 right-0 w-32 h-32 bg-white/5 rounded-full -translate-y-1/2 translate-x-1/2" /> :class="item.iconBg"
>
<div> <q-icon :name="item.icon" size="32px" :class="item.iconColor" />
<span class="text-[10px] font-bold uppercase tracking-[0.15em] opacity-80 mb-3 block" :class="card.textClass === 'text-white' ? 'text-white/80' : 'text-slate-900/60'">าวแรกของ</span>
<h3 class="text-2xl font-bold leading-tight tracking-tight mb-2" :class="card.textClass">{{ card.title }}</h3>
</div> </div>
<h3 class="text-2xl font-black text-slate-900 mb-4 group-hover:text-blue-600 transition-colors">
<div class="space-y-4 relative z-10"> {{ item.title }}
<p class="text-sm font-medium leading-relaxed opacity-90" :class="card.textClass">{{ card.desc }}</p>
<div class="flex justify-end">
<div class="w-10 h-10 rounded-full border border-white/20 flex items-center justify-center transition-all bg-white/10 group-hover:bg-white/20 group-hover:scale-105 backdrop-blur-sm"
:class="[i === 0 ? 'w-12 h-12' : '']">
<q-icon name="arrow_forward" :size="i === 0 ? '24px' : '20px'" :class="card.textClass" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Section 2: "Value Proposition" - Why Online Learning here? -->
<section class="pt-12 pb-12 md:pt-20 md:pb-20 bg-white relative overflow-hidden">
<!-- Decorative background blur -->
<div class="absolute top-1/2 left-0 -translate-y-1/2 w-96 h-96 bg-blue-100/50 rounded-full blur-[100px] -z-10" />
<div class="container mx-auto px-6 lg:px-12">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-20 items-center">
<!-- Left side: Visual representation -->
<div class="relative slide-up">
<div class="relative z-10 bg-gradient-to-br from-blue-600 to-indigo-700 rounded-[4rem] p-12 md:p-20 shadow-3xl overflow-hidden group">
<!-- Animated background shapes -->
<div class="absolute top-0 right-0 w-64 h-64 bg-white/10 rounded-full -translate-y-1/2 translate-x-1/2 group-hover:scale-110 transition-transform duration-1000" />
<div class="absolute bottom-0 left-0 w-48 h-48 bg-amber-400/20 rounded-full translate-y-1/2 -translate-x-1/2" />
<div class="relative z-20 flex flex-col items-center text-center text-white">
<div class="w-24 h-24 md:w-32 md:h-32 bg-white/20 backdrop-blur-md rounded-[2.5rem] flex items-center justify-center mb-8 shadow-inner">
<q-icon name="laptop_mac" size="64px" class="text-white" />
</div>
<h3 class="text-3xl md:text-5xl font-black leading-[1.2] md:leading-[1.15] tracking-tight mb-0 pt-1 overflow-visible">
คอรสออนไลน<br class="hidden md:block" />
ออกแบบมาสำหรบค
</h3> </h3>
<p class="mt-5 text-blue-100/90 text-base md:text-lg font-medium leading-relaxed max-w-md"> <p class="text-slate-500 text-lg leading-relaxed font-medium">
เรยนรกษะใหมจากผเชยวชาญตวจร พรอมเนอหาทเขมขนและใชงานไดจร {{ item.desc }}
</p> </p>
</div> </div>
</div> </div>
<!-- Floating Stats pill -->
<div class="absolute -bottom-10 -right-6 md:right-10 z-30 bg-white p-6 rounded-3xl shadow-2xl animate-float">
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-2xl bg-amber-50 flex items-center justify-center text-amber-600">
<q-icon name="query_builder" size="28px" />
</div>
<div>
<div class="text-xs font-bold text-slate-400 uppercase tracking-tighter">Access Status</div>
<div class="text-xl font-black text-slate-900">เขาถงไดตลอดช</div>
</div>
</div>
</div>
</div>
<!-- Right side: Content & Benefits -->
<div class="slide-up" style="animation-delay: 0.2s;">
<div class="mb-12">
<span class="inline-flex items-center px-5 py-2 rounded-full bg-blue-50 text-blue-600 text-xs md:text-sm font-extrabold uppercase tracking-widest mb-5 border border-blue-100">
Premium Learning Experience
</span>
<h2 class="text-4xl md:text-6xl font-bold text-slate-900 leading-[1.2] md:leading-[1.2] tracking-tight mb-0 pt-1 overflow-visible">
าวขามทกขดจำก<br />
วยการเรยนร <span class="text-blue-600">สระ</span>
</h2>
<p class="mt-6 text-slate-500 text-lg md:text-xl font-medium leading-relaxed max-w-2xl">
เราคดสรรและคราฟตกคอรสเรยนเพอใหนใจวาคณจะไดบประสบการณการเรยนร ไมาจะอยไหนหรอเวลาใดกตาม
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
<div v-for="(feature, f) in learningStyles[0].features" :key="f"
class="flex items-center gap-4 p-5 rounded-3xl bg-slate-50 border border-slate-100 group hover:border-blue-200 hover:bg-white hover:shadow-xl transition-all duration-300"
>
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-white flex items-center justify-center text-green-500 shadow-sm group-hover:bg-green-500 group-hover:text-white transition-colors">
<q-icon name="check" size="20px" />
</div>
<span class="text-base font-black text-slate-700 leading-snug">{{ feature }}</span>
</div>
</div>
<q-btn
unelevated
rounded
color="primary"
label="เริ่มต้นบทเรียนแรกของคุณ"
class="px-12 h-20 font-black text-xl md:text-2xl transition-all shadow-xl hover:shadow-2xl hover:-translate-y-1"
no-caps
:to="user ? '/browse' : '/auth/login'"
/>
</div>
</div>
</div> </div>
</section> </section>
<section class="pt-16 pb-12 md:pt-24 md:pb-20 bg-white">
<div class="container mx-auto px-6 lg:px-12">
<div class="mb-12 slide-up">
<h2 class="text-3xl md:text-4xl font-black text-slate-900 px-4">
เลอกเรยนตามเรองทณสนใจ
</h2>
</div>
<!-- Horizontal Cards (New Layout - Image 2) -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 px-4">
<div v-for="(card, i) in categoryCards" :key="i"
class="group cursor-pointer bg-white rounded-[2rem] p-6 border border-slate-100/80 shadow-sm hover:shadow-2xl hover:shadow-blue-600/5 hover:-translate-y-1 transition-all duration-500 relative flex items-center gap-5"
@click="goBrowse(card.slug)"
>
<!-- Icon Box -->
<div class="flex-shrink-0 w-16 h-16 rounded-[1.5rem] flex items-center justify-center bg-blue-50/50 group-hover:scale-110 transition-transform duration-500"
>
<q-icon :name="card.icon" size="28px" class="text-blue-600" />
</div>
<!-- Content -->
<div class="flex-grow pr-2">
<h3 class="text-lg md:text-xl font-black text-slate-900 mb-1 group-hover:text-blue-600 transition-colors leading-tight">
{{ card.title }}
</h3>
<p class="text-slate-500 text-xs md:text-sm font-medium leading-relaxed opacity-70">
{{ card.desc }}
</p>
</div>
<!-- Arrow -->
<div class="flex-shrink-0 text-slate-300 group-hover:text-blue-600 transition-colors transform group-hover:translate-x-1 duration-300">
<q-icon name="chevron_right" size="24px" />
</div>
</div>
</div>
</div>
</section>
<!-- Section 4: "คอร์สออนไลน์" --> <!-- Section 4: "คอร์สออนไลน์" -->

View file

@ -6,7 +6,7 @@
*/ */
definePageMeta({ definePageMeta({
layout: 'default' layout: 'auth'
}) })
const route = useRoute() const route = useRoute()
@ -68,8 +68,8 @@ const navigateToHome = () => {
<!-- Success State --> <!-- Success State -->
<div v-else-if="isSuccess" class="flex flex-col items-center animate-bounce-in"> <div v-else-if="isSuccess" class="flex flex-col items-center animate-bounce-in">
<div class="w-24 h-24 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center mb-6"> <div class="w-24 h-24 rounded-full bg-green-500 flex items-center justify-center mb-10 shadow-lg shadow-green-500/20">
<q-icon name="check_circle" class="text-6xl text-green-500" /> <q-icon name="check" class="text-5xl text-white font-black" />
</div> </div>
<h2 class="text-3xl font-black text-slate-900 dark:text-white mb-2"> <h2 class="text-3xl font-black text-slate-900 dark:text-white mb-2">

View file

@ -0,0 +1,41 @@
/**
* @file auth.ts
* @description Type definitions for authentication and user profiles.
*/
export interface User {
id: number
username: string
email: string
email_verified_at?: string | null
created_at?: string
updated_at?: string
role: {
code: string // เช่น 'STUDENT', 'INSTRUCTOR', 'ADMIN'
name: { th: string; en: string }
}
profile?: {
prefix: { th: string; en: string }
first_name: string
last_name: string
phone: string | null
avatar_url: string | null
}
}
export interface LoginResponse {
token: string
refreshToken: string
user: User
profile: User['profile']
}
export interface RegisterPayload {
username: string
email: string
password: string
first_name: string
last_name: string
prefix: { th: string; en: string }
phone: string
}

View file

@ -0,0 +1,142 @@
/**
* @file course.ts
* @description Type definitions for courses, enrollments, quizzes, and certificates.
*/
export interface Course {
id: number
title: string | { th: string; en: string }
slug: string
description: string | { th: string; en: string }
thumbnail_url: string
price: string
is_free: boolean
original_price?: string
have_certificate: boolean
status: string
category_id: number
created_at?: string
updated_at?: string
created_by?: number
updated_by?: number
approved_at?: string
approved_by?: number
rejection_reason?: string
enrolled?: boolean
total_lessons?: number
rating?: string
lessons?: number | string
levelType?: 'neutral' | 'warning' | 'success'
chapters?: {
id: number
title: string | { th: string; en: string }
lessons: {
id: number
title: string | { th: string; en: string }
duration_minutes: number
video_url?: string
}[]
}[]
creator?: {
id: number
username: string
email: string
profile: {
first_name: string
last_name: string
avatar_url: string
}
}
instructors?: {
user_id: number
is_primary: boolean
user: {
id: number
username: string
email: string
profile: {
first_name: string
last_name: string
avatar_url: string
}
}
}[]
}
export interface CourseResponse {
code: number
message: string
data: Course[]
total: number
page?: number
limit?: number
totalPages?: number
}
export interface SingleCourseResponse {
code: number
message: string
data: Course
}
export interface EnrolledCourse {
id: number
course_id: number
course: Course
status: 'ENROLLED' | 'IN_PROGRESS' | 'COMPLETED' | 'DROPPED'
progress_percentage: number
enrolled_at: string
started_at?: string
completed_at?: string
last_accessed_at?: string
}
export interface EnrolledCourseResponse {
code: number
message: string
data: EnrolledCourse[]
total: number
page: number
limit: number
}
export interface QuizAnswerSubmission {
question_id: number
choice_id: number
}
export interface QuizSubmitRequest {
answers: QuizAnswerSubmission[]
}
export interface QuizResult {
answers_review: {
score: number
is_correct: boolean
correct_choice_id: number
selected_choice_id: number
question_id: number
}[]
completed_at: string
started_at: string
attempt_number: number
passing_score: number
is_passed: boolean
correct_answers: number
total_questions: number
total_score: number
score: number
quiz_id: number
attempt_id: number
}
export interface Certificate {
certificate_id: number
course_id: number
course_title: {
en: string
th: string
}
issued_at: string
download_url: string
}

View file

@ -0,0 +1,2 @@
export * from './auth'
export * from './course'