Compare commits

...
13 Commits
Author SHA1 Message Date
kavehhn afd729b1ac feat(front): add professor portal view, schedule & earnings charts, and account navigation 2026-08-25 03:48:32 +03:30
kavehhn 5035879651 fix(docker): copy .npmrc and use npmmirror registry for fast builds 2026-08-25 02:40:57 +03:30
kavehhn c61fafeb01 build: add .npmrc with liara npm mirror registry 2026-08-25 01:58:16 +03:30
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
20 changed files with 3425 additions and 251 deletions
+1
View File
@@ -0,0 +1 @@
registry=https://registry.npmmirror.com/
+8 -47
View File
@@ -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;"]
+2
View File
@@ -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 })
} }
+6
View File
@@ -0,0 +1,6 @@
// /src/api/professorApi.js
import axiosInstance from './axiosInstance'
export const professorApi = {
getPortal: () => axiosInstance.get('/professors/portal')
}
+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>
<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>
+2 -1
View File
@@ -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>
+25 -2
View File
@@ -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%;
+6 -2
View File
@@ -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>
+18
View File
@@ -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',
+6 -1
View File
@@ -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,
+8
View File
@@ -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 ||
+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>
+97 -151
View File
@@ -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 {
+82
View File
@@ -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));
+151 -19
View File
@@ -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
View File
@@ -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']
} }
}) })