feat: Implement core authentication and course management logic with new discovery and profile pages.
This commit is contained in:
parent
1aa3190ca4
commit
2ffcc36fe4
12 changed files with 397 additions and 89 deletions
1
Frontend-Learner/.nuxt/imports.d.ts
vendored
1
Frontend-Learner/.nuxt/imports.d.ts
vendored
|
|
@ -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';
|
||||
|
|
@ -1 +1 @@
|
|||
{"id":"dev","timestamp":1768449730995}
|
||||
{"id":"dev","timestamp":1768531253711}
|
||||
|
|
@ -1 +1 @@
|
|||
{"id":"dev","timestamp":1768449730995,"matcher":{"static":{},"wildcard":{},"dynamic":{}},"prerendered":[]}
|
||||
{"id":"dev","timestamp":1768531253711,"matcher":{"static":{},"wildcard":{},"dynamic":{}},"prerendered":[]}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
4
Frontend-Learner/.nuxt/nuxt.d.ts
vendored
4
Frontend-Learner/.nuxt/nuxt.d.ts
vendored
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
5
Frontend-Learner/.nuxt/types/imports.d.ts
vendored
5
Frontend-Learner/.nuxt/types/imports.d.ts
vendored
|
|
@ -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']>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
99
Frontend-Learner/composables/useCourse.ts
Normal file
99
Frontend-Learner/composables/useCourse.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue