Initial commit: public frontend for GameNo.

Vue 3 + Vite student-facing app with routing, Pinia state, and API integration.
This commit is contained in:
2026-08-09 04:18:09 +02:00
commit a9135bfa09
32 changed files with 5394 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:5000/api
+25
View File
@@ -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?
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}
+5
View File
@@ -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
View File
@@ -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>
+2560
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -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"
}
}
+7
View File
@@ -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

+24
View File
@@ -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

+7
View File
@@ -0,0 +1,7 @@
<template>
<RouterView />
</template>
<script setup>
import { RouterView } from 'vue-router'
</script>
+7
View File
@@ -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')
}
+104
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
import axiosInstance from './axiosInstance'
export const certificatesApi = {
getMyCertificates: (params = {}) =>
axiosInstance.get('/certificates/user/my-certificates', { params })
}
+5
View File
@@ -0,0 +1,5 @@
import axiosInstance from './axiosInstance'
export const classesApi = {
getMyClasses: (params = {}) => axiosInstance.get('/classes/user/my-classes', { params })
}
+5
View File
@@ -0,0 +1,5 @@
import axiosInstance from './axiosInstance'
export const contactApi = {
submit: (payload) => axiosInstance.post('/contact-inquiries/public/create', payload)
}
+5
View File
@@ -0,0 +1,5 @@
import axiosInstance from './axiosInstance'
export const coursesApi = {
getAll: (params = {}) => axiosInstance.get('/courses/user/get-all', { params })
}
+530
View File
@@ -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>
+63
View File
@@ -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>
+149
View File
@@ -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>
+163
View File
@@ -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>
+27
View File
@@ -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
View File
@@ -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')
+77
View File
@@ -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
+80
View File
@@ -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
View File
@@ -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;
}
}
+27
View File
@@ -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
)
}
+510
View File
@@ -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>
+217
View File
@@ -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>
+124
View File
@@ -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>
+161
View File
@@ -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>
+158
View File
@@ -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>
+16
View File
@@ -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
}
})