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

@ -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
// Successful save
alert('บันทึกข้อมูลเรียบร้อยแล้ว')
isEditing.value = false
const prefixMap: Record<string, string> = {
'นาย': 'Mr.',
'นาง': 'Mrs.',
'นางสาว': 'Ms.'
}
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>