Compare commits

..
10 Commits
Author SHA1 Message Date
kavehhn 8f67f27b36 fix: raise registration form to reduce scrolling.
Fold the page title into the card header, tighten vertical spacing, and hide the footer on registration routes.
2026-08-16 08:48:49 +03:30
kavehhn 6d76a90d9b style: polish registration flow layout and spacing.
Use a unified card with stepper, clearer payment options, and consistent padding on both steps.
2026-08-16 08:46:30 +03:30
kavehhn 15b699a7c7 feat: add class registration pages for enrollment and class requests.
Wire class cards to payment flows with plan terms, user provisioning, and a disabled pay button until the gateway is ready.
2026-08-16 08:42:25 +03:30
kavehhn 06d091156c Replace active courses with upcoming classes and improve class card UI.
Split the landing page into upcoming and active class sections with colored ClassCard components, and refresh navigation and account class cards to match.
2026-08-16 08:24:01 +03:30
kavehhn f1a7ad8bf8 feat: show public classes with discount, start countdown, and free spots
Add a landing page classes section fed by the public classes API with pricing and availability details.
2026-08-16 08:11:42 +03:30
kavehhn d979205efb feat(classes): show class days and meeting time on my classes
Surface the stored class schedule so students can see when their class meets.
2026-08-15 23:59:08 +03:30
kavehhn 3ef45964c6 fix: display user full name from single name field
Align account display with the merged name schema that no longer uses surname.
2026-08-15 01:31:47 +03:30
kavehhn 44b1f019f6 fix(vite): allow game-no.ir domain in allowedHosts 2026-08-13 12:09:00 +03:30
kavehhn 2a3444e622 chore: remove HEALTHCHECK from Dockerfile 2026-08-13 11:36:00 +03:30
kavehhn 356aae6e24 refactor: remove Nginx and serve application via Node + Vite preview 2026-08-13 11:34:49 +03:30
16 changed files with 1769 additions and 248 deletions
+5 -44
View File
@@ -1,9 +1,5 @@
# Multi-stage production Dockerfile for gameno-front (Vue 3 + Vite SPA)
# ------------------------------------------------------------------------------
# Stage 1: Build the Vite static application
# ------------------------------------------------------------------------------
FROM node:22-alpine AS build
# Production Dockerfile for gameno-front (Node + Vite)
FROM node:22-alpine
WORKDIR /app
@@ -18,44 +14,9 @@ COPY . .
ARG VITE_API_BASE_URL
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
# Build static assets to /app/dist
# Build static application
RUN npm run build
# ------------------------------------------------------------------------------
# Stage 2: Production web server serving static files via Nginx
# ------------------------------------------------------------------------------
FROM nginx:1.27-alpine AS production
EXPOSE 5174
# Inline Nginx configuration for SPA routing, gzip, caching, and healthcheck
RUN printf 'server {\n\
listen 80;\n\
server_name localhost;\n\
root /usr/share/nginx/html;\n\
index index.html;\n\
gzip on;\n\
gzip_types text/plain text/css text/javascript application/javascript application/json image/svg+xml;\n\
location ~ ^/(healthz|health)$ {\n\
access_log off;\n\
default_type text/plain;\n\
return 200 "OK";\n\
}\n\
location / {\n\
try_files $uri $uri/ /index.html;\n\
}\n\
location ~* \\.(?:css|js|jpg|jpeg|gif|png|ico|svg|woff|woff2|ttf)$ {\n\
expires 1y;\n\
access_log off;\n\
add_header Cache-Control "public, immutable";\n\
}\n\
}\n' > /etc/nginx/conf.d/default.conf
# Copy compiled static assets from build stage
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
# Health check endpoint for Docker & Coolify targeting /healthz
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:80/healthz || exit 1
CMD ["nginx", "-g", "daemon off;"]
CMD ["npx", "vite", "preview", "--host", "0.0.0.0", "--port", "5174"]
+2
View File
@@ -1,5 +1,7 @@
import axiosInstance from './axiosInstance'
export const classesApi = {
getAll: (params = {}) => axiosInstance.get('/classes/user/get-all', { params }),
getOne: (id) => axiosInstance.get(`/classes/user/get-one/${id}`),
getMyClasses: (params = {}) => axiosInstance.get('/classes/user/my-classes', { params })
}
+8
View File
@@ -0,0 +1,8 @@
import axiosInstance from './axiosInstance'
export const registrationApi = {
getClass: (id) => axiosInstance.get(`/classes/user/get-one/${id}`),
getPricing: (params) => axiosInstance.get('/pending-students/public/pricing', { params }),
ensureUser: (data) => axiosInstance.post('/pending-students/public/ensure-user', data),
completeAfterPayment: (data) => axiosInstance.post('/pending-students/public/complete', data)
}
+367
View File
@@ -0,0 +1,367 @@
<template>
<article
class="class-card"
:class="[`class-card--${variant}`, `class-card--tone-${tone}`]"
>
<div class="class-card-accent" aria-hidden="true"></div>
<div class="class-card-inner">
<header class="class-card-head">
<span class="class-card-course">{{ cls.course?.title || 'کلاس' }}</span>
<span class="class-card-badge" :class="badgeClass">{{ badgeLabel }}</span>
</header>
<h3 class="class-card-title">{{ cls.name }}</h3>
<p v-if="cls.professor" class="class-card-professor">
<span class="class-card-icon" aria-hidden="true"></span>
{{ cls.professor.name }} {{ cls.professor.surname || '' }}
</p>
<ul class="class-card-stats">
<li v-if="daysLabel">
<span class="stat-icon" aria-hidden="true"></span>
{{ daysLabel }}
</li>
<li v-if="cls.startDate">
<span class="stat-icon" aria-hidden="true">📅</span>
شروع {{ formatJalali(cls.startDate) }}
</li>
<li v-if="scheduleLabel">
<span class="stat-icon" aria-hidden="true">🗓</span>
{{ scheduleLabel }}
</li>
<li v-if="cls.freeSpots != null">
<span class="stat-icon" aria-hidden="true">👥</span>
{{ toPersianDigits(cls.freeSpots) }} ظرفیت آزاد
</li>
</ul>
<footer class="class-card-foot">
<div class="price-stack">
<strong>{{ formatPrice(cls.finalTuitionFee ?? cls.tuitionFee) }}</strong>
<span v-if="cls.hasDiscount && cls.discount" class="price-old">
{{ formatPrice(cls.tuitionFee) }}
</span>
</div>
<span v-if="cls.hasDiscount && cls.discount" class="discount-tag">
تخفیف {{ formatPrice(cls.discount) }}
</span>
</footer>
<RouterLink :to="ctaTo" class="class-card-cta">{{ ctaLabel }}</RouterLink>
</div>
</article>
</template>
<script setup>
import { computed } from 'vue'
import { RouterLink } from 'vue-router'
import {
formatJalali,
formatPrice,
formatDaysUntilStart,
toPersianDigits
} from '@/utils/format'
const WEEKDAY_LABELS = {
0: 'یکشنبه',
1: 'دوشنبه',
2: 'سه‌شنبه',
3: 'چهارشنبه',
4: 'پنجشنبه',
5: 'جمعه',
6: 'شنبه'
}
const IRAN_WEEK_ORDER = [6, 0, 1, 2, 3, 4, 5]
const props = defineProps({
cls: { type: Object, required: true },
variant: {
type: String,
default: 'upcoming',
validator: (v) => ['upcoming', 'active'].includes(v)
},
tone: {
type: Number,
default: 0
}
})
const daysLabel = computed(() => formatDaysUntilStart(props.cls.daysUntilStart))
const scheduleLabel = computed(() => {
const days = formatClassDays(props.cls.days)
const time = formatClassTime(props.cls.startTime, props.cls.endTime)
if (days === '—' && time === '—') return null
if (days !== '—' && time !== '—') return `${days} · ${time}`
return days !== '—' ? days : time
})
const badgeLabel = computed(() => (props.variant === 'upcoming' ? 'پیش‌رو' : 'در حال برگزاری'))
const badgeClass = computed(() =>
props.variant === 'upcoming' ? 'badge-upcoming' : 'badge-active'
)
const ctaLabel = computed(() =>
props.variant === 'upcoming' ? 'رزرو جایگاه' : 'درخواست تشکیل کلاس جدید'
)
const ctaTo = computed(() =>
props.variant === 'upcoming'
? { name: 'register-upcoming', params: { classId: props.cls._id } }
: { name: 'register-request', params: { classId: props.cls._id } }
)
function joinFaList(items) {
if (!items.length) return '—'
if (items.length === 1) return items[0]
if (items.length === 2) return `${items[0]} و ${items[1]}`
return `${items.slice(0, -1).join('، ')} و ${items[items.length - 1]}`
}
function formatClassDays(days = []) {
const unique = [...new Set((days || []).map(Number).filter((day) => day >= 0 && day <= 6))]
unique.sort((a, b) => IRAN_WEEK_ORDER.indexOf(a) - IRAN_WEEK_ORDER.indexOf(b))
return joinFaList(unique.map((day) => WEEKDAY_LABELS[day]).filter(Boolean))
}
function formatClassTime(startTime, endTime) {
const start = String(startTime || '').trim()
const end = String(endTime || '').trim()
if (!start && !end) return '—'
if (!end) return start
if (!start) return end
return `${start} ${end}`
}
</script>
<style scoped>
.class-card {
position: relative;
border-radius: 1.25rem;
overflow: hidden;
background: var(--chalk);
border: 1px solid rgba(20, 53, 43, 0.07);
box-shadow: 0 12px 32px rgba(20, 53, 43, 0.06);
transition: transform 0.3s var(--ease), box-shadow 0.3s var(--ease);
}
.class-card:hover {
transform: translateY(-5px);
box-shadow: var(--shadow);
}
.class-card-accent {
height: 5px;
width: 100%;
}
.class-card--upcoming.class-card--tone-0 .class-card-accent {
background: linear-gradient(90deg, var(--saffron), #f5c842);
}
.class-card--upcoming.class-card--tone-1 .class-card-accent {
background: linear-gradient(90deg, #3d8b66, #52a87d);
}
.class-card--upcoming.class-card--tone-2 .class-card-accent {
background: linear-gradient(90deg, #5b7fd4, #7a9ae8);
}
.class-card--active.class-card--tone-0 .class-card-accent {
background: linear-gradient(90deg, var(--leaf), var(--leaf-bright));
}
.class-card--active.class-card--tone-1 .class-card-accent {
background: linear-gradient(90deg, #2a6b5a, #3d8b78);
}
.class-card--active.class-card--tone-2 .class-card-accent {
background: linear-gradient(90deg, #4a7c59, #6b9f7a);
}
.class-card--upcoming.class-card--tone-0 {
background: linear-gradient(165deg, #fffdf6 0%, var(--chalk) 45%);
}
.class-card--upcoming.class-card--tone-1 {
background: linear-gradient(165deg, #f3faf6 0%, var(--chalk) 45%);
}
.class-card--upcoming.class-card--tone-2 {
background: linear-gradient(165deg, #f4f7fd 0%, var(--chalk) 45%);
}
.class-card--active.class-card--tone-0 {
background: linear-gradient(165deg, #eef7f1 0%, var(--chalk) 45%);
}
.class-card--active.class-card--tone-1 {
background: linear-gradient(165deg, #eaf4ef 0%, var(--chalk) 45%);
}
.class-card--active.class-card--tone-2 {
background: linear-gradient(165deg, #e8f2eb 0%, var(--chalk) 45%);
}
.class-card-inner {
padding: 1.25rem 1.3rem 1.35rem;
}
.class-card-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.65rem;
}
.class-card-course {
font-size: 0.78rem;
font-weight: 700;
color: var(--ink-soft);
padding: 0.2rem 0.6rem;
border-radius: 999px;
background: rgba(20, 53, 43, 0.06);
}
.class-card-badge {
font-size: 0.72rem;
font-weight: 800;
padding: 0.22rem 0.6rem;
border-radius: 999px;
white-space: nowrap;
}
.badge-upcoming {
background: rgba(232, 163, 23, 0.2);
color: var(--saffron-deep);
}
.badge-active {
background: rgba(45, 106, 79, 0.16);
color: var(--leaf);
}
.class-card-title {
font-size: 1.18rem;
margin-bottom: 0.45rem;
line-height: 1.35;
}
.class-card-professor {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.88rem;
color: var(--ink-soft);
margin-bottom: 0.85rem;
}
.class-card-icon {
color: var(--saffron);
font-size: 0.55rem;
}
.class-card--active .class-card-icon {
color: var(--leaf);
}
.class-card-stats {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.class-card-stats li {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.82rem;
color: var(--ink-soft);
padding: 0.35rem 0.65rem;
border-radius: 0.65rem;
background: rgba(20, 53, 43, 0.04);
}
.class-card--upcoming .class-card-stats li:nth-child(1) {
background: rgba(232, 163, 23, 0.12);
color: var(--saffron-deep);
font-weight: 700;
}
.class-card--active .class-card-stats li:nth-child(1) {
background: rgba(45, 106, 79, 0.12);
color: var(--leaf);
font-weight: 700;
}
.stat-icon {
font-size: 0.85rem;
line-height: 1;
opacity: 0.85;
}
.class-card-foot {
margin-top: 1rem;
padding-top: 0.95rem;
border-top: 1px dashed rgba(20, 53, 43, 0.12);
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 0.75rem;
}
.price-stack {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.price-stack strong {
font-size: 1.05rem;
color: var(--ink);
}
.price-old {
font-size: 0.8rem;
color: var(--ink-soft);
text-decoration: line-through;
}
.discount-tag {
font-size: 0.75rem;
font-weight: 700;
padding: 0.25rem 0.55rem;
border-radius: 999px;
background: rgba(232, 163, 23, 0.18);
color: var(--saffron-deep);
}
.class-card-cta {
display: inline-flex;
margin-top: 0.9rem;
font-size: 0.88rem;
font-weight: 800;
padding: 0.55rem 1rem;
border-radius: 999px;
transition: background 0.2s ease, color 0.2s ease, transform 0.2s ease;
}
.class-card--upcoming .class-card-cta {
background: rgba(232, 163, 23, 0.15);
color: var(--saffron-deep);
}
.class-card--upcoming .class-card-cta:hover {
background: var(--saffron);
color: var(--deep);
}
.class-card--active .class-card-cta {
background: rgba(45, 106, 79, 0.12);
color: var(--leaf);
}
.class-card--active .class-card-cta:hover {
background: var(--leaf);
color: var(--chalk);
}
</style>
+2 -1
View File
@@ -7,7 +7,8 @@
</div>
<div class="footer-links">
<RouterLink to="/login">ورود</RouterLink>
<a href="/#courses">دورهها</a>
<a href="/#upcoming-classes">کلاسهای پیشرو</a>
<a href="/#active-classes">کلاسهای فعال</a>
<a href="/#contact">تماس با ما</a>
<RouterLink to="/account">پنل کاربری</RouterLink>
</div>
+2 -1
View File
@@ -9,7 +9,8 @@
</RouterLink>
<nav class="header-nav" aria-label="منوی اصلی">
<a href="/#courses">دورهها</a>
<a href="/#upcoming-classes">کلاسهای پیشرو</a>
<a href="/#active-classes">کلاسهای فعال</a>
<a href="/#path">مسیر یادگیری</a>
<a href="/#contact">تماس با ما</a>
<RouterLink v-if="auth.isAuthenticated" :to="{ name: 'account' }">حساب من</RouterLink>
+1 -2
View File
@@ -35,8 +35,7 @@ const router = useRouter()
const initials = computed(() => {
const name = auth.user?.name || ''
const surname = auth.user?.surname || ''
return `${name.charAt(0)}${surname.charAt(0)}` || 'گ'
return name.charAt(0) || 'گ'
})
async function handleLogout() {
+6 -2
View File
@@ -4,14 +4,18 @@
<main>
<RouterView />
</main>
<SiteFooter />
<SiteFooter v-if="!hideFooter" />
</div>
</template>
<script setup>
import { RouterView } from 'vue-router'
import { computed } from 'vue'
import { RouterView, useRoute } from 'vue-router'
import SiteHeader from '@/components/SiteHeader.vue'
import SiteFooter from '@/components/SiteFooter.vue'
const route = useRoute()
const hideFooter = computed(() => Boolean(route.meta.hideFooter))
</script>
<style scoped>
+12
View File
@@ -17,6 +17,18 @@ const routes = [
name: 'login',
component: () => import('@/views/LoginView.vue'),
meta: { title: 'ورود | گام نو', guestOnly: true }
},
{
path: 'register/upcoming/:classId',
name: 'register-upcoming',
component: () => import('@/views/ClassRegistrationView.vue'),
meta: { title: 'ثبت‌نام در کلاس | گام نو', registrationType: 'enrollment', hideFooter: true }
},
{
path: 'register/request/:classId',
name: 'register-request',
component: () => import('@/views/ClassRegistrationView.vue'),
meta: { title: 'درخواست تشکیل کلاس | گام نو', registrationType: 'class_request', hideFooter: true }
}
]
},
+1 -1
View File
@@ -11,7 +11,7 @@ export const useAuthStore = defineStore('auth', () => {
const isAuthenticated = computed(() => !!accessToken.value)
const fullName = computed(() => {
if (!user.value) return ''
return `${user.value.name || ''} ${user.value.surname || ''}`.trim()
return `${user.value.name || ''}`.trim()
})
function setTokens(access, refresh) {
+8
View File
@@ -17,6 +17,14 @@ export function formatPrice(amount) {
return `${toPersianDigits(Number(amount).toLocaleString('en-US'))} تومان`
}
export function formatDaysUntilStart(days) {
if (days == null || Number.isNaN(Number(days))) return null
const value = Number(days)
if (value > 0) return `${toPersianDigits(value)} روز تا شروع`
if (value === 0) return 'امروز شروع می‌شود'
return `${toPersianDigits(Math.abs(value))} روز از شروع گذشته`
}
export function getApiErrorMessage(error, fallback = 'خطایی رخ داد. دوباره تلاش کنید.') {
return (
error?.response?.data?.error?.message ||
+83
View File
@@ -0,0 +1,83 @@
export const FULL_PAYMENT_DISCOUNT = 500_000
export const CLASS_REQUEST_DEPOSIT_RATE = 0.10
export function getFinalTuition(cls = {}) {
const tuitionFee = Math.max(0, Number(cls.tuitionFee) || 0)
const classDiscount = cls.hasDiscount ? Math.max(0, Number(cls.discount) || 0) : 0
return Math.max(0, tuitionFee - Math.min(classDiscount, tuitionFee))
}
export function calculateMidpointDueDate(cls = {}, course = {}) {
const startDate = cls.startDate ? new Date(cls.startDate) : null
if (!startDate || Number.isNaN(startDate.getTime())) return null
const sectionCount = Math.max(1, Number(course.sectionCount) || 1)
const sessionsPerWeek = Math.max(1, (cls.days || []).length || 1)
const totalWeeks = sectionCount / sessionsPerWeek
const weeksUntilDue = totalWeeks / 2
const due = new Date(startDate)
due.setDate(due.getDate() + Math.round(weeksUntilDue * 7))
return due
}
export function calculateEnrollmentPricing(cls = {}, course = {}, paymentPlan = 'full') {
const tuitionFee = getFinalTuition(cls)
if (paymentPlan === 'installments') {
const totalAmount = tuitionFee
const amountDueNow = Math.ceil(totalAmount / 2)
const secondInstallmentAmount = Math.max(0, totalAmount - amountDueNow)
return {
type: 'enrollment',
paymentPlan: 'installments',
tuitionFee,
classDiscount: cls.hasDiscount ? Math.max(0, Number(cls.discount) || 0) : 0,
paymentDiscount: 0,
totalAmount,
amountDueNow,
secondInstallmentAmount,
secondInstallmentDueDate: calculateMidpointDueDate(cls, course)
}
}
const paymentDiscount = Math.min(FULL_PAYMENT_DISCOUNT, tuitionFee)
const totalAmount = Math.max(0, tuitionFee - paymentDiscount)
return {
type: 'enrollment',
paymentPlan: 'full',
tuitionFee,
classDiscount: cls.hasDiscount ? Math.max(0, Number(cls.discount) || 0) : 0,
paymentDiscount,
totalAmount,
amountDueNow: totalAmount,
secondInstallmentAmount: 0,
secondInstallmentDueDate: null
}
}
export function calculateClassRequestPricing(cls = {}) {
const tuitionFee = getFinalTuition(cls)
const amountDueNow = Math.ceil(tuitionFee * CLASS_REQUEST_DEPOSIT_RATE)
return {
type: 'class_request',
paymentPlan: 'deposit',
tuitionFee,
classDiscount: cls.hasDiscount ? Math.max(0, Number(cls.discount) || 0) : 0,
paymentDiscount: 0,
totalAmount: amountDueNow,
amountDueNow,
secondInstallmentAmount: 0,
secondInstallmentDueDate: null
}
}
export function calculateRegistrationPricing(cls = {}, course = {}, { type = 'enrollment', paymentPlan = 'full' } = {}) {
if (type === 'class_request') {
return calculateClassRequestPricing(cls)
}
return calculateEnrollmentPricing(cls, course, paymentPlan)
}
+991
View File
@@ -0,0 +1,991 @@
<template>
<div class="registration-page">
<div class="container registration-wrap">
<div v-if="loading" class="state-card loading-block">
<div class="spinner" aria-label="در حال بارگذاری"></div>
</div>
<div v-else-if="loadError" class="state-card alert" role="alert">{{ loadError }}</div>
<div v-else-if="cls" class="registration-card">
<header class="card-topbar">
<RouterLink to="/" class="back-link">
<span aria-hidden="true"></span>
بازگشت
</RouterLink>
<div class="card-topbar-title">
<p class="page-eyebrow">{{ isClassRequest ? 'درخواست تشکیل کلاس' : 'ثبت‌نام در کلاس' }}</p>
<h1>{{ pageTitle }}</h1>
</div>
<p v-if="cls.course?.title" class="card-topbar-course">{{ cls.course.title }}</p>
</header>
<div class="card-columns">
<aside class="summary-panel">
<div class="summary-inner">
<p class="summary-label">خلاصه کلاس</p>
<h2 class="summary-title">{{ cls.name }}</h2>
<dl class="summary-list">
<div v-if="cls.professor" class="summary-item">
<dt>استاد</dt>
<dd>{{ cls.professor.name }} {{ cls.professor.surname || '' }}</dd>
</div>
<div v-if="cls.startDate" class="summary-item">
<dt>شروع</dt>
<dd>{{ formatJalali(cls.startDate) }}</dd>
</div>
<div class="summary-item">
<dt>شهریه</dt>
<dd>{{ formatPrice(pricing.tuitionFee) }}</dd>
</div>
</dl>
<div v-if="step === 2" class="summary-total">
<span>مبلغ این مرحله</span>
<strong>{{ formatPrice(activePricing.amountDueNow) }}</strong>
</div>
</div>
</aside>
<section class="form-panel">
<nav class="stepper" aria-label="مراحل ثبت‌نام">
<div class="stepper-item" :class="{ active: step === 1, done: step > 1 }">
<span class="stepper-dot">{{ step > 1 ? '✓' : '۱' }}</span>
<span class="stepper-label">اطلاعات شما</span>
</div>
<span class="stepper-line" aria-hidden="true"></span>
<div class="stepper-item" :class="{ active: step === 2 }">
<span class="stepper-dot">۲</span>
<span class="stepper-label">پرداخت</span>
</div>
</nav>
<!-- Step 1 -->
<div v-if="step === 1" class="step-content">
<div class="step-header">
<h2>اطلاعات تماس</h2>
<p>
برای ادامه، مشخصات خود را وارد کنید. اگر حساب کاربری ندارید، بهصورت خودکار ساخته
میشود و نام کاربری و رمز عبور از طریق پیامک برایتان ارسال میگردد.
</p>
</div>
<div v-if="formError" class="alert" role="alert">{{ formError }}</div>
<form class="reg-form" novalidate @submit.prevent="handleInfoSubmit">
<div class="fields-grid">
<div class="field">
<label for="reg-name">نام <span class="req">*</span></label>
<input
id="reg-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="reg-surname">نام خانوادگی <span class="req">*</span></label>
<input
id="reg-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="reg-national-id">کد ملی <span class="req">*</span></label>
<input
id="reg-national-id"
v-model.trim="form.nationalIdCode"
type="text"
inputmode="numeric"
maxlength="10"
autocomplete="off"
placeholder="۱۰ رقم"
dir="ltr"
class="input-ltr"
/>
<span v-if="errors.nationalIdCode" class="field-error">{{ errors.nationalIdCode }}</span>
</div>
<div class="field">
<label for="reg-phone">شماره موبایل <span class="req">*</span></label>
<input
id="reg-phone"
v-model.trim="form.phoneNumber"
type="tel"
inputmode="tel"
autocomplete="tel"
placeholder="۰۹۱۲۱۲۳۴۵۶۷"
dir="ltr"
class="input-ltr"
/>
<span v-if="errors.phoneNumber" class="field-error">{{ errors.phoneNumber }}</span>
</div>
<div class="field field-wide">
<label for="reg-email">ایمیل <span class="optional">(اختیاری)</span></label>
<input
id="reg-email"
v-model.trim="form.email"
type="email"
autocomplete="email"
placeholder="example@email.com"
dir="ltr"
class="input-ltr"
/>
<span v-if="errors.email" class="field-error">{{ errors.email }}</span>
</div>
</div>
<div class="form-actions">
<button class="btn btn-ink btn-block" type="submit" :disabled="infoSubmitting">
<span v-if="infoSubmitting" class="spinner" aria-hidden="true"></span>
{{ infoSubmitting ? 'در حال ثبت' : 'ادامه به پرداخت' }}
</button>
</div>
</form>
</div>
<!-- Step 2 -->
<div v-else class="step-content">
<div class="step-header">
<h2>{{ isClassRequest ? 'پرداخت بیعانه' : 'انتخاب روش پرداخت' }}</h2>
<p v-if="!isClassRequest">
یکی از روش‌های زیر را انتخاب کنید. پرداخت یک‌جا شامل
{{ formatPrice(FULL_PAYMENT_DISCOUNT) }} تخفیف است.
</p>
<p v-else>
برای ثبت درخواست تشکیل کلاس جدید، بیعانه پرداخت کنید. این مبلغ تا زمان تأیید
نهایی قطعی نیست.
</p>
</div>
<div v-if="!isClassRequest" class="plan-options" role="radiogroup" aria-label="روش پرداخت">
<label class="plan-card" :class="{ selected: paymentPlan === 'full' }">
<input v-model="paymentPlan" type="radio" value="full" />
<span class="plan-radio" aria-hidden="true"></span>
<div class="plan-content">
<div class="plan-top">
<strong>پرداخت یکجا</strong>
<span class="plan-badge">پیشنهادی</span>
</div>
<p>
با پرداخت کامل شهریه،
<span class="highlight">{{ formatPrice(FULL_PAYMENT_DISCOUNT) }}</span>
تخفیف دریافت میکنید.
</p>
<div class="plan-footer">
<span class="plan-price">{{ formatPrice(fullPricing.totalAmount) }}</span>
</div>
</div>
</label>
<label class="plan-card" :class="{ selected: paymentPlan === 'installments' }">
<input v-model="paymentPlan" type="radio" value="installments" />
<span class="plan-radio" aria-hidden="true"></span>
<div class="plan-content">
<div class="plan-top">
<strong>پرداخت دو قسطی</strong>
</div>
<p>
شهریه کامل بدون تخفیف. نیمی از مبلغ اکنون و قسط دوم در
{{ secondInstallmentDueLabel }} سررسید میشود.
</p>
<div class="plan-footer">
<span class="plan-price">{{ formatPrice(installmentPricing.amountDueNow) }}</span>
<span class="plan-note">اکنون · باقیمانده {{ formatPrice(installmentPricing.secondInstallmentAmount) }}</span>
</div>
</div>
</label>
</div>
<div v-else class="terms-box">
<h3>شرایط درخواست</h3>
<ul>
<li>
بیعانه معادل
{{ toPersianDigits(Math.round(CLASS_REQUEST_DEPOSIT_RATE * 100)) }}٪ شهریه:
<strong>{{ formatPrice(requestPricing.amountDueNow) }}</strong>
</li>
<li>اگر تا یک ماه آینده کلاس تشکیل نشود، کل بیعانه بدون کسر بازگردانده میشود.</li>
<li>در صورت انصراف شخصی، بخشی از بیعانه (طبق قوانین آموزشگاه) کسر میشود.</li>
<li>تا زمان تأیید نهایی توسط آموزشگاه، ثبتنام قطعی نیست.</li>
</ul>
</div>
<div class="checkout-box">
<div class="checkout-row checkout-row--total">
<span>مبلغ قابل پرداخت</span>
<strong>{{ formatPrice(activePricing.amountDueNow) }}</strong>
</div>
<div v-if="!isClassRequest && paymentPlan === 'full'" class="checkout-row">
<span>تخفیف پرداخت یکجا</span>
<span class="checkout-discount"> {{ formatPrice(activePricing.paymentDiscount) }}</span>
</div>
<div v-if="!isClassRequest && paymentPlan === 'installments'" class="checkout-row">
<span>قسط دوم · {{ secondInstallmentDueLabel }}</span>
<span>{{ formatPrice(activePricing.secondInstallmentAmount) }}</span>
</div>
</div>
<div class="gateway-banner">
<span class="gateway-icon" aria-hidden="true"></span>
<p>درگاه پرداخت بهزودی فعال میشود. پس از پرداخت، درخواست شما برای بررسی ثبت میشود.</p>
</div>
<div class="form-actions form-actions--split">
<button class="btn btn-ghost" type="button" @click="step = 1">بازگشت</button>
<button
class="btn btn-ink"
type="button"
disabled
title="درگاه پرداخت به‌زودی فعال می‌شود"
>
پرداخت {{ formatPrice(activePricing.amountDueNow) }}
</button>
</div>
</div>
</section>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { registrationApi } from '@/api/registrationApi'
import {
CLASS_REQUEST_DEPOSIT_RATE,
FULL_PAYMENT_DISCOUNT,
calculateClassRequestPricing,
calculateEnrollmentPricing
} from '@/utils/registrationPricing'
import { formatJalali, formatPrice, getApiErrorMessage, toPersianDigits } from '@/utils/format'
const route = useRoute()
const isClassRequest = computed(() => route.meta.registrationType === 'class_request')
const pageTitle = computed(() =>
isClassRequest.value ? 'درخواست تشکیل کلاس جدید' : 'ثبت‌نام در کلاس پیش‌رو'
)
const cls = ref(null)
const loading = ref(true)
const loadError = ref('')
const step = ref(1)
const paymentPlan = ref('full')
const userId = ref(null)
const infoSubmitting = ref(false)
const formError = ref('')
const form = reactive({
name: '',
surname: '',
nationalIdCode: '',
phoneNumber: '',
email: ''
})
const errors = reactive({
name: '',
surname: '',
nationalIdCode: '',
phoneNumber: '',
email: ''
})
const fullPricing = computed(() =>
calculateEnrollmentPricing(cls.value || {}, cls.value?.course || {}, 'full')
)
const installmentPricing = computed(() =>
calculateEnrollmentPricing(cls.value || {}, cls.value?.course || {}, 'installments')
)
const requestPricing = computed(() => calculateClassRequestPricing(cls.value || {}))
const activePricing = computed(() => {
if (isClassRequest.value) return requestPricing.value
return paymentPlan.value === 'installments' ? installmentPricing.value : fullPricing.value
})
const pricing = computed(() => activePricing.value)
const secondInstallmentDueLabel = computed(() => {
const due = installmentPricing.value.secondInstallmentDueDate
return due ? formatJalali(due) : 'میانه دوره'
})
function normalizePhone(value) {
const phone = String(value || '').replace(/[\s\-()]/g, '').trim()
if (phone.startsWith('+98')) return `0${phone.slice(3)}`
if (/^9\d{9}$/.test(phone)) return `0${phone}`
return phone
}
function validateInfo() {
errors.name = form.name ? '' : 'نام الزامی است'
errors.surname = form.surname ? '' : 'نام خانوادگی الزامی است'
errors.nationalIdCode = form.nationalIdCode ? '' : 'کد ملی الزامی است'
errors.phoneNumber = form.phoneNumber ? '' : 'شماره موبایل الزامی است'
if (form.nationalIdCode && !/^\d{10}$/.test(form.nationalIdCode)) {
errors.nationalIdCode = 'کد ملی باید ۱۰ رقم باشد'
}
const phone = normalizePhone(form.phoneNumber)
if (form.phoneNumber && !/^09\d{9}$/.test(phone)) {
errors.phoneNumber = 'شماره موبایل معتبر نیست'
}
if (form.email && !/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/.test(form.email)) {
errors.email = 'ایمیل معتبر نیست'
}
return !Object.values(errors).some(Boolean)
}
async function handleInfoSubmit() {
formError.value = ''
if (!validateInfo()) return
infoSubmitting.value = true
try {
const res = await registrationApi.ensureUser({
name: form.name,
surname: form.surname,
nationalIdCode: form.nationalIdCode,
phoneNumber: normalizePhone(form.phoneNumber),
email: form.email || undefined
})
userId.value = res.data?.data?.user?._id || res.data?.user?._id
step.value = 2
} catch (error) {
const details = error?.response?.data?.error?.details
if (details && typeof details === 'object') {
Object.entries(details).forEach(([key, value]) => {
if (key in errors) errors[key] = String(value)
})
}
formError.value = getApiErrorMessage(error, 'ثبت اطلاعات ناموفق بود.')
} finally {
infoSubmitting.value = false
}
}
onMounted(async () => {
const classId = route.params.classId
if (!classId) {
loadError.value = 'کلاس یافت نشد.'
loading.value = false
return
}
try {
const res = await registrationApi.getClass(classId)
cls.value = res.data?.data || res.data
} catch (error) {
loadError.value = getApiErrorMessage(error, 'بارگذاری اطلاعات کلاس ناموفق بود.')
} finally {
loading.value = false
}
})
</script>
<style scoped>
.registration-page {
min-height: calc(100vh - var(--header-h));
padding: calc(var(--header-h) + 0.65rem) 0 2rem;
background:
radial-gradient(circle at 12% 0%, rgba(45, 106, 79, 0.1), transparent 42%),
radial-gradient(circle at 88% 8%, rgba(232, 163, 23, 0.1), transparent 38%),
linear-gradient(180deg, var(--canvas) 0%, var(--canvas-deep) 100%);
}
.registration-wrap {
max-width: 960px;
}
.card-topbar {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 1rem 1.25rem;
padding: 0.85rem 1.5rem;
border-bottom: 1px solid rgba(20, 53, 43, 0.08);
background: rgba(45, 106, 79, 0.03);
}
.card-topbar-title {
min-width: 0;
}
.card-topbar-course {
font-size: 0.85rem;
font-weight: 700;
color: var(--ink-soft);
white-space: nowrap;
}
.card-columns {
display: grid;
grid-template-columns: minmax(220px, 280px) 1fr;
}
.page-head {
max-width: 42rem;
margin-bottom: 2rem;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.82rem;
font-weight: 600;
color: var(--ink-soft);
transition: color 0.2s ease;
white-space: nowrap;
}
.back-link:hover {
color: var(--leaf);
}
.page-eyebrow {
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.02em;
color: var(--saffron-deep);
margin-bottom: 0.15rem;
}
.card-topbar-title h1,
.page-head h1 {
font-family: var(--font-display);
font-size: 1.15rem;
line-height: 1.3;
}
.state-card {
background: var(--chalk);
border: 1px solid rgba(20, 53, 43, 0.08);
border-radius: 1.25rem;
padding: 2.5rem;
box-shadow: var(--shadow);
}
.registration-card {
display: flex;
flex-direction: column;
gap: 0;
max-width: 960px;
margin-inline: auto;
border-radius: 1.25rem;
overflow: hidden;
border: 1px solid rgba(20, 53, 43, 0.08);
box-shadow: var(--shadow);
background: var(--chalk);
}
.summary-panel {
background:
linear-gradient(165deg, rgba(45, 106, 79, 0.08) 0%, rgba(45, 106, 79, 0.02) 100%);
border-left: 1px solid rgba(20, 53, 43, 0.06);
}
.summary-inner {
padding: 1.35rem 1.5rem;
position: sticky;
top: calc(var(--header-h) + 0.5rem);
}
.summary-label {
font-size: 0.75rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--leaf);
margin-bottom: 0.65rem;
}
.summary-title {
font-size: 1.05rem;
line-height: 1.45;
margin-bottom: 1rem;
}
.summary-list {
margin: 0;
display: grid;
gap: 1rem;
}
.summary-item {
display: grid;
gap: 0.2rem;
}
.summary-item dt {
font-size: 0.78rem;
font-weight: 600;
color: var(--ink-soft);
}
.summary-item dd {
margin: 0;
font-size: 0.95rem;
font-weight: 700;
}
.summary-total {
margin-top: 1.75rem;
padding-top: 1.25rem;
border-top: 1px solid rgba(20, 53, 43, 0.1);
display: grid;
gap: 0.35rem;
}
.summary-total span {
font-size: 0.82rem;
color: var(--ink-soft);
}
.summary-total strong {
font-size: 1.2rem;
color: var(--leaf);
}
.form-panel {
padding: 1.35rem 1.75rem 1.5rem;
}
.stepper {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.25rem;
padding-bottom: 1rem;
border-bottom: 1px solid rgba(20, 53, 43, 0.08);
}
.stepper-item {
display: flex;
align-items: center;
gap: 0.55rem;
color: var(--ink-soft);
font-size: 0.875rem;
font-weight: 600;
}
.stepper-item.active {
color: var(--ink);
}
.stepper-item.done {
color: var(--leaf);
}
.stepper-dot {
width: 1.75rem;
height: 1.75rem;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 0.78rem;
font-weight: 800;
background: rgba(20, 53, 43, 0.06);
flex-shrink: 0;
}
.stepper-item.active .stepper-dot {
background: var(--leaf);
color: var(--chalk);
}
.stepper-item.done .stepper-dot {
background: rgba(45, 106, 79, 0.15);
color: var(--leaf);
}
.stepper-line {
flex: 1;
height: 2px;
background: rgba(20, 53, 43, 0.1);
border-radius: 1px;
}
.step-header {
margin-bottom: 1.15rem;
}
.step-header h2 {
font-size: 1.1rem;
margin-bottom: 0.35rem;
}
.step-header p {
color: var(--ink-soft);
font-size: 0.875rem;
line-height: 1.6;
max-width: 36rem;
}
.fields-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem 1.25rem;
}
.field-wide {
grid-column: 1 / -1;
}
.field label {
display: block;
font-size: 0.875rem;
font-weight: 700;
margin-bottom: 0.45rem;
color: var(--ink);
}
.req {
color: var(--saffron-deep);
}
.optional {
font-weight: 500;
color: var(--ink-soft);
font-size: 0.8rem;
}
.field input {
width: 100%;
border: 1px solid rgba(20, 53, 43, 0.14);
border-radius: 0.75rem;
padding: 0.75rem 1rem;
background: #fff;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.field input:focus {
outline: none;
border-color: var(--leaf);
box-shadow: 0 0 0 3px rgba(45, 106, 79, 0.12);
}
.input-ltr {
text-align: left;
}
.field-error {
display: block;
margin-top: 0.35rem;
font-size: 0.8rem;
color: var(--danger);
}
.form-actions {
margin-top: 1.35rem;
padding-top: 0;
}
.form-actions--split {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.plan-options {
display: grid;
gap: 1rem;
margin-bottom: 1.5rem;
}
.plan-card {
position: relative;
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 1.25rem 1.35rem;
border: 2px solid rgba(20, 53, 43, 0.1);
border-radius: 1rem;
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
}
.plan-card:hover {
border-color: rgba(45, 106, 79, 0.35);
}
.plan-card.selected {
border-color: var(--leaf);
background: rgba(45, 106, 79, 0.04);
box-shadow: 0 0 0 1px rgba(45, 106, 79, 0.15);
}
.plan-card input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.plan-radio {
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
border: 2px solid rgba(20, 53, 43, 0.25);
flex-shrink: 0;
margin-top: 0.15rem;
position: relative;
}
.plan-card.selected .plan-radio {
border-color: var(--leaf);
}
.plan-card.selected .plan-radio::after {
content: '';
position: absolute;
inset: 3px;
border-radius: 50%;
background: var(--leaf);
}
.plan-content {
flex: 1;
min-width: 0;
}
.plan-top {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.4rem;
}
.plan-top strong {
font-size: 1rem;
}
.plan-badge {
font-size: 0.7rem;
font-weight: 800;
padding: 0.15rem 0.5rem;
border-radius: 999px;
background: rgba(232, 163, 23, 0.18);
color: var(--saffron-deep);
}
.plan-content p {
margin: 0 0 0.85rem;
font-size: 0.875rem;
color: var(--ink-soft);
line-height: 1.7;
}
.highlight {
color: var(--saffron-deep);
font-weight: 800;
}
.plan-footer {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0.5rem 1rem;
}
.plan-price {
font-size: 1.05rem;
font-weight: 800;
color: var(--ink);
}
.plan-note {
font-size: 0.8rem;
color: var(--ink-soft);
}
.terms-box {
margin-bottom: 1.5rem;
padding: 1.25rem 1.35rem;
border-radius: 1rem;
background: rgba(232, 163, 23, 0.07);
border: 1px solid rgba(232, 163, 23, 0.2);
}
.terms-box h3 {
font-size: 0.95rem;
margin-bottom: 0.75rem;
}
.terms-box ul {
margin: 0;
padding-right: 1.15rem;
color: var(--ink-soft);
font-size: 0.875rem;
line-height: 1.8;
}
.checkout-box {
padding: 1.25rem 1.35rem;
border-radius: 1rem;
background: rgba(20, 53, 43, 0.03);
border: 1px solid rgba(20, 53, 43, 0.08);
margin-bottom: 1.25rem;
}
.checkout-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 0.4rem 0;
font-size: 0.9rem;
color: var(--ink-soft);
}
.checkout-row--total {
padding-bottom: 0.75rem;
margin-bottom: 0.5rem;
border-bottom: 1px dashed rgba(20, 53, 43, 0.12);
font-size: 1rem;
color: var(--ink);
}
.checkout-row--total strong {
font-size: 1.15rem;
color: var(--leaf);
}
.checkout-discount {
color: var(--saffron-deep);
font-weight: 700;
}
.gateway-banner {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem 1.15rem;
margin-bottom: 1.75rem;
border-radius: 0.85rem;
background: rgba(45, 106, 79, 0.06);
border: 1px solid rgba(45, 106, 79, 0.12);
}
.gateway-icon {
font-size: 1.1rem;
line-height: 1.4;
}
.gateway-banner p {
font-size: 0.85rem;
color: var(--ink-soft);
line-height: 1.65;
}
.alert {
margin-bottom: 1.25rem;
padding: 0.85rem 1.1rem;
border-radius: 0.75rem;
background: rgba(180, 35, 24, 0.08);
color: #7a1a12;
font-size: 0.9rem;
}
.loading-block {
display: grid;
place-items: center;
min-height: 10rem;
}
@media (max-width: 820px) {
.card-topbar {
grid-template-columns: 1fr;
gap: 0.5rem;
padding: 0.85rem 1.25rem;
}
.card-topbar-course {
white-space: normal;
}
.card-columns {
grid-template-columns: 1fr;
}
.registration-card {
max-width: 520px;
}
.summary-panel {
border-left: none;
border-bottom: 1px solid rgba(20, 53, 43, 0.08);
}
.summary-inner {
position: static;
padding: 1.5rem 1.75rem 1.25rem;
}
.summary-title {
margin-bottom: 1rem;
}
.summary-list {
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
}
.summary-total {
margin-top: 1.25rem;
padding-top: 1rem;
}
.form-panel {
padding: 1.75rem 1.5rem 2rem;
}
.stepper-label {
font-size: 0.8rem;
}
}
@media (max-width: 560px) {
.registration-page {
padding-top: calc(var(--header-h) + 0.4rem);
}
.fields-grid {
grid-template-columns: 1fr;
gap: 1.1rem;
}
.summary-list {
grid-template-columns: 1fr;
}
.form-actions--split {
flex-direction: column-reverse;
align-items: stretch;
}
.form-actions--split .btn {
width: 100%;
justify-content: center;
}
.stepper {
gap: 0.5rem;
}
.stepper-label {
display: none;
}
}
</style>
+100 -154
View File
@@ -24,7 +24,7 @@
>
{{ auth.isAuthenticated ? 'ورود به پنل کاربری' : 'ورود به حساب' }}
</RouterLink>
<a href="#courses" class="btn btn-ghost">مشاهده دورهها</a>
<a href="#upcoming-classes" class="btn btn-ghost">کلاسهای پیشرو</a>
<a href="#contact" class="btn btn-ghost">درخواست مشاوره</a>
</div>
@@ -34,55 +34,60 @@
</div>
</section>
<section id="courses" class="section courses-section">
<section id="upcoming-classes" class="section upcoming-section">
<div class="container">
<header class="section-head">
<h2>دورههای فعال</h2>
<p>از میان دورههای جاری انتخاب کنید و از پنل کاربری کلاسهای ثبتشدهتان را ببینید.</p>
<p class="section-eyebrow">ثبتنام باز</p>
<h2>کلاسهای پیشرو</h2>
<p>کلاسهایی که به زودی شروع میشوند ظرفیت، زمان و شهریه را ببینید و برای ثبتنام درخواست مشاوره دهید.</p>
</header>
<div v-if="coursesLoading" class="loading-block">
<div v-if="classesLoading" class="loading-block">
<div class="spinner" aria-label="در حال بارگذاری"></div>
</div>
<div v-else-if="!courses.length" class="empty-state">
<h3>هنوز دورهای ثبت نشده</h3>
<p>به زودی فهرست دورهها اینجا نمایش داده میشود.</p>
<div v-else-if="!upcomingClasses.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>
<div v-else class="class-rail">
<ClassCard
v-for="(cls, index) in upcomingClasses"
:key="cls._id"
:cls="cls"
variant="upcoming"
:tone="index % 3"
/>
</div>
</div>
</section>
<ul v-if="course.highlights?.length" class="course-highlights">
<li v-for="(item, idx) in course.highlights" :key="idx">{{ item }}</li>
</ul>
<section id="active-classes" class="section active-section">
<div class="container">
<header class="section-head">
<p class="section-eyebrow">در حال برگزاری</p>
<h2>کلاسهای فعال</h2>
<p>کلاسهای در حال برگزاری برای تشکیل کلاس جدید همان دوره، درخواست دهید و بیعانه پرداخت کنید.</p>
</header>
<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 v-if="classesLoading" class="loading-block">
<div class="spinner" aria-label="در حال بارگذاری"></div>
</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 v-else-if="!activeClasses.length" class="empty-state">
<h3>فعلاً کلاس فعالی برای نمایش نیست</h3>
<p>کلاسهای در حال برگزاری به محض شروع اینجا نمایش داده میشوند.</p>
</div>
<div v-else class="class-rail">
<ClassCard
v-for="(cls, index) in activeClasses"
:key="cls._id"
:cls="cls"
variant="active"
:tone="index % 3"
/>
</div>
</div>
</section>
@@ -128,25 +133,47 @@
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router'
import ClassCard from '@/components/ClassCard.vue'
import ContactInquiryForm from '@/components/ContactInquiryForm.vue'
import { coursesApi } from '@/api/coursesApi'
import { classesApi } from '@/api/classesApi'
import { useAuthStore } from '@/stores/authStore'
import { formatPrice, toPersianDigits } from '@/utils/format'
const auth = useAuthStore()
const courses = ref([])
const coursesLoading = ref(true)
const allClasses = ref([])
const classesLoading = ref(true)
const todayStart = () => {
const d = new Date()
d.setHours(0, 0, 0, 0)
return d.getTime()
}
const upcomingClasses = computed(() =>
allClasses.value.filter((cls) => {
if (cls.daysUntilStart == null) return false
return cls.daysUntilStart > 0
})
)
const activeClasses = computed(() =>
allClasses.value.filter((cls) => {
if (cls.daysUntilStart == null) return cls.isActive !== false
if (cls.daysUntilStart > 0) return false
if (!cls.endDate) return true
return new Date(cls.endDate).getTime() >= todayStart()
})
)
onMounted(async () => {
try {
const res = await coursesApi.getAll({ limit: 50, sort: '-createdAt' })
courses.value = res.data || []
const res = await classesApi.getAll({ limit: 50 })
allClasses.value = res.data || []
} catch {
courses.value = []
allClasses.value = []
} finally {
coursesLoading.value = false
classesLoading.value = false
}
})
</script>
@@ -280,6 +307,21 @@ onMounted(async () => {
max-width: 36rem;
}
.section-eyebrow {
font-size: 0.82rem;
font-weight: 800;
letter-spacing: 0.06em;
margin-bottom: 0.45rem;
}
.upcoming-section .section-eyebrow {
color: var(--saffron-deep);
}
.active-section .section-eyebrow {
color: var(--leaf);
}
.section-head h2,
.path-copy h2,
.cta-panel h2 {
@@ -294,118 +336,22 @@ onMounted(async () => {
color: var(--ink-soft);
}
.courses-section {
.upcoming-section {
background:
radial-gradient(ellipse at 10% 0%, rgba(232, 163, 23, 0.08), transparent 50%),
linear-gradient(180deg, var(--canvas) 0%, #e8f0ea 100%);
}
.course-rail {
.active-section {
background:
radial-gradient(ellipse at 90% 0%, rgba(45, 106, 79, 0.08), transparent 50%),
var(--chalk);
}
.class-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);
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.35rem;
}
.path-section {
+174 -42
View File
@@ -18,42 +18,65 @@
<div v-else-if="!classes.length" class="empty-state">
<h3>هنوز کلاسی ندارید</h3>
<p>پس از ثبتنام در کلاس توسط آموزشگاه، اینجا نمایش داده میشود.</p>
<RouterLink to="/#courses" class="btn btn-outline">مشاهده دورهها</RouterLink>
<RouterLink to="/#upcoming-classes" 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>
<article
v-for="(item, index) in classes"
:key="item._id"
class="class-card"
:class="[
item.isActive ? 'class-card--active' : 'class-card--inactive',
`class-card--tone-${index % 3}`
]"
>
<div class="class-card-accent" aria-hidden="true"></div>
<div class="class-card-body">
<div class="class-top">
<div>
<span class="course-tag">{{ item.course?.title || 'بدون دوره' }}</span>
<h2>{{ item.name }}</h2>
</div>
<span class="status-badge" :class="item.isActive ? 'status-active' : 'status-muted'">
{{ item.isActive ? 'فعال' : 'غیرفعال' }}
</span>
</div>
<dl class="meta-grid">
<div class="meta-item">
<dt>استاد</dt>
<dd>
{{
item.professor
? `${item.professor.name || ''} ${item.professor.surname || ''}`.trim()
: '—'
}}
</dd>
</div>
<div class="meta-item">
<dt>شروع</dt>
<dd>{{ formatJalali(item.startDate) }}</dd>
</div>
<div class="meta-item">
<dt>پایان</dt>
<dd>{{ formatJalali(item.endDate) }}</dd>
</div>
<div class="meta-item">
<dt>روزها</dt>
<dd>{{ formatClassDays(item.days) }}</dd>
</div>
<div class="meta-item">
<dt>ساعت</dt>
<dd>{{ formatClassTime(item.startTime, item.endTime) }}</dd>
</div>
<div class="meta-item meta-item--price">
<dt>شهریه</dt>
<dd>{{ formatPrice(item.finalTuitionFee ?? item.tuitionFee) }}</dd>
</div>
</dl>
</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>
@@ -65,6 +88,39 @@ import { RouterLink } from 'vue-router'
import { classesApi } from '@/api/classesApi'
import { formatJalali, formatPrice, getApiErrorMessage } from '@/utils/format'
const WEEKDAY_LABELS = {
0: 'یکشنبه',
1: 'دوشنبه',
2: 'سه‌شنبه',
3: 'چهارشنبه',
4: 'پنجشنبه',
5: 'جمعه',
6: 'شنبه'
}
const IRAN_WEEK_ORDER = [6, 0, 1, 2, 3, 4, 5]
function joinFaList(items) {
if (!items.length) return '—'
if (items.length === 1) return items[0]
if (items.length === 2) return `${items[0]} و ${items[1]}`
return `${items.slice(0, -1).join('، ')} و ${items[items.length - 1]}`
}
function formatClassDays(days = []) {
const unique = [...new Set((days || []).map(Number).filter((day) => day >= 0 && day <= 6))]
unique.sort((a, b) => IRAN_WEEK_ORDER.indexOf(a) - IRAN_WEEK_ORDER.indexOf(b))
return joinFaList(unique.map((day) => WEEKDAY_LABELS[day]).filter(Boolean))
}
function formatClassTime(startTime, endTime) {
const start = String(startTime || '').trim()
const end = String(endTime || '').trim()
if (!start && !end) return '—'
if (!end) return start
if (!start) return end
return `${start} ${end}`
}
const classes = ref([])
const loading = ref(true)
const error = ref('')
@@ -103,15 +159,53 @@ onMounted(load)
.class-list {
display: grid;
gap: 1rem;
gap: 1.15rem;
}
.class-card {
padding: 1.35rem 1.4rem;
border-radius: 1.2rem;
border-radius: 1.25rem;
overflow: hidden;
background: var(--chalk);
border: 1px solid rgba(20, 53, 43, 0.08);
box-shadow: 0 10px 28px rgba(20, 53, 43, 0.05);
transition: transform 0.25s var(--ease), box-shadow 0.25s var(--ease);
}
.class-card:hover {
transform: translateY(-3px);
box-shadow: var(--shadow);
}
.class-card-accent {
height: 4px;
}
.class-card--active.class-card--tone-0 .class-card-accent {
background: linear-gradient(90deg, var(--leaf), var(--leaf-bright));
}
.class-card--active.class-card--tone-1 .class-card-accent {
background: linear-gradient(90deg, #2a6b5a, #3d8b78);
}
.class-card--active.class-card--tone-2 .class-card-accent {
background: linear-gradient(90deg, #4a7c59, #6b9f7a);
}
.class-card--inactive .class-card-accent {
background: linear-gradient(90deg, #9aa8a0, #b8cfc0);
}
.class-card--active.class-card--tone-0 {
background: linear-gradient(165deg, #eef7f1 0%, var(--chalk) 40%);
}
.class-card--active.class-card--tone-1 {
background: linear-gradient(165deg, #eaf4ef 0%, var(--chalk) 40%);
}
.class-card--active.class-card--tone-2 {
background: linear-gradient(165deg, #e8f2eb 0%, var(--chalk) 40%);
}
.class-card-body {
padding: 1.25rem 1.35rem 1.35rem;
}
.class-top {
@@ -119,6 +213,17 @@ onMounted(load)
justify-content: space-between;
gap: 0.75rem;
align-items: flex-start;
margin-bottom: 1rem;
}
.course-tag {
display: inline-block;
font-size: 0.75rem;
font-weight: 700;
color: var(--ink-soft);
padding: 0.18rem 0.55rem;
border-radius: 999px;
background: rgba(20, 53, 43, 0.06);
margin-bottom: 0.35rem;
}
@@ -126,28 +231,55 @@ onMounted(load)
font-size: 1.2rem;
}
.course-title {
.status-badge {
font-size: 0.72rem;
font-weight: 800;
padding: 0.25rem 0.65rem;
border-radius: 999px;
white-space: nowrap;
}
.status-active {
background: rgba(45, 106, 79, 0.16);
color: var(--leaf);
font-weight: 700;
margin-bottom: 1rem;
}
.status-muted {
background: rgba(20, 53, 43, 0.08);
color: var(--ink-soft);
}
.meta-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.85rem;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
margin: 0;
}
.meta-item {
padding: 0.65rem 0.75rem;
border-radius: 0.75rem;
background: rgba(20, 53, 43, 0.04);
}
.meta-item--price {
background: rgba(45, 106, 79, 0.1);
}
.meta-item--price dd {
color: var(--leaf);
}
.meta-grid dt {
font-size: 0.75rem;
font-size: 0.72rem;
color: var(--ink-soft);
font-weight: 700;
}
.meta-grid dd {
margin: 0.15rem 0 0;
margin: 0.2rem 0 0;
font-weight: 600;
font-size: 0.92rem;
}
@media (max-width: 760px) {
+7 -1
View File
@@ -11,6 +11,12 @@ export default defineConfig({
},
server: {
port: 5174,
strictPort: true
strictPort: true,
allowedHosts: ['game-no.ir', '.game-no.ir']
},
preview: {
port: 5174,
strictPort: true,
allowedHosts: ['game-no.ir', '.game-no.ir']
}
})