feat: Implement core authentication and course management logic with new discovery and profile pages.

This commit is contained in:
supalerk-ar66 2026-01-16 10:03:04 +07:00
parent 1aa3190ca4
commit 2ffcc36fe4
12 changed files with 397 additions and 89 deletions

View file

@ -31,6 +31,7 @@ export { setInterval } from '#app/compat/interval';
export { definePageMeta } from '../node_modules/nuxt/dist/pages/runtime/composables';
export { defineLazyHydrationComponent } from '#app/composables/lazy-hydration';
export { useAuth } from '../composables/useAuth';
export { useCourse, Course } from '../composables/useCourse';
export { useFormValidation, ValidationRule, FieldErrors } from '../composables/useFormValidation';
export { useDialogPluginComponent, useFormChild, useInterval, useMeta, useQuasar, useRenderCache, useSplitAttrs, useTick, useTimeout, Notify } from 'quasar';
export { useNuxtDevTools } from '../node_modules/@nuxt/devtools/dist/runtime/use-nuxt-devtools';

View file

@ -1 +1 @@
{"id":"dev","timestamp":1768449730995}
{"id":"dev","timestamp":1768531253711}

View file

@ -1 +1 @@
{"id":"dev","timestamp":1768449730995,"matcher":{"static":{},"wildcard":{},"dynamic":{}},"prerendered":[]}
{"id":"dev","timestamp":1768531253711,"matcher":{"static":{},"wildcard":{},"dynamic":{}},"prerendered":[]}

View file

@ -1,5 +1,5 @@
{
"date": "2026-01-15T04:02:21.792Z",
"date": "2026-01-16T02:41:00.186Z",
"preset": "nitro-dev",
"framework": {
"name": "nuxt",
@ -9,9 +9,9 @@
"nitro": "2.12.8"
},
"dev": {
"pid": 1972,
"pid": 15604,
"workerAddress": {
"socketPath": "\\\\.\\pipe\\nitro-worker-1972-1-1-5250.sock"
"socketPath": "\\\\.\\pipe\\nitro-worker-15604-1-1-4154.sock"
}
}
}

View file

@ -1,8 +1,8 @@
/// <reference types="quasar" />
/// <reference types="@nuxtjs/tailwindcss" />
/// <reference types="nuxt-quasar-ui" />
/// <reference types="@nuxt/devtools" />
/// <reference types="nuxt-quasar-ui" />
/// <reference types="@nuxt/telemetry" />
/// <reference types="@nuxtjs/tailwindcss" />
/// <reference path="types/builder-env.d.ts" />
/// <reference types="nuxt" />
/// <reference path="types/app-defaults.d.ts" />

View file

@ -1,4 +1,4 @@
// generated by the @nuxtjs/tailwindcss <https://github.com/nuxt-modules/tailwindcss> module at 15/1/2569 11:25:01
// generated by the @nuxtjs/tailwindcss <https://github.com/nuxt-modules/tailwindcss> module at 16/1/2569 09:58:37
import "@nuxtjs/tailwindcss/config-ctx"
import configMerger from "@nuxtjs/tailwindcss/merger";

View file

@ -100,6 +100,7 @@ declare global {
const useAttrs: typeof import('../../node_modules/vue').useAttrs
const useAuth: typeof import('../../composables/useAuth').useAuth
const useCookie: typeof import('../../node_modules/nuxt/dist/app/composables/cookie').useCookie
const useCourse: typeof import('../../composables/useCourse').useCourse
const useCssModule: typeof import('../../node_modules/vue').useCssModule
const useCssVars: typeof import('../../node_modules/vue').useCssVars
const useDialogPluginComponent: typeof import('quasar').useDialogPluginComponent
@ -194,6 +195,9 @@ declare global {
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from '../../node_modules/vue'
import('../../node_modules/vue')
// @ts-ignore
export type { Course } from '../../composables/useCourse'
import('../../composables/useCourse')
// @ts-ignore
export type { ValidationRule, FieldErrors } from '../../composables/useFormValidation'
import('../../composables/useFormValidation')
}
@ -300,6 +304,7 @@ declare module 'vue' {
readonly useAttrs: UnwrapRef<typeof import('../../node_modules/vue')['useAttrs']>
readonly useAuth: UnwrapRef<typeof import('../../composables/useAuth')['useAuth']>
readonly useCookie: UnwrapRef<typeof import('../../node_modules/nuxt/dist/app/composables/cookie')['useCookie']>
readonly useCourse: UnwrapRef<typeof import('../../composables/useCourse')['useCourse']>
readonly useCssModule: UnwrapRef<typeof import('../../node_modules/vue')['useCssModule']>
readonly useCssVars: UnwrapRef<typeof import('../../node_modules/vue')['useCssVars']>
readonly useDialogPluginComponent: UnwrapRef<typeof import('quasar')['useDialogPluginComponent']>

View file

@ -8,6 +8,8 @@ interface User {
id: number
username: string
email: string
created_at?: string
updated_at?: string
role: {
code: string
name: { th: string; en: string }
@ -167,6 +169,36 @@ export const useAuth = () => {
}
}
// Update User Profile
const updateUserProfile = async (payload: {
first_name: string
last_name: string
phone: string
prefix: { th: string; en: string }
}) => {
if (!token.value) return
try {
const { error } = await useFetch(`${API_BASE_URL}/user/me`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${token.value}`
},
body: payload
})
if (error.value) throw error.value
// If successful, refresh the local user data
await fetchUserProfile()
return { success: true }
} catch (err: any) {
console.error('Failed to update profile:', err)
return { success: false, error: err.data?.message || err.message || 'บันทึกข้อมูลไม่สำเร็จ' }
}
}
// Request Password Reset
const requestPasswordReset = async (email: string) => {
try {
@ -197,6 +229,26 @@ export const useAuth = () => {
}
}
// Change Password
const changePassword = async (payload: { oldPassword: string, newPassword: string }) => {
if (!token.value) return { success: false, error: 'ไม่พบ Token การใช้งาน' }
try {
const { data, error } = await useFetch(`${API_BASE_URL}/user/change-password`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token.value}`
},
body: payload
})
if (error.value) throw error.value
return { success: true }
} catch (err: any) {
return { success: false, error: err.data?.message || 'เปลี่ยนรหัสผ่านไม่สำเร็จ' }
}
}
// Refresh Access Token
const refreshAccessToken = async () => {
if (!refreshToken.value) return false
@ -243,20 +295,23 @@ export const useAuth = () => {
const lastName = user.value.profile?.last_name || ''
return {
prefix,
prefix: user.value.profile?.prefix || { th: '', en: '' },
firstName,
lastName,
email: user.value.email,
phone: user.value.profile?.phone || '',
photoURL: user.value.profile?.avatar_url || '',
role: user.value.role
role: user.value.role,
createdAt: user.value.created_at || new Date().toISOString()
}
}),
login,
register,
fetchUserProfile,
updateUserProfile,
requestPasswordReset,
confirmResetPassword,
changePassword,
refreshAccessToken,
logout
}

View file

@ -0,0 +1,99 @@
import type { H3Event } from 'h3'
// Types based on API responses
export interface Course {
id: number
title: string
slug: string
description: string
thumbnail_url: string
price: string
is_free: boolean
have_certificate: boolean
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
// Helper properties for UI (may be computed or mapped)
rating?: string
lessons?: number | string
levelType?: 'neutral' | 'warning' | 'success' // For UI badging
}
interface CourseResponse {
code: number
message: string
data: Course[]
total: number
}
export const useCourse = () => {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBase as string
const { token } = useAuth()
const fetchCourses = async () => {
try {
const { data, error } = await useFetch<CourseResponse>(`${API_BASE_URL}/courses`, {
method: 'GET',
headers: token.value ? {
Authorization: `Bearer ${token.value}`
} : {}
})
if (error.value) throw error.value
return {
success: true,
data: data.value?.data || [],
total: data.value?.total || 0
}
} catch (err: any) {
console.error('Fetch courses failed:', err)
return {
success: false,
error: err.data?.message || err.message || 'Error fetching courses'
}
}
}
const fetchCourseById = async (id: number) => {
try {
const { data, error } = await useFetch<CourseResponse>(`${API_BASE_URL}/courses/${id}`, {
method: 'GET',
headers: token.value ? {
Authorization: `Bearer ${token.value}`
} : {}
})
if (error.value) throw error.value
// API returns data array even for single item based on schema
const courseData = data.value?.data?.[0]
if (!courseData) throw new Error('Course not found')
return {
success: true,
data: courseData
}
} catch (err: any) {
console.error('Fetch course details failed:', err)
return {
success: false,
error: err.data?.message || err.message || 'Error fetching course details'
}
}
}
return {
fetchCourses,
fetchCourseById
}
}

View file

@ -20,36 +20,42 @@ const showDetail = ref(false);
const searchQuery = ref("");
const isCategoryOpen = ref(true);
// Mock Course Data
const courses = [
{
id: 1,
title: "เบื้องต้นการออกแบบ UX/UI",
levelType: "neutral" as const,
price: "ฟรี",
description: "เรียนรู้พื้นฐานการวาดโครงร่าง...",
rating: "4.8",
lessons: "12",
},
{
id: 2,
title: "รูปแบบ React ขั้นสูง",
levelType: "warning" as const,
price: "ฟรี",
description: "เจาะลึก HOC, Hooks และอื่นๆ...",
rating: "4.9",
lessons: "24",
},
{
id: 3,
title: "การตลาดดิจิทัล 101",
levelType: "success" as const,
price: "ฟรี",
description: "คู่มือสมบูรณ์ SEO/SEM...",
rating: "4.7",
lessons: "18",
},
];
// Courses Data
const { fetchCourses, fetchCourseById } = useCourse();
const courses = ref<any[]>([]);
const isLoading = ref(false);
const selectedCourse = ref<any>(null);
const isLoadingDetail = ref(false);
const loadCourses = async () => {
isLoading.value = true;
const res = await fetchCourses();
if (res.success) {
courses.value = (res.data || []).map((c: any) => ({
...c,
rating: "0.0",
lessons: "0",
levelType: c.levelType || "neutral"
}));
}
isLoading.value = false;
};
const selectCourse = async (id: number) => {
isLoadingDetail.value = true;
selectedCourse.value = null;
showDetail.value = true;
const res = await fetchCourseById(id);
if (res.success) {
selectedCourse.value = res.data;
}
isLoadingDetail.value = false;
};
onMounted(() => {
loadCourses();
});
// Categories Data
const categories = [
@ -79,12 +85,12 @@ const visibleCategories = computed(() => {
// Filter Logic based on search query
const filteredCourses = computed(() => {
if (!searchQuery.value) return courses;
if (!searchQuery.value) return courses.value;
const query = searchQuery.value.toLowerCase();
return courses.filter(
return courses.value.filter(
(c) =>
c.title.toLowerCase().includes(query) ||
c.description.toLowerCase().includes(query)
c.description?.toLowerCase().includes(query)
);
});
</script>
@ -206,8 +212,9 @@ const filteredCourses = computed(() => {
:description="course.description"
:rating="course.rating"
:lessons="course.lessons"
:image="course.thumbnail_url"
show-view-details
@view-details="showDetail = true"
@view-details="selectCourse(course.id)"
/>
</div>
@ -245,11 +252,20 @@ const filteredCourses = computed(() => {
<!-- COURSE DETAIL VIEW: Detailed information about a specific course -->
<div v-else>
<NuxtLink to="/browse" class="btn btn-secondary mb-6 inline-block">
กลบหนารายการคอร
</NuxtLink>
<button
@click="showDetail = false"
class="btn btn-secondary mb-6 inline-flex items-center gap-2"
>
<span></span> กลบหนารายการคอร
</button>
<div class="grid-12">
<div v-if="isLoadingDetail" class="flex justify-center py-20">
<div class="spinner-border animate-spin inline-block w-8 h-8 border-4 rounded-full" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div v-else-if="selectedCourse" class="grid-12">
<!-- Main Content (Left Column) -->
<div class="col-span-8">
<!-- Hero Video Placeholder -->
@ -265,10 +281,17 @@ const filteredCourses = computed(() => {
margin-bottom: 24px;
position: relative;
border: 1px solid var(--border-color);
overflow: hidden;
"
>
<img
v-if="selectedCourse.thumbnail_url"
:src="selectedCourse.thumbnail_url"
class="absolute inset-0 w-full h-full object-cover opacity-50"
/>
<!-- Play Button -->
<div
class="relative z-10"
style="
width: 80px;
height: 80px;
@ -295,15 +318,13 @@ const filteredCourses = computed(() => {
</div>
<h1 class="text-[32px] font-bold mb-4 text-slate-900 dark:text-white">
เบองตนการออกแบบ UX/UI
{{ selectedCourse.title }}
</h1>
<p
class="text-slate-700 dark:text-slate-400 mb-6"
style="font-size: 1.1em; line-height: 1.7"
>
เนอหาครอบคลมทกอยางตงแตการวยผใช (User Research)
ไปจนถงการทำตนแบบความละเอยดส (High-fidelity Prototyping)
เหมาะสำหรบผเรมตนทองการเขาสสายงานออกแบบผลตภณฑ
{{ selectedCourse.description }}
</p>
<!-- Learning Objectives -->
@ -391,12 +412,12 @@ const filteredCourses = computed(() => {
class="text-primary font-bold"
style="font-size: 32px; margin: 0"
>
ฟร
{{ selectedCourse.price || 'ฟรี' }}
</h2>
</div>
<NuxtLink
to="/dashboard/my-courses?enrolled=true"
:to="`/dashboard/my-courses?enroll=${selectedCourse.id}`"
class="btn btn-primary w-full mb-4 text-white"
style="height: 48px; font-size: 16px"
>

View file

@ -19,15 +19,29 @@ const { currentUser } = useAuth()
const { errors, validate, clearFieldError } = useFormValidation()
const isEditing = ref(false)
const formatDate = (dateString?: string) => {
if (!dateString) return '-'
try {
const date = new Date(dateString)
return new Intl.DateTimeFormat('th-TH', {
day: 'numeric',
month: 'short',
year: 'numeric'
}).format(date)
} catch (e) {
return dateString
}
}
// User Profile Data Management
const userData = ref({
firstName: currentUser.value?.firstName || '',
lastName: currentUser.value?.lastName || '',
email: currentUser.value?.email || '',
phone: '0812345678',
joinDate: '12 ธ.ค. 2024',
photoURL: '',
prefix: 'นาย'
phone: currentUser.value?.phone || '',
createdAt: formatDate(currentUser.value?.createdAt),
photoURL: currentUser.value?.photoURL || '',
prefix: currentUser.value?.prefix?.th || ''
})
// Password Form (Separate from userData for security/logic)
@ -47,6 +61,10 @@ const validationRules = {
confirmPassword: { rules: { match: 'newPassword' }, label: 'ยืนยันรหัสผ่าน' }
}
const showCurrentPassword = ref(false)
const showNewPassword = ref(false)
const showConfirmPassword = ref(false)
const fileInput = ref<HTMLInputElement | null>(null)
const toggleEdit = (edit: boolean) => {
@ -73,23 +91,71 @@ const handleFileUpload = (event: Event) => {
}
}
// Save Profile Updates (Mock Implementation)
const saveProfile = () => {
// Save Profile Updates
const saveProfile = async () => {
// Combine data for validation
const formData = {
...userData.value,
...passwordForm
}
if (!validate(formData, validationRules)) return
// TODO: Add password validation if changing password is implemented via this API or handle separately
// For now focused on profile fields
currentUser.value.firstName = userData.value.firstName
currentUser.value.lastName = userData.value.lastName
currentUser.value.email = userData.value.email
const prefixMap: Record<string, string> = {
'นาย': 'Mr.',
'นาง': 'Mrs.',
'นางสาว': 'Ms.'
}
// Successful save
alert('บันทึกข้อมูลเรียบร้อยแล้ว')
isEditing.value = false
if (currentUser.value) {
const { updateUserProfile, changePassword } = useAuth()
const payload = {
first_name: userData.value.firstName,
last_name: userData.value.lastName,
// email: userData.value.email, // Email should not be updated via this endpoint as it causes backend 500 error (not in UserProfile schema)
phone: userData.value.phone,
prefix: {
th: userData.value.prefix,
en: prefixMap[userData.value.prefix] || ''
}
}
// 1. Update Profile
const profileResult = await updateUserProfile(payload)
if (!profileResult?.success) {
alert(profileResult?.error || 'เกิดข้อผิดพลาดในการบันทึกข้อมูลส่วนตัว')
return
}
// 2. Change Password (if filled)
if (passwordForm.currentPassword && passwordForm.newPassword) {
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
alert('รหัสผ่านใหม่ไม่ตรงกัน')
return
}
const passwordResult = await changePassword({
oldPassword: passwordForm.currentPassword,
newPassword: passwordForm.newPassword
})
if (!passwordResult.success) {
alert(passwordResult.error || 'เปลี่ยนรหัสผ่านไม่สำเร็จ')
// Don't return here, maybe we still want to show profile success?
// But usually we stop. Let's alert only.
} else {
// Clear password form on success
passwordForm.currentPassword = ''
passwordForm.newPassword = ''
passwordForm.confirmPassword = ''
}
}
alert('บันทึกข้อมูลเรียบร้อยแล้ว')
isEditing.value = false
}
}
</script>
@ -143,7 +209,7 @@ const saveProfile = () => {
</div>
<div class="info-group">
<span class="label">สมครสมาชกเม</span>
<p class="value">{{ userData.joinDate }}</p>
<p class="value">{{ userData.createdAt }}</p>
</div>
</div>
</div>
@ -250,28 +316,81 @@ const saveProfile = () => {
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="space-y-2">
<label class="text-xs font-black uppercase tracking-widest text-slate-500 dark:text-slate-300">รหสผานปจจ</label>
<input type="password" class="premium-input w-full" placeholder="••••••••">
<div class="relative">
<input
v-model="passwordForm.currentPassword"
:type="showCurrentPassword ? 'text' : 'password'"
class="premium-input w-full pr-12"
placeholder="••••••••"
>
<button
type="button"
@click="showCurrentPassword = !showCurrentPassword"
class="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
tabindex="-1"
>
<svg v-if="!showCurrentPassword" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
</button>
</div>
</div>
<div class="space-y-2">
<label class="text-xs font-black uppercase tracking-widest text-slate-500 dark:text-slate-300">รหสผานใหม</label>
<input
v-model="passwordForm.newPassword"
type="password"
class="premium-input w-full"
:class="{ '!border-red-500': errors.newPassword }"
@input="clearFieldError('newPassword')"
>
<div class="relative">
<input
v-model="passwordForm.newPassword"
:type="showNewPassword ? 'text' : 'password'"
class="premium-input w-full pr-12"
:class="{ '!border-red-500': errors.newPassword }"
@input="clearFieldError('newPassword')"
>
<button
type="button"
@click="showNewPassword = !showNewPassword"
class="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
tabindex="-1"
>
<svg v-if="!showNewPassword" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
</button>
</div>
<span v-if="errors.newPassword" class="text-red-500 text-[10px] mt-1 font-bold">{{ errors.newPassword }}</span>
</div>
<div class="space-y-2">
<label class="text-xs font-black uppercase tracking-widest text-slate-500 dark:text-slate-300">นยนรหสผานใหม</label>
<input
v-model="passwordForm.confirmPassword"
type="password"
class="premium-input w-full"
:class="{ '!border-red-500': errors.confirmPassword }"
@input="clearFieldError('confirmPassword')"
>
<div class="relative">
<input
v-model="passwordForm.confirmPassword"
:type="showConfirmPassword ? 'text' : 'password'"
class="premium-input w-full pr-12"
:class="{ '!border-red-500': errors.confirmPassword }"
@input="clearFieldError('confirmPassword')"
>
<button
type="button"
@click="showConfirmPassword = !showConfirmPassword"
class="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
tabindex="-1"
>
<svg v-if="!showConfirmPassword" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
</button>
</div>
<span v-if="errors.confirmPassword" class="text-red-500 text-[10px] mt-1 font-bold">{{ errors.confirmPassword }}</span>
</div>
</div>

View file

@ -64,11 +64,12 @@
## 🔐 ระบบรักษาความปลอดภัยและ Logic พิเศษ (Core Logic)
| ส่วนงาน | ไฟล์ | คำอธิบาย |
| :-------------- | :----------------------------------- | :-------------------------------------------------------------- |
| **Route Guard** | `middleware/auth.ts` | Middleware ดักจับการเข้าถึง เช็กสถานะการล็อกอิน |
| **Auth State** | `composables/useAuth.ts` | Centralized Logic สำหรับ Login/Logout เก็บ state ผู้ใช้ปัจจุบัน |
| **No-Cache** | `server/middleware/cache-control.ts` | ป้องกันการ Cache หน้าสำคัญด้วย Headers |
| ส่วนงาน | ไฟล์ | คำอธิบาย |
| :--------------- | :----------------------------------- | :-------------------------------------------------------------- |
| **Route Guard** | `middleware/auth.ts` | Middleware ดักจับการเข้าถึง เช็กสถานะการล็อกอิน |
| **Auth State** | `composables/useAuth.ts` | Centralized Logic สำหรับ Login/Logout เก็บ state ผู้ใช้ปัจจุบัน |
| **Course Logic** | `composables/useCourse.ts` | Centralized Logic สำหรับดึงข้อมูลคอร์สทั้งหมดและรายละเอียดคอร์ส |
| **No-Cache** | `server/middleware/cache-control.ts` | ป้องกันการ Cache หน้าสำคัญด้วย Headers |
---
@ -101,3 +102,10 @@
- **New Layout:** ปรับปรุงหน้า Dashboard ให้แสดง "คอร์สที่เรียนล่าสุด" และ "คอร์สแนะนำ" อย่างชัดเจน
- **Progress Tracking:** แสดง Progress Bar ที่สวยงามสำหรับการเรียนปัจจุบัน
### 4. **API Integration (Course System)**
- **Real Data Integration:** เชื่อมต่อหน้า `browse/discovery` เข้ากับ Backend API (`/api/courses`) สำเร็จ
- **Dynamic Detail View:** เพิ่มระบบดึงข้อมูลรายคอร์ส (`/api/courses/{id}`) เมื่อคลิก "ดูรายละเอียด"
- **New Composable:** สร้าง `useCourse.ts` เพื่อจัดการ Logic การดึงข้อมูลคอร์สแยกออกมาให้เป็นระเบียบ
- **Updated Course Card:** รองรับการแสดงผลรูปภาพปกคอร์ส (`thumbnail_url`) และราคาจริงจาก API