Initial commit: public frontend for GameNo.
Vue 3 + Vite student-facing app with routing, Pinia state, and API integration.
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE_URL=http://localhost:5000/api
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Vue 3 + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fa" dir="rtl">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="گام نو — آموزش تخصصی، کلاسهای حضوری و گواهینامه معتبر" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Amiri:wght@400;700&display=swap" rel="stylesheet" />
|
||||||
|
<title>گام نو</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2560
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "gameno-front",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource/vazirmatn": "^5.3.0",
|
||||||
|
"axios": "^1.19.0",
|
||||||
|
"jalali-moment": "^3.3.11",
|
||||||
|
"js-cookie": "^3.0.8",
|
||||||
|
"pinia": "^4.0.2",
|
||||||
|
"vue": "^3.5.40",
|
||||||
|
"vue-router": "^5.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.8",
|
||||||
|
"vite": "^8.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||||
|
<rect width="64" height="64" rx="14" fill="#14352B"/>
|
||||||
|
<path d="M14 40c6-10 12-16 18-18 6 2 12 8 18 18" stroke="#E8A317" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<circle cx="20" cy="42" r="3.5" fill="#DCE8DF"/>
|
||||||
|
<circle cx="32" cy="34" r="3.5" fill="#DCE8DF"/>
|
||||||
|
<circle cx="44" cy="42" r="3.5" fill="#DCE8DF"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 399 B |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<RouterView />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { RouterView } from 'vue-router'
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import axiosInstance from './axiosInstance'
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
login: (data) => axiosInstance.post('/auth/login', data),
|
||||||
|
logout: (refreshToken) => axiosInstance.post('/auth/logout', { refreshToken }),
|
||||||
|
getSelf: () => axiosInstance.get('/users/user/get-self')
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import Cookies from 'js-cookie'
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api'
|
||||||
|
|
||||||
|
const axiosInstance = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json'
|
||||||
|
},
|
||||||
|
timeout: 15000
|
||||||
|
})
|
||||||
|
|
||||||
|
axiosInstance.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('frontAccessToken') || Cookies.get('frontAccessToken')
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
config.headers['Accept-Language'] = 'fa'
|
||||||
|
if (!config.params) config.params = {}
|
||||||
|
if (config.params.lang == null) config.params.lang = 'fa'
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
let isRefreshing = false
|
||||||
|
let failedQueue = []
|
||||||
|
|
||||||
|
const processQueue = (error, token = null) => {
|
||||||
|
failedQueue.forEach((prom) => {
|
||||||
|
if (error) prom.reject(error)
|
||||||
|
else prom.resolve(token)
|
||||||
|
})
|
||||||
|
failedQueue = []
|
||||||
|
}
|
||||||
|
|
||||||
|
axiosInstance.interceptors.response.use(
|
||||||
|
(response) => response.data,
|
||||||
|
async (error) => {
|
||||||
|
const originalRequest = error.config
|
||||||
|
if (!originalRequest || error.response?.status !== 401 || originalRequest._retry) {
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originalRequest.url?.includes('/auth/login') || originalRequest.url?.includes('/auth/refresh')) {
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRefreshing) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
failedQueue.push({ resolve, reject })
|
||||||
|
}).then((token) => {
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${token}`
|
||||||
|
return axiosInstance(originalRequest)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
originalRequest._retry = true
|
||||||
|
isRefreshing = true
|
||||||
|
|
||||||
|
const refreshToken = localStorage.getItem('frontRefreshToken') || Cookies.get('frontRefreshToken')
|
||||||
|
if (!refreshToken) {
|
||||||
|
isRefreshing = false
|
||||||
|
clearFrontAuth()
|
||||||
|
window.location.href = '/login'
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const refreshResponse = await axios.post(`${API_BASE_URL}/auth/refresh`, { refreshToken })
|
||||||
|
const data = refreshResponse.data?.data || refreshResponse.data
|
||||||
|
const newAccessToken = data.accessToken
|
||||||
|
const newRefreshToken = data.refreshToken
|
||||||
|
|
||||||
|
localStorage.setItem('frontAccessToken', newAccessToken)
|
||||||
|
Cookies.set('frontAccessToken', newAccessToken)
|
||||||
|
if (newRefreshToken) {
|
||||||
|
localStorage.setItem('frontRefreshToken', newRefreshToken)
|
||||||
|
Cookies.set('frontRefreshToken', newRefreshToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`
|
||||||
|
processQueue(null, newAccessToken)
|
||||||
|
return axiosInstance(originalRequest)
|
||||||
|
} catch (refreshErr) {
|
||||||
|
processQueue(refreshErr, null)
|
||||||
|
clearFrontAuth()
|
||||||
|
window.location.href = '/login'
|
||||||
|
return Promise.reject(refreshErr)
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function clearFrontAuth() {
|
||||||
|
localStorage.removeItem('frontAccessToken')
|
||||||
|
localStorage.removeItem('frontRefreshToken')
|
||||||
|
localStorage.removeItem('frontUser')
|
||||||
|
Cookies.remove('frontAccessToken')
|
||||||
|
Cookies.remove('frontRefreshToken')
|
||||||
|
}
|
||||||
|
|
||||||
|
export default axiosInstance
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import axiosInstance from './axiosInstance'
|
||||||
|
|
||||||
|
export const certificatesApi = {
|
||||||
|
getMyCertificates: (params = {}) =>
|
||||||
|
axiosInstance.get('/certificates/user/my-certificates', { params })
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import axiosInstance from './axiosInstance'
|
||||||
|
|
||||||
|
export const classesApi = {
|
||||||
|
getMyClasses: (params = {}) => axiosInstance.get('/classes/user/my-classes', { params })
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import axiosInstance from './axiosInstance'
|
||||||
|
|
||||||
|
export const contactApi = {
|
||||||
|
submit: (payload) => axiosInstance.post('/contact-inquiries/public/create', payload)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import axiosInstance from './axiosInstance'
|
||||||
|
|
||||||
|
export const coursesApi = {
|
||||||
|
getAll: (params = {}) => axiosInstance.get('/courses/user/get-all', { params })
|
||||||
|
}
|
||||||
@@ -0,0 +1,530 @@
|
|||||||
|
<template>
|
||||||
|
<section id="contact" class="section contact-section" aria-labelledby="contact-heading">
|
||||||
|
<div class="container contact-shell">
|
||||||
|
<header class="section-head">
|
||||||
|
<h2 id="contact-heading">درخواست مشاوره و ثبت علاقهمندی</h2>
|
||||||
|
<p>
|
||||||
|
دورههای مورد علاقهتان را انتخاب کنید، اطلاعات تماس را بنویسید و بگویید از کدام کانال با شما
|
||||||
|
در ارتباط باشیم.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form class="contact-form" novalidate @submit.prevent="handleSubmit">
|
||||||
|
<div v-if="successMessage" class="alert alert-success" role="status">
|
||||||
|
{{ successMessage }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="formError" class="alert" role="alert">{{ formError }}</div>
|
||||||
|
|
||||||
|
<fieldset class="form-block">
|
||||||
|
<legend>دورههای مورد علاقه</legend>
|
||||||
|
<div v-if="coursesLoading" class="courses-loading">
|
||||||
|
<div class="spinner" aria-label="در حال بارگذاری دورهها"></div>
|
||||||
|
</div>
|
||||||
|
<p v-else-if="!courses.length" class="hint">فعلاً دورهای برای انتخاب موجود نیست.</p>
|
||||||
|
<div v-else class="course-checks" role="group" aria-label="انتخاب دوره">
|
||||||
|
<label
|
||||||
|
v-for="course in courses"
|
||||||
|
:key="course._id"
|
||||||
|
class="check-card"
|
||||||
|
:class="{ selected: selectedCourses.includes(course._id) }"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="selectedCourses"
|
||||||
|
type="checkbox"
|
||||||
|
:value="course._id"
|
||||||
|
/>
|
||||||
|
<span class="check-card-body">
|
||||||
|
<strong>{{ course.title }}</strong>
|
||||||
|
<span class="check-card-meta">
|
||||||
|
{{ course.type === 'Private' ? 'خصوصی' : 'عمومی' }}
|
||||||
|
<template v-if="course.sectionCount"> · {{ toPersianDigits(course.sectionCount) }} جلسه</template>
|
||||||
|
<template v-if="course.hoursPerSection"> · {{ toPersianDigits(course.hoursPerSection) }} ساعت</template>
|
||||||
|
<template v-if="course.price != null"> · {{ formatPrice(course.price) }}</template>
|
||||||
|
</span>
|
||||||
|
<span v-if="course.description" class="check-card-desc">{{ course.description }}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<span v-if="errors.courses" class="field-error">{{ errors.courses }}</span>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="form-block">
|
||||||
|
<legend>اطلاعات شما</legend>
|
||||||
|
<div class="fields-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label for="contact-name">نام</label>
|
||||||
|
<input
|
||||||
|
id="contact-name"
|
||||||
|
v-model.trim="form.name"
|
||||||
|
type="text"
|
||||||
|
autocomplete="given-name"
|
||||||
|
placeholder="نام"
|
||||||
|
/>
|
||||||
|
<span v-if="errors.name" class="field-error">{{ errors.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="contact-surname">نام خانوادگی</label>
|
||||||
|
<input
|
||||||
|
id="contact-surname"
|
||||||
|
v-model.trim="form.surname"
|
||||||
|
type="text"
|
||||||
|
autocomplete="family-name"
|
||||||
|
placeholder="نام خانوادگی"
|
||||||
|
/>
|
||||||
|
<span v-if="errors.surname" class="field-error">{{ errors.surname }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="contact-national-id">کد ملی</label>
|
||||||
|
<input
|
||||||
|
id="contact-national-id"
|
||||||
|
v-model.trim="form.nationalIdCode"
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="10"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="۱۰ رقم"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
<span v-if="errors.nationalIdCode" class="field-error">{{ errors.nationalIdCode }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="contact-phone">شماره موبایل</label>
|
||||||
|
<input
|
||||||
|
id="contact-phone"
|
||||||
|
v-model.trim="form.phoneNumber"
|
||||||
|
type="tel"
|
||||||
|
inputmode="tel"
|
||||||
|
autocomplete="tel"
|
||||||
|
placeholder="۰۹۱۲۱۲۳۴۵۶۷"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
<span v-if="errors.phoneNumber" class="field-error">{{ errors.phoneNumber }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="field field-span">
|
||||||
|
<label for="contact-email">ایمیل</label>
|
||||||
|
<input
|
||||||
|
id="contact-email"
|
||||||
|
v-model.trim="form.email"
|
||||||
|
type="email"
|
||||||
|
autocomplete="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
<span v-if="errors.email" class="field-error">{{ errors.email }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span v-if="errors.contact" class="field-error">{{ errors.contact }}</span>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="form-block">
|
||||||
|
<legend>روش ترجیحی تماس</legend>
|
||||||
|
<div class="method-grid" role="group" aria-label="روشهای تماس">
|
||||||
|
<label
|
||||||
|
v-for="method in contactMethods"
|
||||||
|
:key="method.value"
|
||||||
|
class="method-chip"
|
||||||
|
:class="{ selected: preferredMethods.includes(method.value) }"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="preferredMethods"
|
||||||
|
type="checkbox"
|
||||||
|
:value="method.value"
|
||||||
|
/>
|
||||||
|
<span>{{ method.label }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<span v-if="errors.preferredContactMethods" class="field-error">
|
||||||
|
{{ errors.preferredContactMethods }}
|
||||||
|
</span>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="contact-message">پیام</label>
|
||||||
|
<textarea
|
||||||
|
id="contact-message"
|
||||||
|
v-model.trim="form.message"
|
||||||
|
rows="4"
|
||||||
|
maxlength="2000"
|
||||||
|
placeholder="اگر نکتهای دارید اینجا بنویسید…"
|
||||||
|
></textarea>
|
||||||
|
<span v-if="errors.message" class="field-error">{{ errors.message }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-ink" type="submit" :disabled="submitting">
|
||||||
|
<span v-if="submitting" class="spinner" aria-hidden="true"></span>
|
||||||
|
{{ submitting ? 'در حال ارسال…' : 'ارسال درخواست' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { contactApi } from '@/api/contactApi'
|
||||||
|
import { coursesApi } from '@/api/coursesApi'
|
||||||
|
import { formatPrice, getApiErrorMessage, toPersianDigits } from '@/utils/format'
|
||||||
|
|
||||||
|
const contactMethods = [
|
||||||
|
{ value: 'WhatsApp', label: 'واتساپ' },
|
||||||
|
{ value: 'Telegram', label: 'تلگرام' },
|
||||||
|
{ value: 'Soroush', label: 'سروش' },
|
||||||
|
{ value: 'Bale', label: 'بله' },
|
||||||
|
{ value: 'Eitaa', label: 'ایتا' },
|
||||||
|
{ value: 'SMS', label: 'پیامک' },
|
||||||
|
{ value: 'Call', label: 'تماس تلفنی' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const courses = ref([])
|
||||||
|
const coursesLoading = ref(true)
|
||||||
|
const selectedCourses = ref([])
|
||||||
|
const preferredMethods = ref([])
|
||||||
|
const submitting = ref(false)
|
||||||
|
const formError = ref('')
|
||||||
|
const successMessage = ref('')
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
name: '',
|
||||||
|
surname: '',
|
||||||
|
nationalIdCode: '',
|
||||||
|
phoneNumber: '',
|
||||||
|
email: '',
|
||||||
|
message: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const errors = reactive({
|
||||||
|
name: '',
|
||||||
|
surname: '',
|
||||||
|
nationalIdCode: '',
|
||||||
|
phoneNumber: '',
|
||||||
|
email: '',
|
||||||
|
contact: '',
|
||||||
|
courses: '',
|
||||||
|
preferredContactMethods: '',
|
||||||
|
message: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
function isValidNationalId(code) {
|
||||||
|
if (!/^\d{10}$/.test(code)) return false
|
||||||
|
if (/^(\d)\1{9}$/.test(code)) return false
|
||||||
|
const check = Number(code[9])
|
||||||
|
const sum = code
|
||||||
|
.split('')
|
||||||
|
.slice(0, 9)
|
||||||
|
.reduce((acc, digit, index) => acc + Number(digit) * (10 - index), 0)
|
||||||
|
const remainder = sum % 11
|
||||||
|
return (remainder < 2 && check === remainder) || (remainder >= 2 && check === 11 - remainder)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePhone(value) {
|
||||||
|
let phone = String(value || '').replace(/[\s\-()]/g, '')
|
||||||
|
phone = phone.replace(/[۰-۹]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d))
|
||||||
|
if (phone.startsWith('+98')) phone = `0${phone.slice(3)}`
|
||||||
|
if (phone.startsWith('0098')) phone = `0${phone.slice(4)}`
|
||||||
|
if (/^9\d{9}$/.test(phone)) phone = `0${phone}`
|
||||||
|
return phone
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearErrors() {
|
||||||
|
Object.keys(errors).forEach((key) => {
|
||||||
|
errors[key] = ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate() {
|
||||||
|
clearErrors()
|
||||||
|
let ok = true
|
||||||
|
|
||||||
|
if (!form.name) {
|
||||||
|
errors.name = 'نام الزامی است'
|
||||||
|
ok = false
|
||||||
|
}
|
||||||
|
if (!form.surname) {
|
||||||
|
errors.surname = 'نام خانوادگی الزامی است'
|
||||||
|
ok = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const phone = normalizePhone(form.phoneNumber)
|
||||||
|
if (form.nationalIdCode && !isValidNationalId(form.nationalIdCode)) {
|
||||||
|
errors.nationalIdCode = 'کد ملی معتبر نیست'
|
||||||
|
ok = false
|
||||||
|
}
|
||||||
|
if (form.phoneNumber && !/^09\d{9}$/.test(phone)) {
|
||||||
|
errors.phoneNumber = 'شماره موبایل معتبر نیست'
|
||||||
|
ok = false
|
||||||
|
}
|
||||||
|
if (form.email && !/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/.test(form.email)) {
|
||||||
|
errors.email = 'ایمیل معتبر نیست'
|
||||||
|
ok = false
|
||||||
|
}
|
||||||
|
if (!form.phoneNumber && !form.email) {
|
||||||
|
errors.contact = 'حداقل شماره موبایل یا ایمیل را وارد کنید'
|
||||||
|
ok = false
|
||||||
|
}
|
||||||
|
if (!preferredMethods.value.length) {
|
||||||
|
errors.preferredContactMethods = 'حداقل یک روش تماس را انتخاب کنید'
|
||||||
|
ok = false
|
||||||
|
}
|
||||||
|
if (form.message.length > 2000) {
|
||||||
|
errors.message = 'پیام نباید بیش از ۲۰۰۰ کاراکتر باشد'
|
||||||
|
ok = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
form.name = ''
|
||||||
|
form.surname = ''
|
||||||
|
form.nationalIdCode = ''
|
||||||
|
form.phoneNumber = ''
|
||||||
|
form.email = ''
|
||||||
|
form.message = ''
|
||||||
|
selectedCourses.value = []
|
||||||
|
preferredMethods.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
formError.value = ''
|
||||||
|
successMessage.value = ''
|
||||||
|
if (!validate()) return
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await contactApi.submit({
|
||||||
|
name: form.name,
|
||||||
|
surname: form.surname,
|
||||||
|
nationalIdCode: form.nationalIdCode || undefined,
|
||||||
|
phoneNumber: form.phoneNumber ? normalizePhone(form.phoneNumber) : undefined,
|
||||||
|
email: form.email || undefined,
|
||||||
|
message: form.message || undefined,
|
||||||
|
preferredContactMethods: preferredMethods.value,
|
||||||
|
courses: selectedCourses.value
|
||||||
|
})
|
||||||
|
resetForm()
|
||||||
|
successMessage.value = 'درخواست شما ثبت شد. بهزودی با شما تماس میگیریم.'
|
||||||
|
} catch (error) {
|
||||||
|
const details = error?.response?.data?.error?.details
|
||||||
|
if (details && typeof details === 'object' && !Array.isArray(details)) {
|
||||||
|
Object.entries(details).forEach(([key, value]) => {
|
||||||
|
if (key in errors) errors[key] = String(value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
formError.value = getApiErrorMessage(error, 'ارسال درخواست ناموفق بود. دوباره تلاش کنید.')
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const res = await coursesApi.getAll({ limit: 50, sort: 'title' })
|
||||||
|
courses.value = res.data || []
|
||||||
|
} catch {
|
||||||
|
courses.value = []
|
||||||
|
} finally {
|
||||||
|
coursesLoading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.contact-section {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 12% 20%, rgba(232, 163, 23, 0.12), transparent 40%),
|
||||||
|
linear-gradient(180deg, #e8f0ea 0%, var(--canvas) 100%);
|
||||||
|
padding-block: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-shell {
|
||||||
|
max-width: 48rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
max-width: 36rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head h2 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(2rem, 4vw, 2.8rem);
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head p {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1.75rem 1.5rem;
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
background: rgba(247, 251, 248, 0.72);
|
||||||
|
border: 1px solid rgba(20, 53, 43, 0.08);
|
||||||
|
box-shadow: 0 16px 40px rgba(20, 53, 43, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-block {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-block legend {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink);
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fields-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-span {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.courses-loading {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-checks {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
border: 1.5px solid var(--mist);
|
||||||
|
background: var(--chalk);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s ease, background 0.2s ease, transform 0.2s var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-card:hover {
|
||||||
|
border-color: var(--leaf);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-card.selected {
|
||||||
|
border-color: var(--leaf);
|
||||||
|
background: rgba(45, 106, 79, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-card input {
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
accent-color: var(--leaf);
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-card-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-card-body strong {
|
||||||
|
font-size: 0.98rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-card-meta {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-card-desc {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.55rem 0.9rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1.5px solid var(--mist);
|
||||||
|
background: var(--chalk);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-chip input {
|
||||||
|
accent-color: var(--leaf);
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-chip.selected {
|
||||||
|
border-color: var(--leaf);
|
||||||
|
background: rgba(45, 106, 79, 0.1);
|
||||||
|
color: var(--leaf);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
background: rgba(180, 35, 24, 0.08);
|
||||||
|
color: var(--danger);
|
||||||
|
border: 1px solid rgba(180, 35, 24, 0.2);
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
padding: 0.75rem 0.9rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-success {
|
||||||
|
background: rgba(45, 106, 79, 0.1);
|
||||||
|
color: var(--leaf);
|
||||||
|
border-color: rgba(45, 106, 79, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn .spinner {
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.fields-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-span {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form {
|
||||||
|
padding: 1.35rem 1.1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<template>
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="container footer-inner">
|
||||||
|
<div>
|
||||||
|
<p class="footer-brand">گام نو</p>
|
||||||
|
<p class="footer-copy">آموزش تخصصی برای قدم بعدی شما.</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer-links">
|
||||||
|
<RouterLink to="/login">ورود</RouterLink>
|
||||||
|
<a href="/#courses">دورهها</a>
|
||||||
|
<a href="/#contact">تماس با ما</a>
|
||||||
|
<RouterLink to="/account">پنل کاربری</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.site-footer {
|
||||||
|
background: var(--deep);
|
||||||
|
color: var(--chalk);
|
||||||
|
padding: 2.5rem 0;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-inner {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-brand {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--saffron);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-copy {
|
||||||
|
color: rgba(247, 251, 248, 0.72);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links a {
|
||||||
|
color: rgba(247, 251, 248, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links a:hover {
|
||||||
|
color: var(--saffron);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<template>
|
||||||
|
<header class="site-header" :class="{ scrolled, overHero }">
|
||||||
|
<div class="container header-inner">
|
||||||
|
<RouterLink to="/" class="brand" aria-label="گام نو">
|
||||||
|
<span class="brand-mark" aria-hidden="true">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</span>
|
||||||
|
<span class="brand-text">گام نو</span>
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<nav class="header-nav" aria-label="منوی اصلی">
|
||||||
|
<a href="/#courses">دورهها</a>
|
||||||
|
<a href="/#path">مسیر یادگیری</a>
|
||||||
|
<a href="/#contact">تماس با ما</a>
|
||||||
|
<RouterLink v-if="auth.isAuthenticated" :to="{ name: 'account' }">حساب من</RouterLink>
|
||||||
|
<RouterLink
|
||||||
|
v-else
|
||||||
|
:to="{ name: 'login' }"
|
||||||
|
class="btn btn-primary header-cta"
|
||||||
|
>
|
||||||
|
ورود به حساب
|
||||||
|
</RouterLink>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { RouterLink, useRoute } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const route = useRoute()
|
||||||
|
const scrolled = ref(false)
|
||||||
|
const overHero = computed(() => route.name === 'landing' && !scrolled.value)
|
||||||
|
|
||||||
|
function onScroll() {
|
||||||
|
scrolled.value = window.scrollY > 24
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
onScroll()
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('scroll', onScroll)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.site-header {
|
||||||
|
position: fixed;
|
||||||
|
inset-inline: 0;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
height: var(--header-h);
|
||||||
|
transition: background 0.3s var(--ease), backdrop-filter 0.3s var(--ease), box-shadow 0.3s var(--ease), color 0.3s var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header.scrolled {
|
||||||
|
background: rgba(220, 232, 223, 0.88);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
box-shadow: 0 8px 30px rgba(12, 34, 27, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header.overHero .brand-text,
|
||||||
|
.site-header.overHero .header-nav > a:not(.btn) {
|
||||||
|
color: var(--chalk);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header.overHero .header-nav > a:not(.btn):hover {
|
||||||
|
color: var(--saffron);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-inner {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.22rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
height: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark span {
|
||||||
|
width: 0.38rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--saffron);
|
||||||
|
animation: rise 1.8s var(--ease) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark span:nth-child(1) { height: 0.55rem; animation-delay: 0s; }
|
||||||
|
.brand-mark span:nth-child(2) { height: 0.9rem; animation-delay: 0.15s; }
|
||||||
|
.brand-mark span:nth-child(3) { height: 1.35rem; animation-delay: 0.3s; }
|
||||||
|
|
||||||
|
@keyframes rise {
|
||||||
|
0%, 100% { transform: translateY(0); opacity: 0.7; }
|
||||||
|
50% { transform: translateY(-3px); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-text {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-nav a:not(.btn) {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-nav a:not(.btn):hover {
|
||||||
|
color: var(--leaf);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-cta {
|
||||||
|
min-height: 2.5rem;
|
||||||
|
padding-inline: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.header-nav a:not(.btn) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<template>
|
||||||
|
<div class="account-layout">
|
||||||
|
<SiteHeader />
|
||||||
|
<div class="account-shell container">
|
||||||
|
<aside class="account-nav" aria-label="منوی حساب کاربری">
|
||||||
|
<div class="account-user">
|
||||||
|
<div class="avatar" aria-hidden="true">{{ initials }}</div>
|
||||||
|
<div>
|
||||||
|
<p class="user-name">{{ auth.fullName || 'کاربر' }}</p>
|
||||||
|
<p class="user-meta">{{ auth.user?.username }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav>
|
||||||
|
<RouterLink :to="{ name: 'account' }" class="nav-link">خلاصه</RouterLink>
|
||||||
|
<RouterLink :to="{ name: 'my-classes' }" class="nav-link">کلاسهای من</RouterLink>
|
||||||
|
<RouterLink :to="{ name: 'my-certificates' }" class="nav-link">گواهینامهها</RouterLink>
|
||||||
|
</nav>
|
||||||
|
<button type="button" class="logout-btn" @click="handleLogout">خروج از حساب</button>
|
||||||
|
</aside>
|
||||||
|
<section class="account-content">
|
||||||
|
<RouterView />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { RouterLink, RouterView, useRouter } from 'vue-router'
|
||||||
|
import SiteHeader from '@/components/SiteHeader.vue'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const initials = computed(() => {
|
||||||
|
const name = auth.user?.name || ''
|
||||||
|
const surname = auth.user?.surname || ''
|
||||||
|
return `${name.charAt(0)}${surname.charAt(0)}` || 'گ'
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await auth.logout()
|
||||||
|
router.push({ name: 'landing' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.account-layout {
|
||||||
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 10% 0%, rgba(232, 163, 23, 0.12), transparent 40%),
|
||||||
|
linear-gradient(180deg, var(--canvas) 0%, var(--canvas-deep) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 260px 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding-block: 6.5rem 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-nav {
|
||||||
|
position: sticky;
|
||||||
|
top: 5.5rem;
|
||||||
|
align-self: start;
|
||||||
|
padding: 1.25rem;
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
background: var(--chalk);
|
||||||
|
border: 1px solid rgba(20, 53, 43, 0.08);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-user {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
padding-bottom: 1.1rem;
|
||||||
|
border-bottom: 1px solid rgba(20, 53, 43, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: var(--ink);
|
||||||
|
color: var(--saffron);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-meta {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
padding: 0.7rem 0.85rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
transition: background 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover,
|
||||||
|
.nav-link.router-link-active {
|
||||||
|
background: rgba(45, 106, 79, 0.1);
|
||||||
|
color: var(--leaf);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--danger);
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.7rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn:hover {
|
||||||
|
background: rgba(180, 35, 24, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-content {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.account-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
padding-block: 5.5rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-nav {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<template>
|
||||||
|
<div class="public-layout">
|
||||||
|
<SiteHeader />
|
||||||
|
<main>
|
||||||
|
<RouterView />
|
||||||
|
</main>
|
||||||
|
<SiteFooter />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { RouterView } from 'vue-router'
|
||||||
|
import SiteHeader from '@/components/SiteHeader.vue'
|
||||||
|
import SiteFooter from '@/components/SiteFooter.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.public-layout {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import '@fontsource/vazirmatn/400.css'
|
||||||
|
import '@fontsource/vazirmatn/500.css'
|
||||||
|
import '@fontsource/vazirmatn/700.css'
|
||||||
|
import './style.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.mount('#app')
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: () => import('@/layouts/PublicLayout.vue'),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'landing',
|
||||||
|
component: () => import('@/views/LandingView.vue'),
|
||||||
|
meta: { title: 'گام نو' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'login',
|
||||||
|
name: 'login',
|
||||||
|
component: () => import('@/views/LoginView.vue'),
|
||||||
|
meta: { title: 'ورود | گام نو', guestOnly: true }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/account',
|
||||||
|
component: () => import('@/layouts/AccountLayout.vue'),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'account',
|
||||||
|
component: () => import('@/views/account/AccountHomeView.vue'),
|
||||||
|
meta: { title: 'پنل کاربری | گام نو' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'classes',
|
||||||
|
name: 'my-classes',
|
||||||
|
component: () => import('@/views/account/MyClassesView.vue'),
|
||||||
|
meta: { title: 'کلاسهای من | گام نو' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'certificates',
|
||||||
|
name: 'my-certificates',
|
||||||
|
component: () => import('@/views/account/MyCertificatesView.vue'),
|
||||||
|
meta: { title: 'گواهینامهها | گام نو' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/:pathMatch(.*)*',
|
||||||
|
redirect: '/'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
scrollBehavior(to, _from, saved) {
|
||||||
|
if (saved) return saved
|
||||||
|
if (to.hash) return { el: to.hash, behavior: 'smooth' }
|
||||||
|
return { top: 0 }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
document.title = to.meta.title || 'گام نو'
|
||||||
|
|
||||||
|
if (to.meta.requiresAuth && !auth.isAuthenticated) {
|
||||||
|
return { name: 'login', query: { redirect: to.fullPath } }
|
||||||
|
}
|
||||||
|
if (to.meta.guestOnly && auth.isAuthenticated) {
|
||||||
|
return { name: 'account' }
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import Cookies from 'js-cookie'
|
||||||
|
import { authApi } from '@/api/authApi'
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
const accessToken = ref(localStorage.getItem('frontAccessToken') || Cookies.get('frontAccessToken') || '')
|
||||||
|
const refreshToken = ref(localStorage.getItem('frontRefreshToken') || Cookies.get('frontRefreshToken') || '')
|
||||||
|
const user = ref(JSON.parse(localStorage.getItem('frontUser') || 'null'))
|
||||||
|
|
||||||
|
const isAuthenticated = computed(() => !!accessToken.value)
|
||||||
|
const fullName = computed(() => {
|
||||||
|
if (!user.value) return ''
|
||||||
|
return `${user.value.name || ''} ${user.value.surname || ''}`.trim()
|
||||||
|
})
|
||||||
|
|
||||||
|
function setTokens(access, refresh) {
|
||||||
|
accessToken.value = access
|
||||||
|
refreshToken.value = refresh
|
||||||
|
if (access) {
|
||||||
|
localStorage.setItem('frontAccessToken', access)
|
||||||
|
Cookies.set('frontAccessToken', access)
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('frontAccessToken')
|
||||||
|
Cookies.remove('frontAccessToken')
|
||||||
|
}
|
||||||
|
if (refresh) {
|
||||||
|
localStorage.setItem('frontRefreshToken', refresh)
|
||||||
|
Cookies.set('frontRefreshToken', refresh)
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('frontRefreshToken')
|
||||||
|
Cookies.remove('frontRefreshToken')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUser(userData) {
|
||||||
|
user.value = userData
|
||||||
|
if (userData) localStorage.setItem('frontUser', JSON.stringify(userData))
|
||||||
|
else localStorage.removeItem('frontUser')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(credentials) {
|
||||||
|
const response = await authApi.login(credentials)
|
||||||
|
const data = response.data || response
|
||||||
|
setTokens(data.accessToken, data.refreshToken)
|
||||||
|
setUser(data.user)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
try {
|
||||||
|
if (refreshToken.value) await authApi.logout(refreshToken.value)
|
||||||
|
} catch {
|
||||||
|
// ignore logout network errors
|
||||||
|
} finally {
|
||||||
|
setTokens('', '')
|
||||||
|
setUser(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSelf() {
|
||||||
|
const response = await authApi.getSelf()
|
||||||
|
const data = response.data || response
|
||||||
|
setUser(data)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
user,
|
||||||
|
isAuthenticated,
|
||||||
|
fullName,
|
||||||
|
setTokens,
|
||||||
|
setUser,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
fetchSelf
|
||||||
|
}
|
||||||
|
})
|
||||||
+274
@@ -0,0 +1,274 @@
|
|||||||
|
/* گام نو — verdant campus dawn */
|
||||||
|
:root {
|
||||||
|
--canvas: #dce8df;
|
||||||
|
--canvas-deep: #c5d6ca;
|
||||||
|
--ink: #14352b;
|
||||||
|
--ink-soft: #2a4f42;
|
||||||
|
--leaf: #2d6a4f;
|
||||||
|
--leaf-bright: #3d8b66;
|
||||||
|
--saffron: #e8a317;
|
||||||
|
--saffron-deep: #c4860c;
|
||||||
|
--chalk: #f7fbf8;
|
||||||
|
--mist: #b8cfc0;
|
||||||
|
--deep: #0c221b;
|
||||||
|
--danger: #b42318;
|
||||||
|
--radius: 1rem;
|
||||||
|
--shadow: 0 18px 50px rgba(20, 53, 43, 0.12);
|
||||||
|
--font-display: 'Amiri', 'Vazirmatn', serif;
|
||||||
|
--font-body: 'Vazirmatn', system-ui, sans-serif;
|
||||||
|
--header-h: 4.25rem;
|
||||||
|
--ease: cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--canvas);
|
||||||
|
line-height: 1.7;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4 {
|
||||||
|
line-height: 1.25;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: min(1120px, calc(100% - 2.5rem));
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-height: 2.85rem;
|
||||||
|
padding: 0.65rem 1.35rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
font-weight: 700;
|
||||||
|
transition: transform 0.25s var(--ease), background 0.25s var(--ease), color 0.25s var(--ease), border-color 0.25s var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:focus-visible {
|
||||||
|
outline: 3px solid var(--saffron);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--saffron);
|
||||||
|
color: var(--deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--saffron-deep);
|
||||||
|
color: var(--chalk);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--chalk);
|
||||||
|
border-color: rgba(247, 251, 248, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover {
|
||||||
|
background: rgba(247, 251, 248, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ink {
|
||||||
|
background: var(--ink);
|
||||||
|
color: var(--chalk);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ink:hover {
|
||||||
|
background: var(--leaf);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
border-color: var(--mist);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline:hover {
|
||||||
|
border-color: var(--leaf);
|
||||||
|
color: var(--leaf);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-block {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input,
|
||||||
|
.field textarea,
|
||||||
|
.field select {
|
||||||
|
min-height: 2.9rem;
|
||||||
|
padding: 0.65rem 0.95rem;
|
||||||
|
border: 1.5px solid var(--mist);
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
background: var(--chalk);
|
||||||
|
color: var(--ink);
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field textarea {
|
||||||
|
min-height: 7rem;
|
||||||
|
resize: vertical;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:focus,
|
||||||
|
.field textarea:focus,
|
||||||
|
.field select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--leaf);
|
||||||
|
box-shadow: 0 0 0 4px rgba(45, 106, 79, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error {
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.2rem 0.65rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
background: rgba(45, 106, 79, 0.12);
|
||||||
|
color: var(--leaf);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-gold {
|
||||||
|
background: rgba(232, 163, 23, 0.18);
|
||||||
|
color: var(--saffron-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-muted {
|
||||||
|
background: rgba(20, 53, 43, 0.08);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1.5rem;
|
||||||
|
border: 1.5px dashed var(--mist);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: rgba(247, 251, 248, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h3 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.6rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-block {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 12rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border: 3px solid var(--mist);
|
||||||
|
border-top-color: var(--leaf);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
html { scroll-behavior: auto; }
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import moment from 'jalali-moment'
|
||||||
|
|
||||||
|
const persianDigits = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹']
|
||||||
|
|
||||||
|
export function toPersianDigits(value) {
|
||||||
|
if (value == null) return ''
|
||||||
|
return String(value).replace(/\d/g, (d) => persianDigits[Number(d)])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatJalali(date, format = 'jYYYY/jMM/jDD') {
|
||||||
|
if (!date) return '—'
|
||||||
|
return toPersianDigits(moment(date).locale('fa').format(format))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPrice(amount) {
|
||||||
|
if (amount == null || Number.isNaN(Number(amount))) return '—'
|
||||||
|
return `${toPersianDigits(Number(amount).toLocaleString('en-US'))} تومان`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getApiErrorMessage(error, fallback = 'خطایی رخ داد. دوباره تلاش کنید.') {
|
||||||
|
return (
|
||||||
|
error?.response?.data?.error?.message ||
|
||||||
|
error?.response?.data?.message ||
|
||||||
|
error?.message ||
|
||||||
|
fallback
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,510 @@
|
|||||||
|
<template>
|
||||||
|
<div class="landing">
|
||||||
|
<section class="hero" aria-labelledby="hero-brand">
|
||||||
|
<div class="hero-media" aria-hidden="true">
|
||||||
|
<img
|
||||||
|
src="https://images.unsplash.com/photo-1524178232363-1fb2b075b655?auto=format&fit=crop&w=2000&q=80"
|
||||||
|
alt=""
|
||||||
|
width="2000"
|
||||||
|
height="1333"
|
||||||
|
/>
|
||||||
|
<div class="hero-veil"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container hero-content">
|
||||||
|
<p class="hero-kicker">آموزشگاه تخصصی</p>
|
||||||
|
<h1 id="hero-brand" class="hero-brand">گام نو</h1>
|
||||||
|
<p class="hero-lead">
|
||||||
|
کلاسهای حضوری، اساتید مجرب و گواهینامه رسمی — مسیر یادگیریتان را اینجا ادامه دهید.
|
||||||
|
</p>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<RouterLink
|
||||||
|
:to="auth.isAuthenticated ? { name: 'account' } : { name: 'login' }"
|
||||||
|
class="btn btn-primary"
|
||||||
|
>
|
||||||
|
{{ auth.isAuthenticated ? 'ورود به پنل کاربری' : 'ورود به حساب' }}
|
||||||
|
</RouterLink>
|
||||||
|
<a href="#courses" class="btn btn-ghost">مشاهده دورهها</a>
|
||||||
|
<a href="#contact" class="btn btn-ghost">درخواست مشاوره</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step-path" aria-hidden="true">
|
||||||
|
<span v-for="n in 5" :key="n" class="step" :style="{ '--i': n }"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="courses" class="section courses-section">
|
||||||
|
<div class="container">
|
||||||
|
<header class="section-head">
|
||||||
|
<h2>دورههای فعال</h2>
|
||||||
|
<p>از میان دورههای جاری انتخاب کنید و از پنل کاربری کلاسهای ثبتشدهتان را ببینید.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="coursesLoading" class="loading-block">
|
||||||
|
<div class="spinner" aria-label="در حال بارگذاری"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!courses.length" class="empty-state">
|
||||||
|
<h3>هنوز دورهای ثبت نشده</h3>
|
||||||
|
<p>به زودی فهرست دورهها اینجا نمایش داده میشود.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="course-rail">
|
||||||
|
<article v-for="course in courses" :key="course._id" class="course-item">
|
||||||
|
<div class="course-meta">
|
||||||
|
<span class="badge" :class="course.type === 'Private' ? 'badge-gold' : ''">
|
||||||
|
{{ course.type === 'Private' ? 'خصوصی' : 'عمومی' }}
|
||||||
|
</span>
|
||||||
|
<span v-if="course.isOfficial" class="badge badge-gold">رسمی</span>
|
||||||
|
</div>
|
||||||
|
<h3>{{ course.title }}</h3>
|
||||||
|
<p>{{ course.description || 'جزئیات دوره پس از ثبتنام در پنل کاربری در دسترس است.' }}</p>
|
||||||
|
|
||||||
|
<ul v-if="course.highlights?.length" class="course-highlights">
|
||||||
|
<li v-for="(item, idx) in course.highlights" :key="idx">{{ item }}</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="course-stats">
|
||||||
|
<span v-if="course.sectionCount">
|
||||||
|
{{ toPersianDigits(course.sectionCount) }} جلسه
|
||||||
|
</span>
|
||||||
|
<span v-if="course.hoursPerSection">
|
||||||
|
{{ toPersianDigits(course.hoursPerSection) }} ساعت هر جلسه
|
||||||
|
</span>
|
||||||
|
<span v-if="course.sectionCount && course.hoursPerSection">
|
||||||
|
مجموع {{ toPersianDigits(Number(course.sectionCount) * Number(course.hoursPerSection)) }} ساعت
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="course-foot">
|
||||||
|
<strong>{{ formatPrice(course.price) }}</strong>
|
||||||
|
<span v-if="course.rating">امتیاز {{ toPersianDigits(course.rating) }}</span>
|
||||||
|
</div>
|
||||||
|
<a href="#contact" class="course-cta">علاقهمندی و مشاوره</a>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="path" class="section path-section">
|
||||||
|
<div class="container path-grid">
|
||||||
|
<div class="path-copy">
|
||||||
|
<h2>سه قدم تا گواهینامه</h2>
|
||||||
|
<p>از ثبتنام تا دریافت گواهی، همه چیز در حساب کاربریتان یکجا جمع است.</p>
|
||||||
|
</div>
|
||||||
|
<ol class="path-steps">
|
||||||
|
<li>
|
||||||
|
<strong>ورود به حساب</strong>
|
||||||
|
<span>با نام کاربری و رمز عبور وارد شوید.</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>پیگیری کلاسها</strong>
|
||||||
|
<span>کلاسهای ثبتشده، استاد و بازه زمانی را ببینید.</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>دریافت گواهی</strong>
|
||||||
|
<span>گواهینامههای صادرشده را دانلود کنید.</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ContactInquiryForm />
|
||||||
|
|
||||||
|
<section class="section cta-section">
|
||||||
|
<div class="container cta-panel">
|
||||||
|
<h2>آماده برداشتن گام بعدی؟</h2>
|
||||||
|
<p>وارد حساب شوید و کلاسها و گواهینامههایتان را یکجا ببینید.</p>
|
||||||
|
<RouterLink
|
||||||
|
:to="auth.isAuthenticated ? { name: 'account' } : { name: 'login' }"
|
||||||
|
class="btn btn-ink"
|
||||||
|
>
|
||||||
|
{{ auth.isAuthenticated ? 'رفتن به پنل' : 'ورود به حساب' }}
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
import ContactInquiryForm from '@/components/ContactInquiryForm.vue'
|
||||||
|
import { coursesApi } from '@/api/coursesApi'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
import { formatPrice, toPersianDigits } from '@/utils/format'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const courses = ref([])
|
||||||
|
const coursesLoading = ref(true)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const res = await coursesApi.getAll({ limit: 50, sort: '-createdAt' })
|
||||||
|
courses.value = res.data || []
|
||||||
|
} catch {
|
||||||
|
courses.value = []
|
||||||
|
} finally {
|
||||||
|
coursesLoading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
color: var(--chalk);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-media {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-media img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transform: scale(1.04);
|
||||||
|
animation: heroDrift 18s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes heroDrift {
|
||||||
|
from { transform: scale(1.04) translate3d(0, 0, 0); }
|
||||||
|
to { transform: scale(1.08) translate3d(-1.5%, -1%, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-veil {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(12, 34, 27, 0.35) 0%, rgba(12, 34, 27, 0.55) 42%, rgba(12, 34, 27, 0.92) 100%),
|
||||||
|
radial-gradient(ellipse at 70% 20%, rgba(232, 163, 23, 0.22), transparent 55%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
padding-block: 7rem 4.5rem;
|
||||||
|
max-width: 40rem;
|
||||||
|
margin-inline: 0 auto 0;
|
||||||
|
animation: heroIn 0.9s var(--ease) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes heroIn {
|
||||||
|
from { opacity: 0; transform: translateY(24px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-kicker {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--saffron);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-brand {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(3.8rem, 12vw, 6.5rem);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 0.95;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
text-shadow: 0 10px 40px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-lead {
|
||||||
|
font-size: clamp(1rem, 2.4vw, 1.2rem);
|
||||||
|
color: rgba(247, 251, 248, 0.88);
|
||||||
|
max-width: 32rem;
|
||||||
|
margin-bottom: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-path {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
width: 0.7rem;
|
||||||
|
height: 0.7rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--saffron);
|
||||||
|
opacity: 0;
|
||||||
|
animation: stepIn 0.6s var(--ease) forwards;
|
||||||
|
animation-delay: calc(var(--i) * 0.12s + 0.5s);
|
||||||
|
box-shadow: 0 0 0 6px rgba(232, 163, 23, 0.18);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-start: 100%;
|
||||||
|
top: 50%;
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, rgba(232, 163, 23, 0.7), transparent);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step:last-child::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes stepIn {
|
||||||
|
from { opacity: 0; transform: translateY(8px) scale(0.6); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
padding-block: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
max-width: 36rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head h2,
|
||||||
|
.path-copy h2,
|
||||||
|
.cta-panel h2 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(2rem, 4vw, 2.8rem);
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head p,
|
||||||
|
.path-copy p,
|
||||||
|
.cta-panel p {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.courses-section {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, var(--canvas) 0%, #e8f0ea 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-rail {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-item {
|
||||||
|
padding: 1.35rem 1.25rem;
|
||||||
|
border-radius: 1.15rem;
|
||||||
|
background: var(--chalk);
|
||||||
|
border: 1px solid rgba(20, 53, 43, 0.06);
|
||||||
|
box-shadow: 0 10px 30px rgba(20, 53, 43, 0.05);
|
||||||
|
transition: transform 0.3s var(--ease), box-shadow 0.3s var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-item:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-item h3 {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-item p {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 4.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-highlights {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0.9rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-highlights li {
|
||||||
|
position: relative;
|
||||||
|
padding-inline-start: 1rem;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-highlights li::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-start: 0;
|
||||||
|
top: 0.55em;
|
||||||
|
width: 0.35rem;
|
||||||
|
height: 0.35rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--leaf);
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.55rem;
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-stats span {
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(45, 106, 79, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-foot {
|
||||||
|
margin-top: 1.1rem;
|
||||||
|
padding-top: 0.9rem;
|
||||||
|
border-top: 1px solid rgba(20, 53, 43, 0.08);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-foot span {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-cta {
|
||||||
|
display: inline-flex;
|
||||||
|
margin-top: 0.85rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--leaf);
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-cta:hover {
|
||||||
|
color: var(--saffron-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-section {
|
||||||
|
background: var(--ink);
|
||||||
|
color: var(--chalk);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 0.9fr 1.1fr;
|
||||||
|
gap: 2.5rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-copy p {
|
||||||
|
color: rgba(247, 251, 248, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-steps {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-steps li {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 1.1rem 1.2rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: rgba(247, 251, 248, 0.06);
|
||||||
|
border: 1px solid rgba(247, 251, 248, 0.1);
|
||||||
|
counter-increment: step;
|
||||||
|
position: relative;
|
||||||
|
padding-inline-start: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-steps {
|
||||||
|
counter-reset: step;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-steps li::before {
|
||||||
|
content: counter(step, persian);
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-start: 1rem;
|
||||||
|
top: 1rem;
|
||||||
|
width: 2.1rem;
|
||||||
|
height: 2.1rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: var(--saffron);
|
||||||
|
color: var(--deep);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-steps strong {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-steps span {
|
||||||
|
color: rgba(247, 251, 248, 0.72);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-section {
|
||||||
|
padding-block: 4rem 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-panel {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1.5rem;
|
||||||
|
border-radius: 1.75rem;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 20% 20%, rgba(232, 163, 23, 0.2), transparent 45%),
|
||||||
|
linear-gradient(135deg, #e7f1ea, #cfe0d4);
|
||||||
|
border: 1px solid rgba(20, 53, 43, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-panel p {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.path-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-content {
|
||||||
|
padding-block: 6rem 3.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.hero-media img {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login-page">
|
||||||
|
<div class="container login-shell">
|
||||||
|
<div class="login-visual" aria-hidden="true">
|
||||||
|
<p class="visual-brand">گام نو</p>
|
||||||
|
<p class="visual-text">کلاسها و گواهینامههایتان در یک نگاه.</p>
|
||||||
|
<div class="visual-steps">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="login-form" @submit.prevent="handleLogin">
|
||||||
|
<h1>ورود به حساب</h1>
|
||||||
|
<p class="form-lead">نام کاربری و رمز عبور خود را وارد کنید.</p>
|
||||||
|
|
||||||
|
<div v-if="formError" class="alert" role="alert">{{ formError }}</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="username">نام کاربری</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
v-model.trim="form.username"
|
||||||
|
type="text"
|
||||||
|
autocomplete="username"
|
||||||
|
placeholder="نام کاربری"
|
||||||
|
/>
|
||||||
|
<span v-if="errors.username" class="field-error">{{ errors.username }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="password">رمز عبور</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
v-model="form.password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
placeholder="رمز عبور"
|
||||||
|
/>
|
||||||
|
<span v-if="errors.password" class="field-error">{{ errors.password }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-ink btn-block" type="submit" :disabled="loading">
|
||||||
|
<span v-if="loading" class="spinner" aria-hidden="true"></span>
|
||||||
|
{{ loading ? 'در حال ورود…' : 'ورود' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<RouterLink to="/" class="back-link">بازگشت به صفحه اصلی</RouterLink>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
import { getApiErrorMessage } from '@/utils/format'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const form = reactive({ username: '', password: '' })
|
||||||
|
const errors = reactive({ username: '', password: '' })
|
||||||
|
const formError = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
function validate() {
|
||||||
|
errors.username = form.username ? '' : 'نام کاربری الزامی است'
|
||||||
|
errors.password = form.password ? '' : 'رمز عبور الزامی است'
|
||||||
|
return !errors.username && !errors.password
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
formError.value = ''
|
||||||
|
if (!validate()) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await auth.login({
|
||||||
|
username: form.username,
|
||||||
|
password: form.password
|
||||||
|
})
|
||||||
|
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/account'
|
||||||
|
router.push(redirect)
|
||||||
|
} catch (error) {
|
||||||
|
formError.value = getApiErrorMessage(error, 'نام کاربری یا رمز عبور نادرست است.')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-page {
|
||||||
|
min-height: calc(100vh - var(--header-h));
|
||||||
|
padding: 6.5rem 0 3rem;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 85% 15%, rgba(232, 163, 23, 0.18), transparent 35%),
|
||||||
|
linear-gradient(180deg, var(--canvas), var(--canvas-deep));
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0;
|
||||||
|
max-width: 920px;
|
||||||
|
border-radius: 1.75rem;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border: 1px solid rgba(20, 53, 43, 0.08);
|
||||||
|
background: var(--chalk);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-visual {
|
||||||
|
background:
|
||||||
|
linear-gradient(160deg, rgba(12, 34, 27, 0.88), rgba(45, 106, 79, 0.85)),
|
||||||
|
url('https://images.unsplash.com/photo-1522202176988-66273c2fd55f?auto=format&fit=crop&w=1200&q=80') center/cover;
|
||||||
|
color: var(--chalk);
|
||||||
|
padding: 2.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
|
min-height: 28rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visual-brand {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--saffron);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visual-text {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
color: rgba(247, 251, 248, 0.85);
|
||||||
|
max-width: 16rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visual-steps {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.55rem;
|
||||||
|
margin-top: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visual-steps span {
|
||||||
|
width: 0.55rem;
|
||||||
|
height: 0.55rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--saffron);
|
||||||
|
animation: pulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visual-steps span:nth-child(2) { animation-delay: 0.2s; }
|
||||||
|
.visual-steps span:nth-child(3) { animation-delay: 0.4s; }
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { transform: scale(1); opacity: 0.55; }
|
||||||
|
50% { transform: scale(1.25); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
padding: 2.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form h1 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 2.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-lead {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
background: rgba(180, 35, 24, 0.08);
|
||||||
|
color: var(--danger);
|
||||||
|
border: 1px solid rgba(180, 35, 24, 0.2);
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
padding: 0.75rem 0.9rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn .spinner {
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
color: var(--leaf);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.login-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-visual {
|
||||||
|
min-height: 12rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<template>
|
||||||
|
<div class="account-home">
|
||||||
|
<header class="page-head">
|
||||||
|
<h1>سلام، {{ auth.user?.name || 'کاربر' }}</h1>
|
||||||
|
<p>کلاسهای ثبتشده و گواهینامههایتان را از اینجا مدیریت کنید.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="summary-grid">
|
||||||
|
<RouterLink :to="{ name: 'my-classes' }" class="summary-tile">
|
||||||
|
<span class="tile-label">کلاسهای من</span>
|
||||||
|
<strong class="tile-value">
|
||||||
|
<span v-if="loading">—</span>
|
||||||
|
<span v-else>{{ toPersianDigits(classCount) }}</span>
|
||||||
|
</strong>
|
||||||
|
<span class="tile-hint">مشاهده کلاسهای ثبتشده</span>
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<RouterLink :to="{ name: 'my-certificates' }" class="summary-tile gold">
|
||||||
|
<span class="tile-label">گواهینامهها</span>
|
||||||
|
<strong class="tile-value">
|
||||||
|
<span v-if="loading">—</span>
|
||||||
|
<span v-else>{{ toPersianDigits(certCount) }}</span>
|
||||||
|
</strong>
|
||||||
|
<span class="tile-hint">دانلود و مشاهده گواهیها</span>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
import { classesApi } from '@/api/classesApi'
|
||||||
|
import { certificatesApi } from '@/api/certificatesApi'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
import { toPersianDigits } from '@/utils/format'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const loading = ref(true)
|
||||||
|
const classCount = ref(0)
|
||||||
|
const certCount = ref(0)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const [classesRes, certsRes] = await Promise.all([
|
||||||
|
classesApi.getMyClasses({ limit: 1 }),
|
||||||
|
certificatesApi.getMyCertificates({ limit: 1 })
|
||||||
|
])
|
||||||
|
classCount.value = classesRes.meta?.totalCount || classesRes.data?.length || 0
|
||||||
|
certCount.value = certsRes.meta?.totalCount || certsRes.data?.length || 0
|
||||||
|
} catch {
|
||||||
|
classCount.value = 0
|
||||||
|
certCount.value = 0
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-head {
|
||||||
|
margin-bottom: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-head h1 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(2rem, 4vw, 2.6rem);
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-head p {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-tile {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
background: var(--chalk);
|
||||||
|
border: 1px solid rgba(20, 53, 43, 0.08);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
transition: transform 0.25s var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-tile:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-tile.gold {
|
||||||
|
background: linear-gradient(145deg, #fff8e8, var(--chalk));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-value {
|
||||||
|
font-size: 2.4rem;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
color: var(--ink);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-hint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--leaf);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.summary-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<template>
|
||||||
|
<div class="certs-view">
|
||||||
|
<header class="page-head">
|
||||||
|
<h1>گواهینامهها</h1>
|
||||||
|
<p>گواهیهای صادرشده یا بارگذاریشده برای حساب شما.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading-block">
|
||||||
|
<div class="spinner" aria-label="در حال بارگذاری"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="error" class="empty-state">
|
||||||
|
<h3>بارگذاری ناموفق بود</h3>
|
||||||
|
<p>{{ error }}</p>
|
||||||
|
<button type="button" class="btn btn-ink" @click="load">تلاش دوباره</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!certificates.length" class="empty-state">
|
||||||
|
<h3>گواهینامهای یافت نشد</h3>
|
||||||
|
<p>پس از صدور گواهی توسط آموزشگاه، فایل آن اینجا قابل دانلود است.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="cert-list">
|
||||||
|
<article v-for="cert in certificates" :key="cert._id" class="cert-card">
|
||||||
|
<div class="cert-mark" aria-hidden="true">گ</div>
|
||||||
|
<div class="cert-body">
|
||||||
|
<div class="cert-top">
|
||||||
|
<h2>{{ cert.title }}</h2>
|
||||||
|
<span class="badge" :class="cert.isOfficial ? 'badge-gold' : 'badge-muted'">
|
||||||
|
{{ cert.isOfficial ? 'رسمی' : 'شخصی' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="issuer">صادرکننده: {{ cert.issuer || 'گام نو' }}</p>
|
||||||
|
<p class="course" v-if="cert.course?.title">دوره: {{ cert.course.title }}</p>
|
||||||
|
<div class="cert-foot">
|
||||||
|
<span>{{ formatJalali(cert.issuedAt) }}</span>
|
||||||
|
<a
|
||||||
|
v-if="cert.presignedUrl"
|
||||||
|
class="btn btn-outline"
|
||||||
|
:href="cert.presignedUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
مشاهده / دانلود
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { certificatesApi } from '@/api/certificatesApi'
|
||||||
|
import { formatJalali, getApiErrorMessage } from '@/utils/format'
|
||||||
|
|
||||||
|
const certificates = ref([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const res = await certificatesApi.getMyCertificates({ limit: 50 })
|
||||||
|
certificates.value = res.data || []
|
||||||
|
} catch (err) {
|
||||||
|
error.value = getApiErrorMessage(err)
|
||||||
|
certificates.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-head {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-head h1 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(1.9rem, 3.5vw, 2.4rem);
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-head p {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
border-radius: 1.2rem;
|
||||||
|
background: linear-gradient(145deg, #fff9ec, var(--chalk));
|
||||||
|
border: 1px solid rgba(196, 134, 12, 0.18);
|
||||||
|
box-shadow: 0 10px 28px rgba(20, 53, 43, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-mark {
|
||||||
|
width: 3.2rem;
|
||||||
|
height: 3.2rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: var(--ink);
|
||||||
|
color: var(--saffron);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-top h2 {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issuer,
|
||||||
|
.course {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-foot {
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-foot span {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.cert-card {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<template>
|
||||||
|
<div class="classes-view">
|
||||||
|
<header class="page-head">
|
||||||
|
<h1>کلاسهای من</h1>
|
||||||
|
<p>کلاسهایی که در آنها ثبتنام شدهاید.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading-block">
|
||||||
|
<div class="spinner" aria-label="در حال بارگذاری"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="error" class="empty-state">
|
||||||
|
<h3>بارگذاری ناموفق بود</h3>
|
||||||
|
<p>{{ error }}</p>
|
||||||
|
<button type="button" class="btn btn-ink" @click="load">تلاش دوباره</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!classes.length" class="empty-state">
|
||||||
|
<h3>هنوز کلاسی ندارید</h3>
|
||||||
|
<p>پس از ثبتنام در کلاس توسط آموزشگاه، اینجا نمایش داده میشود.</p>
|
||||||
|
<RouterLink to="/#courses" class="btn btn-outline">مشاهده دورهها</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="class-list">
|
||||||
|
<article v-for="item in classes" :key="item._id" class="class-card">
|
||||||
|
<div class="class-top">
|
||||||
|
<h2>{{ item.name }}</h2>
|
||||||
|
<span class="badge" :class="item.isActive ? '' : 'badge-muted'">
|
||||||
|
{{ item.isActive ? 'فعال' : 'غیرفعال' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="course-title">{{ item.course?.title || 'بدون دوره' }}</p>
|
||||||
|
<dl class="meta-grid">
|
||||||
|
<div>
|
||||||
|
<dt>استاد</dt>
|
||||||
|
<dd>
|
||||||
|
{{
|
||||||
|
item.professor
|
||||||
|
? `${item.professor.name || ''} ${item.professor.surname || ''}`.trim()
|
||||||
|
: '—'
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>شروع</dt>
|
||||||
|
<dd>{{ formatJalali(item.startDate) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>پایان</dt>
|
||||||
|
<dd>{{ formatJalali(item.endDate) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>شهریه</dt>
|
||||||
|
<dd>{{ formatPrice(item.tuitionFee) }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
import { classesApi } from '@/api/classesApi'
|
||||||
|
import { formatJalali, formatPrice, getApiErrorMessage } from '@/utils/format'
|
||||||
|
|
||||||
|
const classes = ref([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const res = await classesApi.getMyClasses({ limit: 50 })
|
||||||
|
classes.value = res.data || []
|
||||||
|
} catch (err) {
|
||||||
|
error.value = getApiErrorMessage(err)
|
||||||
|
classes.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-head {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-head h1 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(1.9rem, 3.5vw, 2.4rem);
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-head p {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-card {
|
||||||
|
padding: 1.35rem 1.4rem;
|
||||||
|
border-radius: 1.2rem;
|
||||||
|
background: var(--chalk);
|
||||||
|
border: 1px solid rgba(20, 53, 43, 0.08);
|
||||||
|
box-shadow: 0 10px 28px rgba(20, 53, 43, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-top h2 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-title {
|
||||||
|
color: var(--leaf);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 0.85rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-grid dt {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-grid dd {
|
||||||
|
margin: 0.15rem 0 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.meta-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5174,
|
||||||
|
strictPort: true
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user