Initial commit: public frontend for GameNo.

Vue 3 + Vite student-facing app with routing, Pinia state, and API integration.
This commit is contained in:
2026-08-09 04:18:09 +02:00
commit a9135bfa09
32 changed files with 5394 additions and 0 deletions
+510
View File
@@ -0,0 +1,510 @@
<template>
<div class="landing">
<section class="hero" aria-labelledby="hero-brand">
<div class="hero-media" aria-hidden="true">
<img
src="https://images.unsplash.com/photo-1524178232363-1fb2b075b655?auto=format&fit=crop&w=2000&q=80"
alt=""
width="2000"
height="1333"
/>
<div class="hero-veil"></div>
</div>
<div class="container hero-content">
<p class="hero-kicker">آموزشگاه تخصصی</p>
<h1 id="hero-brand" class="hero-brand">گام نو</h1>
<p class="hero-lead">
کلاسهای حضوری، اساتید مجرب و گواهینامه رسمی مسیر یادگیریتان را اینجا ادامه دهید.
</p>
<div class="hero-actions">
<RouterLink
:to="auth.isAuthenticated ? { name: 'account' } : { name: 'login' }"
class="btn btn-primary"
>
{{ auth.isAuthenticated ? 'ورود به پنل کاربری' : 'ورود به حساب' }}
</RouterLink>
<a href="#courses" class="btn btn-ghost">مشاهده دورهها</a>
<a href="#contact" class="btn btn-ghost">درخواست مشاوره</a>
</div>
<div class="step-path" aria-hidden="true">
<span v-for="n in 5" :key="n" class="step" :style="{ '--i': n }"></span>
</div>
</div>
</section>
<section id="courses" class="section courses-section">
<div class="container">
<header class="section-head">
<h2>دورههای فعال</h2>
<p>از میان دورههای جاری انتخاب کنید و از پنل کاربری کلاسهای ثبتشدهتان را ببینید.</p>
</header>
<div v-if="coursesLoading" class="loading-block">
<div class="spinner" aria-label="در حال بارگذاری"></div>
</div>
<div v-else-if="!courses.length" class="empty-state">
<h3>هنوز دورهای ثبت نشده</h3>
<p>به زودی فهرست دورهها اینجا نمایش داده میشود.</p>
</div>
<div v-else class="course-rail">
<article v-for="course in courses" :key="course._id" class="course-item">
<div class="course-meta">
<span class="badge" :class="course.type === 'Private' ? 'badge-gold' : ''">
{{ course.type === 'Private' ? 'خصوصی' : 'عمومی' }}
</span>
<span v-if="course.isOfficial" class="badge badge-gold">رسمی</span>
</div>
<h3>{{ course.title }}</h3>
<p>{{ course.description || 'جزئیات دوره پس از ثبت‌نام در پنل کاربری در دسترس است.' }}</p>
<ul v-if="course.highlights?.length" class="course-highlights">
<li v-for="(item, idx) in course.highlights" :key="idx">{{ item }}</li>
</ul>
<div class="course-stats">
<span v-if="course.sectionCount">
{{ toPersianDigits(course.sectionCount) }} جلسه
</span>
<span v-if="course.hoursPerSection">
{{ toPersianDigits(course.hoursPerSection) }} ساعت هر جلسه
</span>
<span v-if="course.sectionCount && course.hoursPerSection">
مجموع {{ toPersianDigits(Number(course.sectionCount) * Number(course.hoursPerSection)) }} ساعت
</span>
</div>
<div class="course-foot">
<strong>{{ formatPrice(course.price) }}</strong>
<span v-if="course.rating">امتیاز {{ toPersianDigits(course.rating) }}</span>
</div>
<a href="#contact" class="course-cta">علاقهمندی و مشاوره</a>
</article>
</div>
</div>
</section>
<section id="path" class="section path-section">
<div class="container path-grid">
<div class="path-copy">
<h2>سه قدم تا گواهینامه</h2>
<p>از ثبتنام تا دریافت گواهی، همه چیز در حساب کاربریتان یکجا جمع است.</p>
</div>
<ol class="path-steps">
<li>
<strong>ورود به حساب</strong>
<span>با نام کاربری و رمز عبور وارد شوید.</span>
</li>
<li>
<strong>پیگیری کلاسها</strong>
<span>کلاسهای ثبتشده، استاد و بازه زمانی را ببینید.</span>
</li>
<li>
<strong>دریافت گواهی</strong>
<span>گواهینامههای صادرشده را دانلود کنید.</span>
</li>
</ol>
</div>
</section>
<ContactInquiryForm />
<section class="section cta-section">
<div class="container cta-panel">
<h2>آماده برداشتن گام بعدی؟</h2>
<p>وارد حساب شوید و کلاسها و گواهینامههایتان را یکجا ببینید.</p>
<RouterLink
:to="auth.isAuthenticated ? { name: 'account' } : { name: 'login' }"
class="btn btn-ink"
>
{{ auth.isAuthenticated ? 'رفتن به پنل' : 'ورود به حساب' }}
</RouterLink>
</div>
</section>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router'
import ContactInquiryForm from '@/components/ContactInquiryForm.vue'
import { coursesApi } from '@/api/coursesApi'
import { useAuthStore } from '@/stores/authStore'
import { formatPrice, toPersianDigits } from '@/utils/format'
const auth = useAuthStore()
const courses = ref([])
const coursesLoading = ref(true)
onMounted(async () => {
try {
const res = await coursesApi.getAll({ limit: 50, sort: '-createdAt' })
courses.value = res.data || []
} catch {
courses.value = []
} finally {
coursesLoading.value = false
}
})
</script>
<style scoped>
.hero {
position: relative;
min-height: 100vh;
min-height: 100dvh;
display: flex;
align-items: flex-end;
color: var(--chalk);
overflow: hidden;
}
.hero-media {
position: absolute;
inset: 0;
}
.hero-media img {
width: 100%;
height: 100%;
object-fit: cover;
transform: scale(1.04);
animation: heroDrift 18s ease-in-out infinite alternate;
}
@keyframes heroDrift {
from { transform: scale(1.04) translate3d(0, 0, 0); }
to { transform: scale(1.08) translate3d(-1.5%, -1%, 0); }
}
.hero-veil {
position: absolute;
inset: 0;
background:
linear-gradient(180deg, rgba(12, 34, 27, 0.35) 0%, rgba(12, 34, 27, 0.55) 42%, rgba(12, 34, 27, 0.92) 100%),
radial-gradient(ellipse at 70% 20%, rgba(232, 163, 23, 0.22), transparent 55%);
}
.hero-content {
position: relative;
z-index: 1;
padding-block: 7rem 4.5rem;
max-width: 40rem;
margin-inline: 0 auto 0;
animation: heroIn 0.9s var(--ease) both;
}
@keyframes heroIn {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
.hero-kicker {
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--saffron);
margin-bottom: 0.75rem;
}
.hero-brand {
font-family: var(--font-display);
font-size: clamp(3.8rem, 12vw, 6.5rem);
font-weight: 700;
line-height: 0.95;
margin-bottom: 1rem;
text-shadow: 0 10px 40px rgba(0, 0, 0, 0.35);
}
.hero-lead {
font-size: clamp(1rem, 2.4vw, 1.2rem);
color: rgba(247, 251, 248, 0.88);
max-width: 32rem;
margin-bottom: 1.75rem;
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 2.5rem;
}
.step-path {
display: flex;
gap: 1.1rem;
align-items: center;
}
.step {
width: 0.7rem;
height: 0.7rem;
border-radius: 50%;
background: var(--saffron);
opacity: 0;
animation: stepIn 0.6s var(--ease) forwards;
animation-delay: calc(var(--i) * 0.12s + 0.5s);
box-shadow: 0 0 0 6px rgba(232, 163, 23, 0.18);
position: relative;
}
.step::after {
content: '';
position: absolute;
inset-inline-start: 100%;
top: 50%;
width: 1.1rem;
height: 2px;
background: linear-gradient(90deg, rgba(232, 163, 23, 0.7), transparent);
transform: translateY(-50%);
}
.step:last-child::after {
display: none;
}
@keyframes stepIn {
from { opacity: 0; transform: translateY(8px) scale(0.6); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.section {
padding-block: 5rem;
}
.section-head {
margin-bottom: 2rem;
max-width: 36rem;
}
.section-head h2,
.path-copy h2,
.cta-panel h2 {
font-family: var(--font-display);
font-size: clamp(2rem, 4vw, 2.8rem);
margin-bottom: 0.6rem;
}
.section-head p,
.path-copy p,
.cta-panel p {
color: var(--ink-soft);
}
.courses-section {
background:
linear-gradient(180deg, var(--canvas) 0%, #e8f0ea 100%);
}
.course-rail {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 1.25rem;
}
.course-item {
padding: 1.35rem 1.25rem;
border-radius: 1.15rem;
background: var(--chalk);
border: 1px solid rgba(20, 53, 43, 0.06);
box-shadow: 0 10px 30px rgba(20, 53, 43, 0.05);
transition: transform 0.3s var(--ease), box-shadow 0.3s var(--ease);
}
.course-item:hover {
transform: translateY(-4px);
box-shadow: var(--shadow);
}
.course-meta {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
margin-bottom: 0.85rem;
}
.course-item h3 {
font-size: 1.15rem;
margin-bottom: 0.5rem;
}
.course-item p {
color: var(--ink-soft);
font-size: 0.92rem;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 4.4em;
}
.course-highlights {
list-style: none;
margin: 0.9rem 0 0;
padding: 0;
display: grid;
gap: 0.35rem;
}
.course-highlights li {
position: relative;
padding-inline-start: 1rem;
font-size: 0.86rem;
color: var(--ink-soft);
}
.course-highlights li::before {
content: '';
position: absolute;
inset-inline-start: 0;
top: 0.55em;
width: 0.35rem;
height: 0.35rem;
border-radius: 50%;
background: var(--leaf);
}
.course-stats {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
margin-top: 0.9rem;
font-size: 0.8rem;
color: var(--ink-soft);
}
.course-stats span {
padding: 0.2rem 0.55rem;
border-radius: 999px;
background: rgba(45, 106, 79, 0.08);
}
.course-foot {
margin-top: 1.1rem;
padding-top: 0.9rem;
border-top: 1px solid rgba(20, 53, 43, 0.08);
display: flex;
justify-content: space-between;
gap: 0.75rem;
font-size: 0.88rem;
}
.course-foot span {
color: var(--ink-soft);
}
.course-cta {
display: inline-flex;
margin-top: 0.85rem;
font-size: 0.88rem;
font-weight: 700;
color: var(--leaf);
}
.course-cta:hover {
color: var(--saffron-deep);
}
.path-section {
background: var(--ink);
color: var(--chalk);
}
.path-grid {
display: grid;
grid-template-columns: 0.9fr 1.1fr;
gap: 2.5rem;
align-items: start;
}
.path-copy p {
color: rgba(247, 251, 248, 0.75);
}
.path-steps {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 1rem;
}
.path-steps li {
display: grid;
gap: 0.25rem;
padding: 1.1rem 1.2rem;
border-radius: 1rem;
background: rgba(247, 251, 248, 0.06);
border: 1px solid rgba(247, 251, 248, 0.1);
counter-increment: step;
position: relative;
padding-inline-start: 4rem;
}
.path-steps {
counter-reset: step;
}
.path-steps li::before {
content: counter(step, persian);
position: absolute;
inset-inline-start: 1rem;
top: 1rem;
width: 2.1rem;
height: 2.1rem;
border-radius: 50%;
display: grid;
place-items: center;
background: var(--saffron);
color: var(--deep);
font-weight: 700;
font-size: 0.95rem;
}
.path-steps strong {
font-size: 1.05rem;
}
.path-steps span {
color: rgba(247, 251, 248, 0.72);
font-size: 0.92rem;
}
.cta-section {
padding-block: 4rem 5rem;
}
.cta-panel {
text-align: center;
padding: 3rem 1.5rem;
border-radius: 1.75rem;
background:
radial-gradient(circle at 20% 20%, rgba(232, 163, 23, 0.2), transparent 45%),
linear-gradient(135deg, #e7f1ea, #cfe0d4);
border: 1px solid rgba(20, 53, 43, 0.08);
}
.cta-panel p {
margin-bottom: 1.5rem;
}
@media (max-width: 860px) {
.path-grid {
grid-template-columns: 1fr;
gap: 1.5rem;
}
.hero-content {
padding-block: 6rem 3.5rem;
}
}
@media (prefers-reduced-motion: reduce) {
.hero-media img {
animation: none;
}
}
</style>