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,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>
|
||||
Reference in New Issue
Block a user