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:
@@ -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>
|
||||
Reference in New Issue
Block a user