Compare commits
13
Commits
ab0c2671ae
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afd729b1ac | ||
|
|
5035879651 | ||
|
|
c61fafeb01 | ||
|
|
8f67f27b36 | ||
|
|
6d76a90d9b | ||
|
|
15b699a7c7 | ||
|
|
06d091156c | ||
|
|
f1a7ad8bf8 | ||
|
|
d979205efb | ||
|
|
3ef45964c6 | ||
|
|
44b1f019f6 | ||
|
|
2a3444e622 | ||
|
|
356aae6e24 |
+8
-47
@@ -1,15 +1,11 @@
|
|||||||
# Multi-stage production Dockerfile for gameno-front (Vue 3 + Vite SPA)
|
# Production Dockerfile for gameno-front (Node + Vite)
|
||||||
|
FROM node:22-alpine
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# Stage 1: Build the Vite static application
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
FROM node:22-alpine AS build
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package descriptors and install dependencies
|
# Copy package descriptors and npm configuration
|
||||||
COPY package*.json ./
|
COPY package*.json .npmrc* ./
|
||||||
RUN npm ci
|
RUN npm ci --registry=https://registry.npmmirror.com
|
||||||
|
|
||||||
# Copy application source code
|
# Copy application source code
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -18,44 +14,9 @@ COPY . .
|
|||||||
ARG VITE_API_BASE_URL
|
ARG VITE_API_BASE_URL
|
||||||
ENV VITE_API_BASE_URL=${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
|
RUN npm run build
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
EXPOSE 5174
|
||||||
# Stage 2: Production web server serving static files via Nginx
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
FROM nginx:1.27-alpine AS production
|
|
||||||
|
|
||||||
# Inline Nginx configuration for SPA routing, gzip, caching, and healthcheck
|
CMD ["npx", "vite", "preview", "--host", "0.0.0.0", "--port", "5174"]
|
||||||
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;"]
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import axiosInstance from './axiosInstance'
|
import axiosInstance from './axiosInstance'
|
||||||
|
|
||||||
export const classesApi = {
|
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 })
|
getMyClasses: (params = {}) => axiosInstance.get('/classes/user/my-classes', { params })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// /src/api/professorApi.js
|
||||||
|
import axiosInstance from './axiosInstance'
|
||||||
|
|
||||||
|
export const professorApi = {
|
||||||
|
getPortal: () => axiosInstance.get('/professors/portal')
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -7,7 +7,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="footer-links">
|
<div class="footer-links">
|
||||||
<RouterLink to="/login">ورود</RouterLink>
|
<RouterLink to="/login">ورود</RouterLink>
|
||||||
<a href="/#courses">دورهها</a>
|
<a href="/#upcoming-classes">کلاسهای پیشرو</a>
|
||||||
|
<a href="/#active-classes">کلاسهای فعال</a>
|
||||||
<a href="/#contact">تماس با ما</a>
|
<a href="/#contact">تماس با ما</a>
|
||||||
<RouterLink to="/account">پنل کاربری</RouterLink>
|
<RouterLink to="/account">پنل کاربری</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
<nav class="header-nav" aria-label="منوی اصلی">
|
<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="/#path">مسیر یادگیری</a>
|
||||||
<a href="/#contact">تماس با ما</a>
|
<a href="/#contact">تماس با ما</a>
|
||||||
<RouterLink v-if="auth.isAuthenticated" :to="{ name: 'account' }">حساب من</RouterLink>
|
<RouterLink v-if="auth.isAuthenticated" :to="{ name: 'account' }">حساب من</RouterLink>
|
||||||
|
|||||||
@@ -12,6 +12,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<nav>
|
<nav>
|
||||||
<RouterLink :to="{ name: 'account' }" class="nav-link">خلاصه</RouterLink>
|
<RouterLink :to="{ name: 'account' }" class="nav-link">خلاصه</RouterLink>
|
||||||
|
<RouterLink v-if="auth.isProfessor" :to="{ name: 'professor-portal' }" class="nav-link professor-link">
|
||||||
|
<span class="professor-icon">🎓</span>
|
||||||
|
میزکار تدریس و اساتید
|
||||||
|
</RouterLink>
|
||||||
<RouterLink :to="{ name: 'my-classes' }" class="nav-link">کلاسهای من</RouterLink>
|
<RouterLink :to="{ name: 'my-classes' }" class="nav-link">کلاسهای من</RouterLink>
|
||||||
<RouterLink :to="{ name: 'my-certificates' }" class="nav-link">گواهینامهها</RouterLink>
|
<RouterLink :to="{ name: 'my-certificates' }" class="nav-link">گواهینامهها</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -35,8 +39,7 @@ const router = useRouter()
|
|||||||
|
|
||||||
const initials = computed(() => {
|
const initials = computed(() => {
|
||||||
const name = auth.user?.name || ''
|
const name = auth.user?.name || ''
|
||||||
const surname = auth.user?.surname || ''
|
return name.charAt(0) || 'گ'
|
||||||
return `${name.charAt(0)}${surname.charAt(0)}` || 'گ'
|
|
||||||
})
|
})
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
@@ -122,6 +125,26 @@ nav {
|
|||||||
color: var(--leaf);
|
color: var(--leaf);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.professor-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: rgba(232, 163, 23, 0.08);
|
||||||
|
border: 1px solid rgba(232, 163, 23, 0.25);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.professor-link:hover,
|
||||||
|
.professor-link.router-link-active {
|
||||||
|
background: linear-gradient(135deg, rgba(232, 163, 23, 0.2), rgba(45, 106, 79, 0.15));
|
||||||
|
color: var(--ink);
|
||||||
|
border-color: var(--saffron);
|
||||||
|
}
|
||||||
|
|
||||||
|
.professor-icon {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.logout-btn {
|
.logout-btn {
|
||||||
margin-top: 1.25rem;
|
margin-top: 1.25rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -4,14 +4,18 @@
|
|||||||
<main>
|
<main>
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</main>
|
</main>
|
||||||
<SiteFooter />
|
<SiteFooter v-if="!hideFooter" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { RouterView } from 'vue-router'
|
import { computed } from 'vue'
|
||||||
|
import { RouterView, useRoute } from 'vue-router'
|
||||||
import SiteHeader from '@/components/SiteHeader.vue'
|
import SiteHeader from '@/components/SiteHeader.vue'
|
||||||
import SiteFooter from '@/components/SiteFooter.vue'
|
import SiteFooter from '@/components/SiteFooter.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const hideFooter = computed(() => Boolean(route.meta.hideFooter))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -17,6 +17,18 @@ const routes = [
|
|||||||
name: 'login',
|
name: 'login',
|
||||||
component: () => import('@/views/LoginView.vue'),
|
component: () => import('@/views/LoginView.vue'),
|
||||||
meta: { title: 'ورود | گام نو', guestOnly: true }
|
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 }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -31,6 +43,12 @@ const routes = [
|
|||||||
component: () => import('@/views/account/AccountHomeView.vue'),
|
component: () => import('@/views/account/AccountHomeView.vue'),
|
||||||
meta: { title: 'پنل کاربری | گام نو' }
|
meta: { title: 'پنل کاربری | گام نو' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'teaching',
|
||||||
|
name: 'professor-portal',
|
||||||
|
component: () => import('@/views/account/ProfessorPortalView.vue'),
|
||||||
|
meta: { title: 'میزکار اساتید و تدریس | گام نو' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'classes',
|
path: 'classes',
|
||||||
name: 'my-classes',
|
name: 'my-classes',
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
const isAuthenticated = computed(() => !!accessToken.value)
|
const isAuthenticated = computed(() => !!accessToken.value)
|
||||||
const fullName = computed(() => {
|
const fullName = computed(() => {
|
||||||
if (!user.value) return ''
|
if (!user.value) return ''
|
||||||
return `${user.value.name || ''} ${user.value.surname || ''}`.trim()
|
return `${user.value.name || ''}`.trim()
|
||||||
|
})
|
||||||
|
const isProfessor = computed(() => {
|
||||||
|
const roleName = user.value?.role?.name || user.value?.role
|
||||||
|
return roleName === 'Professor' || user.value?.isProfessor === true
|
||||||
})
|
})
|
||||||
|
|
||||||
function setTokens(access, refresh) {
|
function setTokens(access, refresh) {
|
||||||
@@ -71,6 +75,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
user,
|
user,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
fullName,
|
fullName,
|
||||||
|
isProfessor,
|
||||||
setTokens,
|
setTokens,
|
||||||
setUser,
|
setUser,
|
||||||
login,
|
login,
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ export function formatPrice(amount) {
|
|||||||
return `${toPersianDigits(Number(amount).toLocaleString('en-US'))} تومان`
|
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 = 'خطایی رخ داد. دوباره تلاش کنید.') {
|
export function getApiErrorMessage(error, fallback = 'خطایی رخ داد. دوباره تلاش کنید.') {
|
||||||
return (
|
return (
|
||||||
error?.response?.data?.error?.message ||
|
error?.response?.data?.error?.message ||
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
+97
-151
@@ -24,7 +24,7 @@
|
|||||||
>
|
>
|
||||||
{{ auth.isAuthenticated ? 'ورود به پنل کاربری' : 'ورود به حساب' }}
|
{{ auth.isAuthenticated ? 'ورود به پنل کاربری' : 'ورود به حساب' }}
|
||||||
</RouterLink>
|
</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>
|
<a href="#contact" class="btn btn-ghost">درخواست مشاوره</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -34,55 +34,60 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="courses" class="section courses-section">
|
<section id="upcoming-classes" class="section upcoming-section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<header class="section-head">
|
<header class="section-head">
|
||||||
<h2>دورههای فعال</h2>
|
<p class="section-eyebrow">ثبتنام باز</p>
|
||||||
<p>از میان دورههای جاری انتخاب کنید و از پنل کاربری کلاسهای ثبتشدهتان را ببینید.</p>
|
<h2>کلاسهای پیشرو</h2>
|
||||||
|
<p>کلاسهایی که به زودی شروع میشوند — ظرفیت، زمان و شهریه را ببینید و برای ثبتنام درخواست مشاوره دهید.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div v-if="coursesLoading" class="loading-block">
|
<div v-if="classesLoading" class="loading-block">
|
||||||
<div class="spinner" aria-label="در حال بارگذاری"></div>
|
<div class="spinner" aria-label="در حال بارگذاری"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="!courses.length" class="empty-state">
|
<div v-else-if="!upcomingClasses.length" class="empty-state">
|
||||||
<h3>هنوز دورهای ثبت نشده</h3>
|
<h3>فعلاً کلاس پیشرویی نیست</h3>
|
||||||
<p>به زودی فهرست دورهها اینجا نمایش داده میشود.</p>
|
<p>به زودی کلاسهای جدید اینجا منتشر میشوند.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="course-rail">
|
<div v-else class="class-rail">
|
||||||
<article v-for="course in courses" :key="course._id" class="course-item">
|
<ClassCard
|
||||||
<div class="course-meta">
|
v-for="(cls, index) in upcomingClasses"
|
||||||
<span class="badge" :class="course.type === 'Private' ? 'badge-gold' : ''">
|
:key="cls._id"
|
||||||
{{ course.type === 'Private' ? 'خصوصی' : 'عمومی' }}
|
:cls="cls"
|
||||||
</span>
|
variant="upcoming"
|
||||||
<span v-if="course.isOfficial" class="badge badge-gold">رسمی</span>
|
:tone="index % 3"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h3>{{ course.title }}</h3>
|
</div>
|
||||||
<p>{{ course.description || 'جزئیات دوره پس از ثبتنام در پنل کاربری در دسترس است.' }}</p>
|
</section>
|
||||||
|
|
||||||
<ul v-if="course.highlights?.length" class="course-highlights">
|
<section id="active-classes" class="section active-section">
|
||||||
<li v-for="(item, idx) in course.highlights" :key="idx">{{ item }}</li>
|
<div class="container">
|
||||||
</ul>
|
<header class="section-head">
|
||||||
|
<p class="section-eyebrow">در حال برگزاری</p>
|
||||||
|
<h2>کلاسهای فعال</h2>
|
||||||
|
<p>کلاسهای در حال برگزاری — برای تشکیل کلاس جدید همان دوره، درخواست دهید و بیعانه پرداخت کنید.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div class="course-stats">
|
<div v-if="classesLoading" class="loading-block">
|
||||||
<span v-if="course.sectionCount">
|
<div class="spinner" aria-label="در حال بارگذاری"></div>
|
||||||
{{ 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>
|
||||||
|
|
||||||
<div class="course-foot">
|
<div v-else-if="!activeClasses.length" class="empty-state">
|
||||||
<strong>{{ formatPrice(course.price) }}</strong>
|
<h3>فعلاً کلاس فعالی برای نمایش نیست</h3>
|
||||||
<span v-if="course.rating">امتیاز {{ toPersianDigits(course.rating) }}</span>
|
<p>کلاسهای در حال برگزاری به محض شروع اینجا نمایش داده میشوند.</p>
|
||||||
</div>
|
</div>
|
||||||
<a href="#contact" class="course-cta">علاقهمندی و مشاوره</a>
|
|
||||||
</article>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -128,25 +133,47 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
|
import ClassCard from '@/components/ClassCard.vue'
|
||||||
import ContactInquiryForm from '@/components/ContactInquiryForm.vue'
|
import ContactInquiryForm from '@/components/ContactInquiryForm.vue'
|
||||||
import { coursesApi } from '@/api/coursesApi'
|
import { classesApi } from '@/api/classesApi'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
import { formatPrice, toPersianDigits } from '@/utils/format'
|
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const courses = ref([])
|
const allClasses = ref([])
|
||||||
const coursesLoading = ref(true)
|
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 () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await coursesApi.getAll({ limit: 50, sort: '-createdAt' })
|
const res = await classesApi.getAll({ limit: 50 })
|
||||||
courses.value = res.data || []
|
allClasses.value = res.data || []
|
||||||
} catch {
|
} catch {
|
||||||
courses.value = []
|
allClasses.value = []
|
||||||
} finally {
|
} finally {
|
||||||
coursesLoading.value = false
|
classesLoading.value = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -280,6 +307,21 @@ onMounted(async () => {
|
|||||||
max-width: 36rem;
|
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,
|
.section-head h2,
|
||||||
.path-copy h2,
|
.path-copy h2,
|
||||||
.cta-panel h2 {
|
.cta-panel h2 {
|
||||||
@@ -294,118 +336,22 @@ onMounted(async () => {
|
|||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.courses-section {
|
.upcoming-section {
|
||||||
background:
|
background:
|
||||||
|
radial-gradient(ellipse at 10% 0%, rgba(232, 163, 23, 0.08), transparent 50%),
|
||||||
linear-gradient(180deg, var(--canvas) 0%, #e8f0ea 100%);
|
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;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
gap: 1.25rem;
|
gap: 1.35rem;
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
.path-section {
|
||||||
|
|||||||
@@ -5,6 +5,17 @@
|
|||||||
<p>کلاسهای ثبتشده و گواهینامههایتان را از اینجا مدیریت کنید.</p>
|
<p>کلاسهای ثبتشده و گواهینامههایتان را از اینجا مدیریت کنید.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div v-if="auth.isProfessor" class="professor-banner">
|
||||||
|
<div class="professor-banner-content">
|
||||||
|
<div class="professor-badge-pill">🎓 پنل ویژه اساتید گام نو</div>
|
||||||
|
<h2>میزکار و پنل تدریس اساتید</h2>
|
||||||
|
<p>مشاهده کلاسهای در حال برگزاری، زمانبندی جلسات، آمار دانشجویان و گزارش و نمودار سهم تدریس شما</p>
|
||||||
|
</div>
|
||||||
|
<RouterLink :to="{ name: 'professor-portal' }" class="professor-cta-btn">
|
||||||
|
ورود به میزکار تدریس ←
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="summary-grid">
|
<div class="summary-grid">
|
||||||
<RouterLink :to="{ name: 'my-classes' }" class="summary-tile">
|
<RouterLink :to="{ name: 'my-classes' }" class="summary-tile">
|
||||||
<span class="tile-label">کلاسهای من</span>
|
<span class="tile-label">کلاسهای من</span>
|
||||||
@@ -72,6 +83,77 @@ onMounted(async () => {
|
|||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.professor-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1.5rem 1.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
background: linear-gradient(135deg, #14352b 0%, #1f4e3f 60%, #2d6a4f 100%);
|
||||||
|
color: var(--chalk);
|
||||||
|
box-shadow: 0 10px 25px -5px rgba(20, 53, 43, 0.25);
|
||||||
|
border: 1px solid rgba(232, 163, 23, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.professor-badge-pill {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.2rem 0.65rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(232, 163, 23, 0.2);
|
||||||
|
color: #ffd166;
|
||||||
|
border: 1px solid rgba(232, 163, 23, 0.4);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.professor-banner h2 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.35rem;
|
||||||
|
color: #fff;
|
||||||
|
margin: 0 0 0.35rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.professor-banner p {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
max-width: 540px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.professor-cta-btn {
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
background: var(--saffron);
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
box-shadow: 0 4px 12px rgba(232, 163, 23, 0.3);
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.professor-cta-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 16px rgba(232, 163, 23, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.professor-banner {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
.professor-cta-btn {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.summary-grid {
|
.summary-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|||||||
@@ -18,20 +18,34 @@
|
|||||||
<div v-else-if="!classes.length" class="empty-state">
|
<div v-else-if="!classes.length" class="empty-state">
|
||||||
<h3>هنوز کلاسی ندارید</h3>
|
<h3>هنوز کلاسی ندارید</h3>
|
||||||
<p>پس از ثبتنام در کلاس توسط آموزشگاه، اینجا نمایش داده میشود.</p>
|
<p>پس از ثبتنام در کلاس توسط آموزشگاه، اینجا نمایش داده میشود.</p>
|
||||||
<RouterLink to="/#courses" class="btn btn-outline">مشاهده دورهها</RouterLink>
|
<RouterLink to="/#upcoming-classes" class="btn btn-outline">مشاهده کلاسهای پیشرو</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="class-list">
|
<div v-else class="class-list">
|
||||||
<article v-for="item in classes" :key="item._id" class="class-card">
|
<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 class="class-top">
|
||||||
|
<div>
|
||||||
|
<span class="course-tag">{{ item.course?.title || 'بدون دوره' }}</span>
|
||||||
<h2>{{ item.name }}</h2>
|
<h2>{{ item.name }}</h2>
|
||||||
<span class="badge" :class="item.isActive ? '' : 'badge-muted'">
|
</div>
|
||||||
|
<span class="status-badge" :class="item.isActive ? 'status-active' : 'status-muted'">
|
||||||
{{ item.isActive ? 'فعال' : 'غیرفعال' }}
|
{{ item.isActive ? 'فعال' : 'غیرفعال' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="course-title">{{ item.course?.title || 'بدون دوره' }}</p>
|
|
||||||
<dl class="meta-grid">
|
<dl class="meta-grid">
|
||||||
<div>
|
<div class="meta-item">
|
||||||
<dt>استاد</dt>
|
<dt>استاد</dt>
|
||||||
<dd>
|
<dd>
|
||||||
{{
|
{{
|
||||||
@@ -41,19 +55,28 @@
|
|||||||
}}
|
}}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="meta-item">
|
||||||
<dt>شروع</dt>
|
<dt>شروع</dt>
|
||||||
<dd>{{ formatJalali(item.startDate) }}</dd>
|
<dd>{{ formatJalali(item.startDate) }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="meta-item">
|
||||||
<dt>پایان</dt>
|
<dt>پایان</dt>
|
||||||
<dd>{{ formatJalali(item.endDate) }}</dd>
|
<dd>{{ formatJalali(item.endDate) }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<dt>شهریه</dt>
|
||||||
<dd>{{ formatPrice(item.tuitionFee) }}</dd>
|
<dd>{{ formatPrice(item.finalTuitionFee ?? item.tuitionFee) }}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,6 +88,39 @@ import { RouterLink } from 'vue-router'
|
|||||||
import { classesApi } from '@/api/classesApi'
|
import { classesApi } from '@/api/classesApi'
|
||||||
import { formatJalali, formatPrice, getApiErrorMessage } from '@/utils/format'
|
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 classes = ref([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
@@ -103,15 +159,53 @@ onMounted(load)
|
|||||||
|
|
||||||
.class-list {
|
.class-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.class-card {
|
.class-card {
|
||||||
padding: 1.35rem 1.4rem;
|
border-radius: 1.25rem;
|
||||||
border-radius: 1.2rem;
|
overflow: hidden;
|
||||||
background: var(--chalk);
|
background: var(--chalk);
|
||||||
border: 1px solid rgba(20, 53, 43, 0.08);
|
border: 1px solid rgba(20, 53, 43, 0.08);
|
||||||
box-shadow: 0 10px 28px rgba(20, 53, 43, 0.05);
|
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 {
|
.class-top {
|
||||||
@@ -119,6 +213,17 @@ onMounted(load)
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
align-items: flex-start;
|
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;
|
margin-bottom: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,28 +231,55 @@ onMounted(load)
|
|||||||
font-size: 1.2rem;
|
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);
|
color: var(--leaf);
|
||||||
font-weight: 700;
|
}
|
||||||
margin-bottom: 1rem;
|
|
||||||
|
.status-muted {
|
||||||
|
background: rgba(20, 53, 43, 0.08);
|
||||||
|
color: var(--ink-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-grid {
|
.meta-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 0.85rem;
|
gap: 0.75rem;
|
||||||
margin: 0;
|
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 {
|
.meta-grid dt {
|
||||||
font-size: 0.75rem;
|
font-size: 0.72rem;
|
||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-grid dd {
|
.meta-grid dd {
|
||||||
margin: 0.15rem 0 0;
|
margin: 0.2rem 0 0;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
font-size: 0.92rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+7
-1
@@ -11,6 +11,12 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5174,
|
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']
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user