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.
This commit is contained in:
@@ -2,5 +2,6 @@ import axiosInstance from './axiosInstance'
|
|||||||
|
|
||||||
export const classesApi = {
|
export const classesApi = {
|
||||||
getAll: (params = {}) => axiosInstance.get('/classes/user/get-all', { params }),
|
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,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)
|
||||||
|
}
|
||||||
@@ -49,13 +49,14 @@
|
|||||||
</span>
|
</span>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<a href="#contact" class="class-card-cta">{{ ctaLabel }}</a>
|
<RouterLink :to="ctaTo" class="class-card-cta">{{ ctaLabel }}</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
formatJalali,
|
formatJalali,
|
||||||
formatPrice,
|
formatPrice,
|
||||||
@@ -107,6 +108,12 @@ const ctaLabel = computed(() =>
|
|||||||
props.variant === 'upcoming' ? 'رزرو جایگاه' : 'درخواست تشکیل کلاس جدید'
|
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) {
|
function joinFaList(items) {
|
||||||
if (!items.length) return '—'
|
if (!items.length) return '—'
|
||||||
if (items.length === 1) return items[0]
|
if (items.length === 1) return items[0]
|
||||||
|
|||||||
@@ -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' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'register/request/:classId',
|
||||||
|
name: 'register-request',
|
||||||
|
component: () => import('@/views/ClassRegistrationView.vue'),
|
||||||
|
meta: { title: 'درخواست تشکیل کلاس | گام نو', registrationType: 'class_request' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,584 @@
|
|||||||
|
<template>
|
||||||
|
<div class="registration-page">
|
||||||
|
<div class="container registration-shell">
|
||||||
|
<header class="page-head">
|
||||||
|
<RouterLink to="/" class="back-link">← بازگشت</RouterLink>
|
||||||
|
<p class="page-eyebrow">{{ isClassRequest ? 'درخواست تشکیل کلاس' : 'ثبتنام در کلاس' }}</p>
|
||||||
|
<h1>{{ pageTitle }}</h1>
|
||||||
|
<p v-if="cls" class="page-lead">{{ cls.course?.title || cls.name }}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading-block">
|
||||||
|
<div class="spinner" aria-label="در حال بارگذاری"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="loadError" class="alert" role="alert">{{ loadError }}</div>
|
||||||
|
|
||||||
|
<template v-else-if="cls">
|
||||||
|
<aside class="class-summary">
|
||||||
|
<h2>خلاصه کلاس</h2>
|
||||||
|
<ul>
|
||||||
|
<li v-if="cls.professor">
|
||||||
|
<span>استاد</span>
|
||||||
|
<strong>{{ cls.professor.name }} {{ cls.professor.surname || '' }}</strong>
|
||||||
|
</li>
|
||||||
|
<li v-if="cls.startDate">
|
||||||
|
<span>شروع</span>
|
||||||
|
<strong>{{ formatJalali(cls.startDate) }}</strong>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span>شهریه</span>
|
||||||
|
<strong>{{ formatPrice(pricing.tuitionFee) }}</strong>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="registration-main">
|
||||||
|
<div v-if="step === 1" class="step-panel">
|
||||||
|
<h2>اطلاعات شما</h2>
|
||||||
|
<p class="step-lead">
|
||||||
|
برای ادامه، اطلاعات تماس خود را وارد کنید. در صورت نداشتن حساب، حساب کاربری برای شما
|
||||||
|
ساخته میشود و مشخصات ورود از طریق پیامک ارسال میگردد.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="formError" class="alert" role="alert">{{ formError }}</div>
|
||||||
|
<div v-if="userReady" class="alert alert-success" role="status">
|
||||||
|
اطلاعات شما ثبت شد. میتوانید روش پرداخت را انتخاب کنید.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="reg-form" novalidate @submit.prevent="handleInfoSubmit">
|
||||||
|
<div class="fields-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label for="reg-name">نام</label>
|
||||||
|
<input id="reg-name" v-model.trim="form.name" type="text" autocomplete="given-name" />
|
||||||
|
<span v-if="errors.name" class="field-error">{{ errors.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="reg-surname">نام خانوادگی</label>
|
||||||
|
<input id="reg-surname" v-model.trim="form.surname" type="text" autocomplete="family-name" />
|
||||||
|
<span v-if="errors.surname" class="field-error">{{ errors.surname }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="reg-national-id">کد ملی</label>
|
||||||
|
<input
|
||||||
|
id="reg-national-id"
|
||||||
|
v-model.trim="form.nationalIdCode"
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="10"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
<span v-if="errors.nationalIdCode" class="field-error">{{ errors.nationalIdCode }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="reg-phone">شماره موبایل</label>
|
||||||
|
<input
|
||||||
|
id="reg-phone"
|
||||||
|
v-model.trim="form.phoneNumber"
|
||||||
|
type="tel"
|
||||||
|
inputmode="tel"
|
||||||
|
autocomplete="tel"
|
||||||
|
placeholder="۰۹۱۲۱۲۳۴۵۶۷"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
<span v-if="errors.phoneNumber" class="field-error">{{ errors.phoneNumber }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="field field-wide">
|
||||||
|
<label for="reg-email">ایمیل (اختیاری)</label>
|
||||||
|
<input id="reg-email" v-model.trim="form.email" type="email" autocomplete="email" dir="ltr" />
|
||||||
|
<span v-if="errors.email" class="field-error">{{ errors.email }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-ink" type="submit" :disabled="infoSubmitting">
|
||||||
|
{{ infoSubmitting ? 'در حال ثبت…' : 'ادامه به انتخاب پرداخت' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="step-panel">
|
||||||
|
<h2>{{ isClassRequest ? 'پرداخت بیعانه' : 'روش پرداخت' }}</h2>
|
||||||
|
|
||||||
|
<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" />
|
||||||
|
<div class="plan-body">
|
||||||
|
<strong>پرداخت یکجا</strong>
|
||||||
|
<p>
|
||||||
|
با پرداخت کامل شهریه،
|
||||||
|
<span class="highlight">{{ formatPrice(FULL_PAYMENT_DISCOUNT) }}</span>
|
||||||
|
تخفیف دریافت میکنید.
|
||||||
|
</p>
|
||||||
|
<span class="plan-price">{{ formatPrice(fullPricing.totalAmount) }}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="plan-card" :class="{ selected: paymentPlan === 'installments' }">
|
||||||
|
<input v-model="paymentPlan" type="radio" value="installments" />
|
||||||
|
<div class="plan-body">
|
||||||
|
<strong>پرداخت دو قسطی</strong>
|
||||||
|
<p>
|
||||||
|
شهریه کامل بدون تخفیف. اکنون نیمی از مبلغ را پرداخت میکنید و قسط دوم در میانه
|
||||||
|
دوره ({{ secondInstallmentDueLabel }}) سررسید میشود.
|
||||||
|
</p>
|
||||||
|
<span class="plan-price">
|
||||||
|
{{ formatPrice(installmentPricing.amountDueNow) }} اکنون
|
||||||
|
</span>
|
||||||
|
</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="payment-summary">
|
||||||
|
<div class="summary-row">
|
||||||
|
<span>مبلغ قابل پرداخت</span>
|
||||||
|
<strong>{{ formatPrice(activePricing.amountDueNow) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div v-if="!isClassRequest && paymentPlan === 'installments'" class="summary-row muted">
|
||||||
|
<span>قسط دوم</span>
|
||||||
|
<span>{{ formatPrice(activePricing.secondInstallmentAmount) }} · {{ secondInstallmentDueLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!isClassRequest && paymentPlan === 'full'" class="summary-row muted">
|
||||||
|
<span>تخفیف پرداخت یکجا</span>
|
||||||
|
<span>{{ formatPrice(activePricing.paymentDiscount) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="payNotice" class="pay-notice" role="status">{{ payNotice }}</p>
|
||||||
|
|
||||||
|
<div class="actions-row">
|
||||||
|
<button class="btn btn-ghost" type="button" @click="step = 1">بازگشت</button>
|
||||||
|
<button class="btn btn-ink" type="button" disabled title="درگاه پرداخت بهزودی فعال میشود">
|
||||||
|
پرداخت
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="gateway-note">درگاه پرداخت بهزودی فعال میشود. پس از پرداخت، درخواست شما برای بررسی ثبت میشود.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, reactive, ref, watch } 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 userReady = ref(false)
|
||||||
|
const infoSubmitting = ref(false)
|
||||||
|
const formError = ref('')
|
||||||
|
const payNotice = 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
|
||||||
|
userReady.value = true
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(paymentPlan, () => {
|
||||||
|
payNotice.value = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
if (isClassRequest.value) step.value = 1
|
||||||
|
} catch (error) {
|
||||||
|
loadError.value = getApiErrorMessage(error, 'بارگذاری اطلاعات کلاس ناموفق بود.')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.registration-page {
|
||||||
|
padding: 6.5rem 0 3rem;
|
||||||
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 90% 0%, rgba(232, 163, 23, 0.12), transparent 35%),
|
||||||
|
linear-gradient(180deg, var(--canvas) 0%, var(--canvas-deep) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-shell {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-head {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-eyebrow {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--saffron-deep);
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-head h1 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(1.8rem, 3.5vw, 2.35rem);
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-lead {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-summary,
|
||||||
|
.registration-main,
|
||||||
|
.step-panel {
|
||||||
|
background: var(--chalk);
|
||||||
|
border: 1px solid rgba(20, 53, 43, 0.08);
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
box-shadow: 0 12px 32px rgba(20, 53, 43, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-summary {
|
||||||
|
padding: 1.25rem 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-summary h2,
|
||||||
|
.step-panel h2 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-summary ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-summary li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-summary span {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-main {
|
||||||
|
padding: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-lead {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fields-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.85rem 1rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid rgba(20, 53, 43, 0.14);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 0.65rem 0.8rem;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #b42318;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-options {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-card {
|
||||||
|
display: block;
|
||||||
|
border: 1px solid rgba(20, 53, 43, 0.12);
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-card input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-card.selected {
|
||||||
|
border-color: var(--leaf);
|
||||||
|
box-shadow: 0 0 0 1px rgba(45, 106, 79, 0.25);
|
||||||
|
background: rgba(45, 106, 79, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-body strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-body p {
|
||||||
|
margin: 0 0 0.55rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight {
|
||||||
|
color: var(--saffron-deep);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-price {
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms-box {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: rgba(232, 163, 23, 0.08);
|
||||||
|
border: 1px dashed rgba(232, 163, 23, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms-box h3 {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin-bottom: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms-box ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-right: 1.1rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-summary {
|
||||||
|
border-top: 1px dashed rgba(20, 53, 43, 0.12);
|
||||||
|
padding-top: 0.95rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-row.muted {
|
||||||
|
font-size: 0.86rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gateway-note {
|
||||||
|
margin-top: 0.85rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0.75rem 0.9rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: rgba(180, 35, 24, 0.08);
|
||||||
|
color: #7a1a12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-success {
|
||||||
|
background: rgba(45, 106, 79, 0.1);
|
||||||
|
color: var(--leaf);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-block {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.registration-shell {
|
||||||
|
grid-template-columns: 280px 1fr;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-head {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.fields-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
<header class="section-head">
|
<header class="section-head">
|
||||||
<p class="section-eyebrow">در حال برگزاری</p>
|
<p class="section-eyebrow">در حال برگزاری</p>
|
||||||
<h2>کلاسهای فعال</h2>
|
<h2>کلاسهای فعال</h2>
|
||||||
<p>کلاسهایی که هماکنون در حال برگزاری هستند و هنوز امکان پیوستن دارند.</p>
|
<p>کلاسهای در حال برگزاری — برای تشکیل کلاس جدید همان دوره، درخواست دهید و بیعانه پرداخت کنید.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div v-if="classesLoading" class="loading-block">
|
<div v-if="classesLoading" class="loading-block">
|
||||||
|
|||||||
Reference in New Issue
Block a user