This commit is contained in:
sajjadtalkhabi
2026-05-13 03:06:59 +03:30
parent d2a577f254
commit b8ced45375
142 changed files with 3706 additions and 905 deletions
+1 -1
View File
@@ -13,8 +13,8 @@
</template>
<script setup>
import PatternSection from '@/features/auth/components/PatternSection.vue'
import { gallery } from '@/utils/gallery'
import PatternSection from '@/features/auth/components/PatternSection.vue'
const bgPattern = gallery.loginPattern
const bgPatternMobile = gallery.loginPatternMobile
+7 -8
View File
@@ -65,17 +65,16 @@
<script setup>
import { ref } from 'vue'
import { RouterLink } from 'vue-router'
import AuthHeading from '@/features/auth/components/AuthHeading.vue'
import AuthCopyright from '@/features/auth/components/AuthCopyright.vue'
import BaseButton from '@/components/BaseButton.vue'
import TextField from '@/components/form/TextField.vue'
import PasswordField from '@/components/form/PasswordField.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import useYup from '@/composables/useYup'
import useAuth from '@/composables/useAuth'
import { useLoginMutation } from '@/services/query/auth'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { loginSchema } from '@/features/auth/schema'
import TextField from '@/components/form/TextField.vue'
import { useLoginMutation } from '@/services/query/auth'
import PasswordField from '@/components/form/PasswordField.vue'
import AuthHeading from '@/features/auth/components/AuthHeading.vue'
import AuthCopyright from '@/features/auth/components/AuthCopyright.vue'
import { useRedirectAfterLogin } from '@/features/auth/composables/useRedirectAfterLogin'
const emit = defineEmits(['next'])
@@ -48,16 +48,15 @@
<script setup>
import { computed, ref } from 'vue'
import AuthHeading from '@/features/auth/components/AuthHeading.vue'
import AuthCopyright from '@/features/auth/components/AuthCopyright.vue'
import BaseButton from '@/components/BaseButton.vue'
import TextField from '@/components/form/TextField.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import useYup from '@/composables/useYup'
import useAuth from '@/composables/useAuth'
import { useForgotPasswordMutation, useLoginWithCodeMutation } from '@/services/query/auth'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import TextField from '@/components/form/TextField.vue'
import { loginWithCodeSchema } from '@/features/auth/schema'
import AuthHeading from '@/features/auth/components/AuthHeading.vue'
import AuthCopyright from '@/features/auth/components/AuthCopyright.vue'
import { useForgotPasswordMutation, useLoginWithCodeMutation } from '@/services/query/auth'
const props = defineProps({
mode: { type: String, default: 'loginWithCode' },
+7 -7
View File
@@ -84,16 +84,16 @@
<script setup>
import { ref } from 'vue'
import { RouterLink } from 'vue-router'
import AuthHeading from '@/features/auth/components/AuthHeading.vue'
import AuthCopyright from '@/features/auth/components/AuthCopyright.vue'
import BaseButton from '@/components/BaseButton.vue'
import TextField from '@/components/form/TextField.vue'
import PasswordField from '@/components/form/PasswordField.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import useYup from '@/composables/useYup'
import useAuth from '@/composables/useAuth'
import { useRegisterMutation } from '@/services/query/auth'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { signupSchema } from '@/features/auth/schema'
import TextField from '@/components/form/TextField.vue'
import { useRegisterMutation } from '@/services/query/auth'
import PasswordField from '@/components/form/PasswordField.vue'
import AuthHeading from '@/features/auth/components/AuthHeading.vue'
import AuthCopyright from '@/features/auth/components/AuthCopyright.vue'
const emit = defineEmits(['next'])
@@ -46,13 +46,16 @@
<script setup>
import { computed, ref } from 'vue'
import AuthHeading from '@/features/auth/components/AuthHeading.vue'
import AuthCopyright from '@/features/auth/components/AuthCopyright.vue'
import BaseButton from '@/components/BaseButton.vue'
import TextField from '@/components/form/TextField.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { useRouter } from 'vue-router'
import useYup from '@/composables/useYup'
import useAuth from '@/composables/useAuth'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import TextField from '@/components/form/TextField.vue'
import { verifyCodeSchema } from '@/features/auth/schema'
import AuthHeading from '@/features/auth/components/AuthHeading.vue'
import AuthCopyright from '@/features/auth/components/AuthCopyright.vue'
import { useRedirectAfterLogin } from '@/features/auth/composables/useRedirectAfterLogin'
import {
useForgotPasswordMutation,
useLoginWithCodeMutation,
@@ -60,9 +63,6 @@ import {
useVerifyCodeMutation,
useVerifyForgotPasswordCodeMutation,
} from '@/services/query/auth'
import { useRedirectAfterLogin } from '@/features/auth/composables/useRedirectAfterLogin'
import { useRouter } from 'vue-router'
import { verifyCodeSchema } from '@/features/auth/schema'
const props = defineProps({
phoneNumber: { type: String, default: '' },
@@ -0,0 +1,175 @@
<template>
<form class="educational-info" @submit.prevent="onSubmit">
<LineTitleBlock title="جزئیات تحصیلی" title-en="Educational Details" />
<div class="educational-info__row">
<div class="educational-info__cell">
<SelectField
v-model="form.educationStatus"
name="educationStatus"
label="وضعیت تحصیلی"
:options="educationStatusOptions"
option-label="label"
option-value="value"
:error="errors.educationStatus"
/>
</div>
<div class="educational-info__cell">
<SelectField
v-model="form.seminaryLevel"
name="seminaryLevel"
label="آخرین مدرک تحصیلی حوزوی"
:options="seminaryLevelOptions"
option-label="label"
option-value="value"
:error="errors.seminaryLevel"
/>
</div>
<div class="educational-info__cell">
<SelectField
v-model="form.universityLevel"
name="universityLevel"
label="آخرین مدرک تحصیلی"
:options="universityLevelOptions"
option-label="label"
option-value="value"
:error="errors.universityLevel"
/>
</div>
<div class="educational-info__cell">
<TextField
v-model="form.universityName"
name="universityName"
label="نام حوزه علمیه / دانشگاه"
:error="errors.universityName"
@blur="validateAt('universityName', form.universityName)"
>
<template #appendIcon>
<SvgIcon name="book" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
</div>
<div class="educational-info__cell educational-info__cell--full">
<TextField
v-model="form.fieldOfStudy"
name="fieldOfStudy"
label="گرایش تخصصی حوزوی / رشته تحصیلی دانشگاهی"
:error="errors.fieldOfStudy"
@blur="validateAt('fieldOfStudy', form.fieldOfStudy)"
>
<template #appendIcon>
<SvgIcon name="book" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
</div>
</div>
<LineTitleBlock title="مشخصات شغلی" title-en="Job Details" />
<div class="educational-info__row">
<div class="educational-info__cell educational-info__cell--full">
<TextareaField
v-model="form.workExperienceSummary"
name="workExperienceSummary"
label="خلاصه ای از سوابق شغلی خود را ذکر بفرمایید."
:row="isMobile ? 5 : 1"
:error="errors.workExperienceSummary"
@blur="validateAt('workExperienceSummary', form.workExperienceSummary)"
>
<template #appendIcon>
<SvgIcon name="file" :size="20" color="var(--color-thd-gray)" />
</template>
</TextareaField>
</div>
<div class="educational-info__cell educational-info__cell--full">
<TextareaField
v-model="form.activitySummary"
name="activitySummary"
label="خلاصه ای از فعالیت های خود را در موقعیت‌های شغلی ذکر شده بنویسید."
:row="isMobile ? 5 : 1"
:error="errors.activitySummary"
@blur="validateAt('activitySummary', form.activitySummary)"
>
<template #appendIcon>
<SvgIcon name="file" :size="20" color="var(--color-thd-gray)" />
</template>
</TextareaField>
</div>
</div>
<div class="educational-info__divider" />
<StepActions @back="store.goToPrevStep()" />
</form>
</template>
<script setup>
import useYup from '@/composables/useYup'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import TextField from '@/components/form/TextField.vue'
import SelectField from '@/components/form/SelectField.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import TextareaField from '@/components/form/TextareaField.vue'
import StepActions from '@/features/auth/components/studentRegister/StepActions.vue'
import { educationalInformationSchema } from '@/features/auth/schema/student-register'
import { useStudentRegistrationStore } from '@/features/auth/store/student-registration'
import { EDUCATION_STATUS, SEMINARY_LEVEL, STUDENT_REGISTRATION, UNIVERSITY_LEVEL } from '@/enums'
const store = useStudentRegistrationStore()
const isMobile = ref(window.innerWidth < 768)
const onResize = () => {
isMobile.value = window.innerWidth < 768
}
onMounted(() => window.addEventListener('resize', onResize))
onBeforeUnmount(() => window.removeEventListener('resize', onResize))
const educationStatusOptions = Object.entries(EDUCATION_STATUS).map(([value, label]) => ({
value,
label,
}))
const seminaryLevelOptions = Object.entries(SEMINARY_LEVEL).map(([value, label]) => ({
value,
label,
}))
const universityLevelOptions = Object.entries(UNIVERSITY_LEVEL).map(([value, label]) => ({
value,
label,
}))
const form = ref({ ...store.educational })
const { validate, validateAt, errors } = useYup(educationalInformationSchema)
const onSubmit = async () => {
const { isValid, payload } = await validate(form.value)
if (!isValid) return
store.educational = { ...store.educational, ...payload }
store.markStepAsCompleted(STUDENT_REGISTRATION.EDUCATIONAL_INFORMATION)
store.goToNextStep()
}
store.updateCurrentStep(STUDENT_REGISTRATION.EDUCATIONAL_INFORMATION)
</script>
<style lang="scss" scoped>
.educational-info {
&__row {
display: grid;
grid-template-columns: 1fr;
gap: 0.5rem 0.625rem;
margin-bottom: 1rem;
@media (min-width: 1024px) {
grid-template-columns: repeat(4, 1fr);
}
}
&__cell--full {
grid-column: 1 / -1;
}
&__divider {
border-block-end: 1px solid var(--color-thd-gray);
margin: 1rem 0 1.5rem;
}
}
</style>
@@ -0,0 +1,364 @@
<template>
<div class="media-recorder" :class="`media-recorder--${kind}`">
<div class="media-recorder__frame">
<div v-if="previewUrl" class="media-recorder__preview">
<video
v-if="kind === 'video'"
ref="previewEl"
:src="previewUrl"
controls
playsinline
class="media-recorder__media"
/>
<audio
v-else
ref="previewEl"
:src="previewUrl"
controls
class="media-recorder__media media-recorder__media--audio"
/>
</div>
<div v-else-if="isRecording" class="media-recorder__live">
<video
v-if="kind === 'video'"
ref="liveEl"
autoplay
muted
playsinline
class="media-recorder__media"
/>
<div v-else class="media-recorder__pulse">
<SvgIcon name="paper-plane-right" :size="32" color="var(--color-primary)" />
</div>
<div class="media-recorder__timer">{{ formattedTime }}</div>
</div>
<div v-else class="media-recorder__empty">
<SvgIcon name="upload" :size="48" color="var(--color-thd-gray)" />
<p class="media-recorder__hint">{{ hintText }}</p>
</div>
</div>
<div class="media-recorder__actions">
<button
v-if="!isRecording && !previewUrl"
type="button"
class="media-recorder__btn media-recorder__btn--start"
:disabled="loading"
@click="onStart"
>
<span v-if="loading">در حال بارگذاری...</span>
<span v-else>شروع ضبط</span>
</button>
<button
v-if="isRecording"
type="button"
class="media-recorder__btn media-recorder__btn--stop"
@click="onStop"
>
پایان ضبط
</button>
<button
v-if="previewUrl && !loading"
type="button"
class="media-recorder__btn media-recorder__btn--reset"
@click="onReset"
>
ضبط دوباره
</button>
</div>
<p v-if="error" class="media-recorder__error">{{ error }}</p>
</div>
<PermissionModal v-if="isModal('RegisterPermissionModal')" />
</template>
<script setup>
import { toast } from 'vue3-toastify'
import useModal from '@/composables/useModal'
import { computed, onBeforeUnmount, ref } from 'vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { objectToFormData } from '@/utils/object-to-formdata'
import { useUploadTemporaryMutation } from '@/services/query/auth'
import PermissionModal from '@/features/auth/components/studentRegister/PermissionModal.vue'
const props = defineProps({
modelValue: { type: Object, default: null },
kind: {
type: String,
default: 'audio',
validator: (v) => ['audio', 'video'].includes(v),
},
maxSeconds: { type: Number, default: 120 },
context: { type: String, default: 'verification' },
subType: { type: String, default: '' },
error: { type: String, default: '' },
})
const emit = defineEmits(['update:modelValue', 'uploaded'])
const { openModal, isModal } = useModal()
const previewUrl = ref(props.modelValue?.url || null)
const isRecording = ref(false)
const elapsedMs = ref(0)
const loading = ref(false)
const liveEl = ref(null)
const previewEl = ref(null)
let mediaRecorder = null
let mediaStream = null
let chunks = []
let timerHandle = null
let timerStart = 0
const hintText = computed(() =>
props.kind === 'video' ? 'ویدیوی خود را ضبط کنید' : 'صدای خود را ضبط کنید'
)
const formattedTime = computed(() => {
const s = Math.floor(elapsedMs.value / 1000)
const mm = String(Math.floor(s / 60)).padStart(2, '0')
const ss = String(s % 60).padStart(2, '0')
return `${mm}:${ss}`
})
const uploadMutation = useUploadTemporaryMutation()
const stopTracks = () => {
if (mediaStream) {
mediaStream.getTracks().forEach((t) => t.stop())
mediaStream = null
}
}
const clearTimer = () => {
if (timerHandle) {
clearInterval(timerHandle)
timerHandle = null
}
}
const onStart = async () => {
try {
const constraints = props.kind === 'video' ? { audio: true, video: true } : { audio: true }
mediaStream = await navigator.mediaDevices.getUserMedia(constraints)
if (props.kind === 'video' && liveEl.value) {
liveEl.value.srcObject = mediaStream
}
chunks = []
mediaRecorder = new window.MediaRecorder(mediaStream)
mediaRecorder.ondataavailable = (e) => {
if (e.data?.size > 0) chunks.push(e.data)
}
mediaRecorder.onstop = () => handleStop()
mediaRecorder.start()
isRecording.value = true
elapsedMs.value = 0
timerStart = Date.now()
timerHandle = setInterval(() => {
elapsedMs.value = Date.now() - timerStart
if (elapsedMs.value >= props.maxSeconds * 1000) onStop()
}, 250)
} catch {
openModal('RegisterPermissionModal', { kind: props.kind })
}
}
const onStop = () => {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop()
}
clearTimer()
}
const handleStop = async () => {
isRecording.value = false
stopTracks()
const mime = props.kind === 'video' ? 'video/webm' : 'audio/webm'
const blob = new Blob(chunks, { type: mime })
const ext = props.kind === 'video' ? 'webm' : 'webm'
const file = new File([blob], `recording.${ext}`, { type: mime, lastModified: Date.now() })
if (previewUrl.value && previewUrl.value.startsWith('blob:')) {
URL.revokeObjectURL(previewUrl.value)
}
previewUrl.value = URL.createObjectURL(blob)
loading.value = true
try {
const fd = objectToFormData({
file,
subType: props.subType || props.kind,
context: props.context,
})
const response = await uploadMutation.mutateAsync(fd)
const payload = response?.data ?? response
const value = {
url: previewUrl.value,
uploadId: payload?.uploadId || payload?.id,
...payload,
}
emit('update:modelValue', value)
emit('uploaded', value)
} catch {
toast.error('بارگذاری فایل با خطا مواجه شد')
} finally {
loading.value = false
}
}
const onReset = () => {
if (previewUrl.value && previewUrl.value.startsWith('blob:')) {
URL.revokeObjectURL(previewUrl.value)
}
previewUrl.value = null
emit('update:modelValue', null)
}
onBeforeUnmount(() => {
stopTracks()
clearTimer()
if (previewUrl.value && previewUrl.value.startsWith('blob:')) {
URL.revokeObjectURL(previewUrl.value)
}
})
</script>
<style lang="scss" scoped>
.media-recorder {
display: flex;
flex-direction: column;
gap: 0.625rem;
width: 100%;
&__frame {
position: relative;
aspect-ratio: 3 / 4;
border-radius: 1rem;
background: #f3f4f6;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
&--audio &__frame {
aspect-ratio: 1;
}
&__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
color: var(--color-thd-gray);
}
&__hint {
font-family: var(--font-family-fa);
font-size: 0.75rem;
margin: 0;
}
&__live,
&__preview {
width: 100%;
height: 100%;
position: relative;
}
&__media {
width: 100%;
height: 100%;
object-fit: cover;
background: #000;
}
&__media--audio {
width: 100%;
height: 2.5rem;
background: transparent;
position: absolute;
inset-block-end: 0.75rem;
inset-inline: 0.5rem;
width: calc(100% - 1rem);
}
&__pulse {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
animation: pulse 1.5s ease-in-out infinite;
}
&__timer {
position: absolute;
inset-block-start: 0.5rem;
inset-inline-start: 0.5rem;
background: rgba(0, 0, 0, 60%);
color: #fff;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-family: var(--font-family-en);
font-size: 0.75rem;
}
&__actions {
display: flex;
justify-content: center;
gap: 0.5rem;
}
&__btn {
border: none;
border-radius: 9999px;
padding: 0.5rem 1rem;
font-family: var(--font-family-fa);
font-size: 0.875rem;
cursor: pointer;
transition: opacity 0.15s ease;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&--start {
background: var(--color-primary);
color: #fff;
}
&--stop {
background: var(--color-error);
color: #fff;
}
&--reset {
background: transparent;
color: var(--color-sec-gray);
border: 1px solid var(--color-thd-gray);
}
}
&__error {
font-family: var(--font-family-fa);
font-size: 0.75rem;
color: var(--color-error);
margin: 0;
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
</style>
@@ -0,0 +1,63 @@
<template>
<BasicModal
width="90%"
max-width="28rem"
min-width="auto"
:closable="false"
:show-close-button="false"
>
<template #default="{ data, close }">
<div class="permission-modal">
<SvgIcon name="warning" :size="48" color="var(--color-prim-gray)" />
<h3 class="permission-modal__title">دسترسی مورد نیاز</h3>
<p class="permission-modal__desc">{{ message(data) }}</p>
<BaseButton text="متوجه شدم" custom-class="permission-modal__btn" @click="close" />
</div>
</template>
</BasicModal>
</template>
<script setup>
import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
defineOptions({ name: 'RegisterPermissionModal' })
const message = (data) => {
if (data?.kind === 'audio') {
return 'برای ضبط صدا، لطفاً دسترسی به میکروفون را فعال کنید.'
}
return 'برای ضبط ویدیو، لطفاً دسترسی به دوربین و میکروفون را فعال کنید.'
}
</script>
<style lang="scss" scoped>
.permission-modal {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1rem 0;
text-align: center;
&__title {
font-family: var(--font-family-fa);
font-weight: 700;
font-size: 1.125rem;
margin: 0;
}
&__desc {
font-family: var(--font-family-fa);
font-size: 0.875rem;
color: var(--color-prim-gray);
margin: 0;
}
&__btn {
width: 100%;
max-width: 12rem;
}
}
</style>
@@ -0,0 +1,337 @@
<template>
<form class="personal-info" @submit.prevent="onSubmit">
<div class="personal-info__grid">
<div class="personal-info__avatar-col">
<ImageCropper
v-model="avatar"
name="avatar"
bg-color="#eeeeee"
:error="errors.avatar"
@crop="onAvatarCropped"
@error="onAvatarError"
/>
</div>
<div class="personal-info__main-col">
<LineTitleBlock title="مشخصات فردی" title-en="Personal Details" />
<div class="personal-info__row">
<div class="personal-info__cell">
<TextField
v-model="form.firstName"
name="firstName"
label="نام"
:error="errors.firstName"
@blur="validateAt('firstName', form.firstName)"
>
<template #appendIcon>
<SvgIcon name="user" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
</div>
<div class="personal-info__cell">
<TextField
v-model="form.lastName"
name="lastName"
label="نام خانوادگی"
:error="errors.lastName"
@blur="validateAt('lastName', form.lastName)"
>
<template #appendIcon>
<SvgIcon name="user" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
</div>
<div class="personal-info__cell">
<DatePickerField
v-model="form.birthDate"
name="birthDate"
label="تاریخ تولد"
:max="maxBirthDate"
:error="errors.birthDate"
/>
</div>
<div class="personal-info__cell">
<TextField
v-model="form.nationalCode"
name="nationalCode"
label="کد ملی"
inputmode="numeric"
:convert-digits="true"
:error="errors.nationalCode"
@blur="validateAt('nationalCode', form.nationalCode)"
>
<template #appendIcon>
<SvgIcon name="user" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
</div>
<div class="personal-info__cell">
<SelectField
v-model="form.maritalStatus"
name="maritalStatus"
label="وضعیت تاهل"
:options="maritalStatusOptions"
option-label="label"
option-value="value"
:error="errors.maritalStatus"
/>
</div>
<div class="personal-info__cell">
<SelectField
v-model="form.gender"
name="gender"
label="جنسیت"
:options="genderOptions"
option-label="label"
option-value="value"
:error="errors.gender"
/>
</div>
</div>
<LineTitleBlock title="اطلاعات تماس" title-en="Contact Details" />
<div class="personal-info__row">
<div class="personal-info__cell personal-info__cell--full-sm">
<TextField
v-model="form.phoneNumber"
name="phoneNumber"
label="شماره تلفن همراه"
inputmode="numeric"
:convert-digits="true"
:disabled="!!initialPhoneNumber"
:error="errors.phoneNumber"
@blur="validateAt('phoneNumber', form.phoneNumber)"
>
<template #appendIcon>
<SvgIcon name="phone" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
</div>
<div class="personal-info__cell">
<SelectField
v-model="form.provinceId"
name="provinceId"
label="استان"
:options="provinces"
option-label="name"
option-value="id"
:error="errors.provinceId"
@change="onProvinceChange"
/>
</div>
<div class="personal-info__cell">
<SelectField
v-model="form.cityId"
name="cityId"
label="شهر"
:options="cities"
option-label="name"
option-value="id"
:disabled="!form.provinceId"
:error="errors.cityId"
/>
</div>
<div class="personal-info__cell personal-info__cell--full">
<TextareaField
v-model="form.address"
name="address"
label="آدرس محل سکونت"
:row="isMobile ? 5 : 1"
:error="errors.address"
@blur="validateAt('address', form.address)"
>
<template #appendIcon>
<SvgIcon name="file" :size="20" color="var(--color-thd-gray)" />
</template>
</TextareaField>
</div>
</div>
</div>
</div>
<div class="personal-info__divider" />
<StepActions :show-back="false" :loading="uploadMutation.isPending.value" />
</form>
</template>
<script setup>
import { toast } from 'vue3-toastify'
import useYup from '@/composables/useYup'
import useAuth from '@/composables/useAuth'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { convertToJalali } from '@/utils/date-utils'
import TextField from '@/components/form/TextField.vue'
import SelectField from '@/components/form/SelectField.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import ImageCropper from '@/components/form/ImageCropper.vue'
import { objectToFormData } from '@/utils/object-to-formdata'
import TextareaField from '@/components/form/TextareaField.vue'
import { useUploadTemporaryMutation } from '@/services/query/auth'
import DatePickerField from '@/components/form/DatePickerField.vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { GENDER, MARITAL_STATUS, STUDENT_REGISTRATION } from '@/enums'
import { personalInformationSchema } from '@/features/auth/schema/student-register'
import StepActions from '@/features/auth/components/studentRegister/StepActions.vue'
import { useStudentRegistrationStore } from '@/features/auth/store/student-registration'
import { useGetCitiesOfProvinceQuery, useGetProvincesQuery } from '@/services/query/common'
const store = useStudentRegistrationStore()
const { user } = useAuth()
const isMobile = ref(window.innerWidth < 768)
const onResize = () => {
isMobile.value = window.innerWidth < 768
}
onMounted(() => window.addEventListener('resize', onResize))
onBeforeUnmount(() => window.removeEventListener('resize', onResize))
const maritalStatusOptions = Object.entries(MARITAL_STATUS).map(([value, label]) => ({
value,
label,
}))
const genderOptions = Object.entries(GENDER).map(([value, label]) => ({ value, label }))
const maxBirthDate = computed(() => {
const d = new Date()
d.setFullYear(d.getFullYear() - 15)
try {
return convertToJalali(d.toISOString())
} catch {
return d.toISOString()
}
})
const initialPhoneNumber = ref(user.value?.phoneNumber || store.personal.phoneNumber || '')
const form = ref({
firstName: store.personal.firstName || '',
lastName: store.personal.lastName || '',
birthDate: store.personal.birthDate || '',
nationalCode: store.personal.nationalCode || '',
maritalStatus: store.personal.maritalStatus || '',
gender: store.personal.gender || '',
phoneNumber: initialPhoneNumber.value,
provinceId: store.personal.provinceId || null,
cityId: store.personal.cityId || null,
address: store.personal.address || '',
avatar: store.personal.avatar || null,
avatarId: store.personal.avatarId || '',
})
const avatar = ref(store.personal.avatar || null)
const { validate, validateAt, errors } = useYup(personalInformationSchema)
const { data: provinces = ref([]) } = useGetProvincesQuery()
const provinceIdRef = ref(form.value.provinceId)
const { data: cities = ref([]) } = useGetCitiesOfProvinceQuery(provinceIdRef, {
enabled: () => !!provinceIdRef.value,
})
const onProvinceChange = () => {
provinceIdRef.value = form.value.provinceId
form.value.cityId = null
}
watch(
() => form.value.provinceId,
(val) => {
provinceIdRef.value = val
}
)
const uploadMutation = useUploadTemporaryMutation()
const onAvatarCropped = async (file) => {
try {
const fd = objectToFormData({ file, subType: 'avatar', context: 'verification' })
const response = await uploadMutation.mutateAsync(fd)
const payload = response?.data ?? response
const value = { url: payload?.url, uploadId: payload?.uploadId || payload?.id, ...payload }
avatar.value = value
form.value.avatar = value
form.value.avatarId = value.uploadId
} catch {
toast.error('بارگذاری تصویر با خطا مواجه شد')
}
}
const onAvatarError = (msg) => toast.error(msg)
const onSubmit = async () => {
const { isValid, payload } = await validate(form.value)
if (!isValid) return
store.personal = { ...store.personal, ...payload, avatar: form.value.avatar }
store.markStepAsCompleted(STUDENT_REGISTRATION.PERSONAL_INFORMATION)
store.goToNextStep()
}
store.updateCurrentStep(STUDENT_REGISTRATION.PERSONAL_INFORMATION)
</script>
<style lang="scss" scoped>
.personal-info {
display: flex;
flex-direction: column;
&__grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
@media (min-width: 1024px) {
grid-template-columns: 3fr 9fr;
}
}
&__avatar-col {
order: 2;
@media (min-width: 1024px) {
order: 1;
padding: 0.5rem;
}
}
&__main-col {
order: 1;
@media (min-width: 1024px) {
order: 2;
padding-inline-start: 1.25rem;
}
}
&__row {
display: grid;
grid-template-columns: 1fr;
gap: 0.5rem 0.625rem;
margin-bottom: 1rem;
@media (min-width: 768px) {
grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 1280px) {
grid-template-columns: repeat(3, 1fr);
}
}
&__cell--full {
grid-column: 1 / -1;
}
&__cell--full-sm {
@media (max-width: 767px) {
grid-column: 1 / -1;
}
}
&__divider {
border-block-end: 1px solid var(--color-thd-gray);
margin: 1rem 0 1.5rem;
}
}
</style>
@@ -0,0 +1,236 @@
<template>
<form class="professional-info" @submit.prevent="onSubmit">
<LineTitleBlock title="مشخصات شغلی" title-en="Job Details" />
<div class="professional-info__row">
<div class="professional-info__cell professional-info__cell--5">
<TextField
v-model="form.propagationExperienceYears"
name="propagationExperienceYears"
label="سابقه فعالیت تبلیغی شما چند سال می باشد؟"
inputmode="numeric"
:convert-digits="true"
:error="errors.propagationExperienceYears"
@blur="validateAt('propagationExperienceYears', form.propagationExperienceYears)"
>
<template #appendIcon>
<SvgIcon name="check-square" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
</div>
<div class="professional-info__cell professional-info__cell--7">
<TextareaField
v-model="form.propagationMethodDescription"
name="propagationMethodDescription"
label="لطفا توضیحاتی ناظر بر مدل فعالیت تبلیغی خود بفرمایید."
:row="isMobile ? 5 : 1"
:error="errors.propagationMethodDescription"
@blur="validateAt('propagationMethodDescription', form.propagationMethodDescription)"
>
<template #appendIcon>
<SvgIcon name="file" :size="20" color="var(--color-thd-gray)" />
</template>
</TextareaField>
</div>
<div class="professional-info__cell professional-info__cell--full">
<TextareaField
v-model="form.specializedTopics"
name="specializedTopics"
label="موضوعات تخصصی تبلیغی خود را بفرمایید."
:row="isMobile ? 5 : 1"
:error="errors.specializedTopics"
@blur="validateAt('specializedTopics', form.specializedTopics)"
>
<template #appendIcon>
<SvgIcon name="file" :size="20" color="var(--color-thd-gray)" />
</template>
</TextareaField>
</div>
<div class="professional-info__cell professional-info__cell--5">
<SelectField
v-model="form.propagationPlatforms"
name="propagationPlatforms"
label="در چه بستر هایی فعالیت تبلیغی داشته اید؟"
:options="platformOptions"
option-label="label"
option-value="value"
:multiple="true"
:error="errors.propagationPlatforms"
@change="onPlatformsChange"
/>
</div>
<div
:class="[
'professional-info__cell',
'professional-info__cell--7',
{ 'professional-info__cell--blurred': !hasOther },
]"
>
<TextareaField
v-model="form.otherPlatformDetails"
name="otherPlatformDetails"
label="نوع فعالیت خود را توضیح دهید."
:row="isMobile ? 5 : 1"
:disabled="!hasOther"
:error="errors.otherPlatformDetails"
>
<template #appendIcon>
<SvgIcon name="file" :size="20" color="var(--color-thd-gray)" />
</template>
</TextareaField>
</div>
<div
:class="[
'professional-info__cell',
'professional-info__cell--full',
{ 'professional-info__cell--blurred': !hasOnline },
]"
>
<TextareaField
v-model="form.onlinePlatformDetails"
name="onlinePlatformDetails"
label="نام شبکه و مدل فعالیت تبلیغی خود را ذکر کنید."
:row="isMobile ? 5 : 1"
:disabled="!hasOnline"
:error="errors.onlinePlatformDetails"
>
<template #appendIcon>
<SvgIcon name="file" :size="20" color="var(--color-thd-gray)" />
</template>
</TextareaField>
</div>
</div>
<div class="professional-info__divider" />
<StepActions @back="store.goToPrevStep()" />
</form>
</template>
<script setup>
import useYup from '@/composables/useYup'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import TextField from '@/components/form/TextField.vue'
import SelectField from '@/components/form/SelectField.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import TextareaField from '@/components/form/TextareaField.vue'
import { PROPAGATION_PLATFORM, STUDENT_REGISTRATION } from '@/enums'
import StepActions from '@/features/auth/components/studentRegister/StepActions.vue'
import { professionalInformationSchema } from '@/features/auth/schema/student-register'
import { useStudentRegistrationStore } from '@/features/auth/store/student-registration'
const store = useStudentRegistrationStore()
const isMobile = ref(window.innerWidth < 768)
const onResize = () => {
isMobile.value = window.innerWidth < 768
}
onMounted(() => window.addEventListener('resize', onResize))
onBeforeUnmount(() => window.removeEventListener('resize', onResize))
const platformOptions = Object.entries(PROPAGATION_PLATFORM).map(([value, label]) => ({
value,
label,
}))
const seedPlatforms = (store.professional.propagationPlatforms || []).map((p) => p.platform || p)
const seedOther =
store.professional.propagationPlatforms?.find((p) => p.platform === 'other')?.platformDetails ||
''
const seedOnline =
store.professional.propagationPlatforms?.find((p) => p.platform === 'online')?.platformDetails ||
''
const form = ref({
propagationExperienceYears: store.professional.propagationExperienceYears || '',
propagationMethodDescription: store.professional.propagationMethodDescription || '',
specializedTopics: store.professional.specializedTopics || '',
propagationPlatforms: seedPlatforms,
otherPlatformDetails: seedOther,
onlinePlatformDetails: seedOnline,
})
const hasOther = computed(() => form.value.propagationPlatforms.includes('other'))
const hasOnline = computed(() => form.value.propagationPlatforms.includes('online'))
const onPlatformsChange = (selected) => {
let next = Array.isArray(selected) ? [...selected] : []
const hasNone = next.includes('none')
const hasOthers = next.some((v) => v !== 'none')
if (hasNone && hasOthers) {
const lastSelected = next.at(-1)
next = lastSelected === 'none' ? ['none'] : next.filter((v) => v !== 'none')
}
form.value.propagationPlatforms = next
if (!next.includes('online')) form.value.onlinePlatformDetails = ''
if (!next.includes('other')) form.value.otherPlatformDetails = ''
}
const { validate, validateAt, errors } = useYup(professionalInformationSchema)
const onSubmit = async () => {
const { isValid, payload } = await validate(form.value)
if (!isValid) return
const propagationPlatforms = payload.propagationPlatforms.map((platform) => {
if (platform === 'online') {
return { platform: 'online', platformDetails: payload.onlinePlatformDetails || '' }
}
if (platform === 'other') {
return { platform: 'other', platformDetails: payload.otherPlatformDetails || '' }
}
return { platform, platformDetails: '' }
})
store.professional = {
propagationExperienceYears: Number(payload.propagationExperienceYears),
propagationMethodDescription: payload.propagationMethodDescription,
specializedTopics: payload.specializedTopics,
propagationPlatforms,
}
store.markStepAsCompleted(STUDENT_REGISTRATION.PROFESSIONAL_INFORMATION)
store.goToNextStep()
}
store.updateCurrentStep(STUDENT_REGISTRATION.PROFESSIONAL_INFORMATION)
</script>
<style lang="scss" scoped>
.professional-info {
&__row {
display: grid;
grid-template-columns: 1fr;
gap: 0.5rem 0.625rem;
margin-bottom: 1rem;
@media (min-width: 1024px) {
grid-template-columns: repeat(12, 1fr);
}
}
&__cell--5 {
@media (min-width: 1024px) {
grid-column: span 5;
}
}
&__cell--7 {
@media (min-width: 1024px) {
grid-column: span 7;
}
}
&__cell--full {
grid-column: 1 / -1;
}
&__cell--blurred {
filter: blur(2px);
transition: filter 0.2s ease;
}
&__divider {
border-block-end: 1px solid var(--color-thd-gray);
margin: 1rem 0 1.5rem;
}
}
</style>
@@ -0,0 +1,126 @@
<template>
<aside class="sidebar-steps">
<div class="sidebar-steps__mobile-logo">
<img :src="logoPinkishRed" alt="banu" />
</div>
<div class="sidebar-steps__panel">
<div class="sidebar-steps__logo">
<img :src="logoWhite" alt="banu" />
</div>
<ul class="sidebar-steps__list">
<li
v-for="tab in tabs"
:key="tab.key"
class="sidebar-steps__item"
:class="{ 'sidebar-steps__item--active': activeTab === tab.key }"
@click="emit('update:active-tab', tab.key)"
>
<SvgIcon :name="tab.icon" :size="22" color="#fff" />
</li>
</ul>
</div>
</aside>
</template>
<script setup>
import { gallery } from '@/utils/gallery'
import SvgIcon from '@/components/icons/SvgIcon.vue'
defineProps({
tabs: { type: Array, required: true },
activeTab: { type: String, default: '' },
})
const emit = defineEmits(['update:active-tab'])
const logoWhite = gallery.logoWhite
const logoPinkishRed = gallery.logoPinkishRed
</script>
<style lang="scss" scoped>
.sidebar-steps {
height: 100%;
&__mobile-logo {
display: flex;
justify-content: center;
margin-bottom: 0.5rem;
img {
height: 2.5rem;
object-fit: contain;
}
@media (min-width: 1024px) {
display: none;
}
}
&__panel {
background: var(--color-primary);
border-radius: 1rem;
padding: 0.75rem;
height: 100%;
display: flex;
align-items: center;
gap: 1rem;
@media (min-width: 1024px) {
flex-direction: column;
align-items: center;
gap: 1.5rem;
padding: 1.25rem 0.75rem;
border-radius: 1.5rem;
}
}
&__logo {
display: none;
@media (min-width: 1024px) {
display: flex;
justify-content: center;
padding-top: 0.5rem;
}
img {
height: 3.5rem;
object-fit: contain;
}
}
&__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 1rem;
flex: 1;
@media (min-width: 1024px) {
flex-direction: column;
justify-content: flex-start;
gap: 1.25rem;
padding-top: 1rem;
}
}
&__item {
cursor: pointer;
opacity: 0.5;
transition: opacity 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
&--active,
&:hover {
opacity: 1;
}
}
}
</style>
@@ -0,0 +1,25 @@
<template>
<SkillSelectStep
:step="STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_1"
store-key="skill1"
select-field="responseToLowSatisfaction"
desc-field="responseToLowSatisfactionDescription"
:intro="intro"
:options="options"
:schema="skillAssessmentPart1Schema"
/>
</template>
<script setup>
import { LOW_SATISFACTION_RESPONSE, STUDENT_REGISTRATION } from '@/enums'
import { skillAssessmentPart1Schema } from '@/features/auth/schema/student-register'
import SkillSelectStep from '@/features/auth/components/studentRegister/SkillSelectStep.vue'
const intro =
'اگر عملکرد شما در جلسات خانگی مورد ارزیابی توسط مخاطبین‌تان قرار بگیرد - با فرض اینکه شما به صحت و درستی روش، مسیر و مبانی خود ایمان و اعتقاد دارید - و در نهایت میزان رضایت‌شان پایین باشد، در این شرایط:'
const options = Object.entries(LOW_SATISFACTION_RESPONSE).map(([value, label]) => ({
value,
label,
}))
</script>
@@ -0,0 +1,25 @@
<template>
<SkillSelectStep
:step="STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_2"
store-key="skill2"
select-field="responseToSessionCancellation"
desc-field="responseToSessionCancellationDescription"
:intro="intro"
:options="options"
:schema="skillAssessmentPart2Schema"
/>
</template>
<script setup>
import { SESSION_CANCELLATION_RESPONSE, STUDENT_REGISTRATION } from '@/enums'
import { skillAssessmentPart2Schema } from '@/features/auth/schema/student-register'
import SkillSelectStep from '@/features/auth/components/studentRegister/SkillSelectStep.vue'
const intro =
'در شرایطی که صاحب بیت اعلام کند به دلیل تأمین نشدن هزینه‌های جلسه نمی‌تواند برای ادامه آن همکاری کند، در این صورت شما:'
const options = Object.entries(SESSION_CANCELLATION_RESPONSE).map(([value, label]) => ({
value,
label,
}))
</script>
@@ -0,0 +1,25 @@
<template>
<SkillSelectStep
:step="STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_3"
store-key="skill3"
select-field="responseToAudienceConflict"
desc-field="responseToAudienceConflictDescription"
:intro="intro"
:options="options"
:schema="skillAssessmentPart3Schema"
/>
</template>
<script setup>
import { AUDIENCE_CONFLICT_RESPONSE, STUDENT_REGISTRATION } from '@/enums'
import { skillAssessmentPart3Schema } from '@/features/auth/schema/student-register'
import SkillSelectStep from '@/features/auth/components/studentRegister/SkillSelectStep.vue'
const intro =
'اگر در جلسه شما تناقضی بین درخواست برخی از مخاطبین با عده‌ای دیگر بوجود آید به نحوی که مثلاً عده‌ای متقاضی «بیان معارف انقلابی و نقل از امامین انقلاب» و عده‌ای مخالف این کار و طالب «بیان آیات و روایات صرف» باشند / عده‌ای به دنبال شنیدن «صحبت‌های سخنران» و عده‌ای دوستدار شنیدن «صحبت های مداح» باشند، در این صورت شما:'
const options = Object.entries(AUDIENCE_CONFLICT_RESPONSE).map(([value, label]) => ({
value,
label,
}))
</script>
@@ -0,0 +1,25 @@
<template>
<SkillSelectStep
:step="STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_4"
store-key="skill4"
select-field="responseToCompetingPropagator"
desc-field="responseToCompetingPropagatorDescription"
:intro="intro"
:options="options"
:schema="skillAssessmentPart4Schema"
/>
</template>
<script setup>
import { COMPETING_PROPAGATOR_RESPONSE, STUDENT_REGISTRATION } from '@/enums'
import { skillAssessmentPart4Schema } from '@/features/auth/schema/student-register'
import SkillSelectStep from '@/features/auth/components/studentRegister/SkillSelectStep.vue'
const intro =
'در کنار جلسه شما، جلسه‌ای با حضور یک مبلّغ قوی برگزار می‌شود که به دلیل «استفاده از ابزارها و روش‌های نوآورانه»، توانسته در جذب مخاطب قوی باشد، در حالی که می‌دانید «در حوزه مبانی ضعف و نقص دارد» در این صورت شما:'
const options = Object.entries(COMPETING_PROPAGATOR_RESPONSE).map(([value, label]) => ({
value,
label,
}))
</script>
@@ -0,0 +1,99 @@
<template>
<form class="skill-five" @submit.prevent="onSubmit">
<LineTitleBlock title="مهارت‌ها و تخصص‌ها" title-en="Skills & Expertise" />
<div class="skill-five__row">
<TextareaField
v-model="form.responseToUnqualifiedAdvisors"
name="responseToUnqualifiedAdvisors"
label="در بستر و محیط جلسه شما افرادی هستند که بواسطه ارائه اذکار و اوراد به مردم برای حل مسائل و مشکلات زندگی‌شان، محل رجوع شده و توانسته‌اند فضایی ایجاد نمایند که مردم به جای مراجعه به متخصصین در هر موضوع یا تلاش برای حل مشکلات، رجوع به این افراد را ترجیح دهند. این در حالی است که می‌دانید برخی دستورات و تجویز‌های این افراد نادرست بوده و با مقاصد مادی یا غیر موجه به این امور می‌پردازند. متناسب با این موضوع، برنامه و عملکرد شما چیست؟ اگر خواسته باشید توصیه‌های مشابه از اساتید و علمای مطرح یا اذکار توصیه شده را به مردم بگویید چگونه مرز این دو را مشخص می‌کنید؟"
:row="isMobile ? 5 : 1"
:error="errors.responseToUnqualifiedAdvisors"
@blur="validateAt('responseToUnqualifiedAdvisors', form.responseToUnqualifiedAdvisors)"
>
<template #appendIcon>
<SvgIcon name="file" :size="20" color="var(--color-thd-gray)" />
</template>
</TextareaField>
<TextareaField
v-model="form.relevantCertificates"
name="relevantCertificates"
label="چه مدارک تحصیلی یا مهارتی و گواهینامه‌هایی را اگر داشته باشید می‌توانید با مخاطب تعامل داشته و بر او اثر بگذارید؟"
:row="isMobile ? 5 : 1"
:error="errors.relevantCertificates"
@blur="validateAt('relevantCertificates', form.relevantCertificates)"
>
<template #appendIcon>
<SvgIcon name="file" :size="20" color="var(--color-thd-gray)" />
</template>
</TextareaField>
<TextareaField
v-model="form.hijabApproach"
name="hijabApproach"
label="در مواجهه با «مسئله حجاب در جامعه» یا در مواجهه با «فرد بی‌حجاب در جلسه شما» چه می‌کنید؟ (ایده، منطق و روش خود را بیان فرمایید)"
:row="isMobile ? 5 : 1"
:error="errors.hijabApproach"
@blur="validateAt('hijabApproach', form.hijabApproach)"
>
<template #appendIcon>
<SvgIcon name="file" :size="20" color="var(--color-thd-gray)" />
</template>
</TextareaField>
</div>
<div class="skill-five__divider" />
<StepActions @back="store.goToPrevStep()" />
</form>
</template>
<script setup>
import useYup from '@/composables/useYup'
import { STUDENT_REGISTRATION } from '@/enums'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import TextareaField from '@/components/form/TextareaField.vue'
import StepActions from '@/features/auth/components/studentRegister/StepActions.vue'
import { skillAssessmentPart5Schema } from '@/features/auth/schema/student-register'
import { useStudentRegistrationStore } from '@/features/auth/store/student-registration'
const store = useStudentRegistrationStore()
const isMobile = ref(window.innerWidth < 768)
const onResize = () => {
isMobile.value = window.innerWidth < 768
}
onMounted(() => window.addEventListener('resize', onResize))
onBeforeUnmount(() => window.removeEventListener('resize', onResize))
const form = ref({ ...store.skill5 })
const { validate, validateAt, errors } = useYup(skillAssessmentPart5Schema)
const onSubmit = async () => {
const { isValid, payload } = await validate(form.value)
if (!isValid) return
store.skill5 = { ...store.skill5, ...payload }
store.markStepAsCompleted(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_5)
store.goToNextStep()
}
store.updateCurrentStep(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_5)
</script>
<style lang="scss" scoped>
.skill-five {
&__row {
display: flex;
flex-direction: column;
gap: 0.625rem;
margin-bottom: 1rem;
}
&__divider {
border-block-end: 1px solid var(--color-thd-gray);
margin: 1rem 0 1.5rem;
}
}
</style>
@@ -0,0 +1,151 @@
<template>
<form class="skill-six" @submit.prevent="onSubmit">
<div class="skill-six__grid">
<div class="skill-six__media-col">
<MediaRecorder
v-model="audio"
kind="audio"
sub-type="audio"
context="verification"
:max-seconds="180"
:error="errors.faithProductionAudio"
@uploaded="onUploaded"
/>
</div>
<div class="skill-six__content-col">
<LineTitleBlock title="مهارت‌ها و تخصص‌ها" title-en="Skills & Expertise" />
<p class="skill-six__intro">
لطفا ویدئو را مشاهده نمایید و در قالب صوت نظرات و تحلیل خود را بیان کرده و بفرمایید چگونه
میتوان از سرمایه ایمان الهی، عمل صالح تولید نمود؟
</p>
<div v-if="videoUrl" class="skill-six__video-wrap">
<video :src="videoUrl" controls playsinline class="skill-six__video" />
</div>
<div v-else class="skill-six__video-placeholder">
<p>ویدیویی در دسترس نیست</p>
</div>
</div>
</div>
<div class="skill-six__divider" />
<StepActions @back="store.goToPrevStep()" />
</form>
</template>
<script setup>
import useYup from '@/composables/useYup'
import { computed, ref, watch } from 'vue'
import { STUDENT_REGISTRATION } from '@/enums'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import { useGetRegistrationVideoQuery } from '@/services/query/auth'
import StepActions from '@/features/auth/components/studentRegister/StepActions.vue'
import { skillAssessmentPart6Schema } from '@/features/auth/schema/student-register'
import MediaRecorder from '@/features/auth/components/studentRegister/MediaRecorder.vue'
import { useStudentRegistrationStore } from '@/features/auth/store/student-registration'
const store = useStudentRegistrationStore()
const audio = ref(store.skill6.faithProductionAudio || null)
const form = ref({
faithProductionAudio: store.skill6.faithProductionAudio || null,
faithProductionId: store.skill6.faithProductionId || '',
})
const { validate, errors } = useYup(skillAssessmentPart6Schema)
const onUploaded = (value) => {
form.value.faithProductionAudio = value
form.value.faithProductionId = value?.uploadId || ''
}
const { data: media } = useGetRegistrationVideoQuery()
const videoUrl = computed(
() =>
media.value?.faithProductionVideoUrl || store.registrationMedia.faithProductionVideoUrl || ''
)
watch(media, (val) => {
if (val) store.registrationMedia = { ...store.registrationMedia, ...val }
})
const onSubmit = async () => {
const { isValid, payload } = await validate(form.value)
if (!isValid) return
store.skill6 = { ...store.skill6, ...payload }
store.markStepAsCompleted(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_6)
store.goToNextStep()
}
store.updateCurrentStep(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_6)
</script>
<style lang="scss" scoped>
.skill-six {
&__grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
@media (min-width: 1024px) {
grid-template-columns: 3fr 9fr;
}
}
&__media-col {
order: 2;
@media (min-width: 1024px) {
order: 1;
padding: 0.5rem;
}
}
&__content-col {
order: 1;
@media (min-width: 1024px) {
order: 2;
padding-inline-start: 1.25rem;
}
}
&__intro {
font-family: var(--font-family-fa);
font-weight: 300;
font-size: 0.875rem;
line-height: 1.7;
margin: 0 0 0.75rem;
text-align: justify;
}
&__video-wrap {
width: 100%;
border-radius: 1rem;
overflow: hidden;
background: #000;
}
&__video {
width: 100%;
height: auto;
display: block;
}
&__video-placeholder {
border-radius: 1rem;
background: #f3f4f6;
color: var(--color-thd-gray);
padding: 2rem;
text-align: center;
font-family: var(--font-family-fa);
font-size: 0.875rem;
}
&__divider {
border-block-end: 1px solid var(--color-thd-gray);
margin: 1rem 0 1.5rem;
}
}
</style>
@@ -0,0 +1,167 @@
<template>
<form class="skill-seven" @submit.prevent="onSubmit">
<div class="skill-seven__grid">
<div class="skill-seven__media-col">
<MediaRecorder
v-model="video"
kind="video"
sub-type="video"
context="verification"
:max-seconds="180"
:error="errors.leaderMessageVideo"
@uploaded="onUploaded"
/>
</div>
<div class="skill-seven__content-col">
<LineTitleBlock title="مهارت‌ها و تخصص‌ها" title-en="Skills & Expertise" />
<p class="skill-seven__intro">
لطفا درک و برداشت خود را از پیام رهبری به کنگره ۷ هزار زن شهید را در قالب یک ویدیو برای ما
ضبط کنید.
</p>
<div v-if="videoUrl" class="skill-seven__video-wrap">
<video :src="videoUrl" controls playsinline class="skill-seven__video" />
</div>
<div v-else class="skill-seven__video-placeholder">
<p>ویدیویی در دسترس نیست</p>
</div>
</div>
</div>
<div class="skill-seven__divider" />
<StepActions
:loading="completeMutation.isPending.value"
next-text="تایید اطلاعات"
@back="store.goToPrevStep()"
/>
</form>
</template>
<script setup>
import { toast } from 'vue3-toastify'
import useYup from '@/composables/useYup'
import { computed, ref, watch } from 'vue'
import useAuth from '@/composables/useAuth'
import { STUDENT_REGISTRATION } from '@/enums'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import StepActions from '@/features/auth/components/studentRegister/StepActions.vue'
import { skillAssessmentPart7Schema } from '@/features/auth/schema/student-register'
import MediaRecorder from '@/features/auth/components/studentRegister/MediaRecorder.vue'
import { useStudentRegistrationStore } from '@/features/auth/store/student-registration'
import { useCompleteProfileMutation, useGetRegistrationVideoQuery } from '@/services/query/auth'
const store = useStudentRegistrationStore()
const { applySession } = useAuth()
const video = ref(store.skill7.leaderMessageVideo || null)
const form = ref({
leaderMessageVideo: store.skill7.leaderMessageVideo || null,
leaderMessageId: store.skill7.leaderMessageId || '',
})
const { validate, errors } = useYup(skillAssessmentPart7Schema)
const onUploaded = (value) => {
form.value.leaderMessageVideo = value
form.value.leaderMessageId = value?.uploadId || ''
}
const { data: media } = useGetRegistrationVideoQuery()
const videoUrl = computed(
() => media.value?.leaderMessageVideoUrl || store.registrationMedia.leaderMessageVideoUrl || ''
)
watch(media, (val) => {
if (val) store.registrationMedia = { ...store.registrationMedia, ...val }
})
const completeMutation = useCompleteProfileMutation()
const onSubmit = async () => {
const { isValid, payload } = await validate(form.value)
if (!isValid) return
store.skill7 = { ...store.skill7, ...payload }
store.markStepAsCompleted(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_7)
try {
const response = await completeMutation.mutateAsync(store.buildCompletePayload())
const data = response?.data ?? response
applySession(data)
store.isProfileCompleted = true
} catch (error) {
toast.error(error?.response?.data?.message || 'عملیات با خطا مواجه شد')
}
}
store.updateCurrentStep(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_7)
</script>
<style lang="scss" scoped>
.skill-seven {
&__grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
@media (min-width: 1024px) {
grid-template-columns: 3fr 9fr;
}
}
&__media-col {
order: 2;
@media (min-width: 1024px) {
order: 1;
padding: 0.5rem;
}
}
&__content-col {
order: 1;
@media (min-width: 1024px) {
order: 2;
padding-inline-start: 1.25rem;
}
}
&__intro {
font-family: var(--font-family-fa);
font-weight: 300;
font-size: 0.875rem;
line-height: 1.7;
margin: 0 0 0.75rem;
text-align: justify;
}
&__video-wrap {
width: 100%;
border-radius: 1rem;
overflow: hidden;
background: #000;
}
&__video {
width: 100%;
height: auto;
display: block;
}
&__video-placeholder {
border-radius: 1rem;
background: #f3f4f6;
color: var(--color-thd-gray);
padding: 2rem;
text-align: center;
font-family: var(--font-family-fa);
font-size: 0.875rem;
}
&__divider {
border-block-end: 1px solid var(--color-thd-gray);
margin: 1rem 0 1.5rem;
}
}
</style>
@@ -0,0 +1,143 @@
<template>
<form class="skill-step" @submit.prevent="onSubmit">
<LineTitleBlock title="مهارت‌ها و تخصص‌ها" title-en="Skills & Expertise" />
<div class="skill-step__row">
<div class="skill-step__intro">
<p>{{ intro }}</p>
</div>
<div class="skill-step__select">
<SelectField
v-model="form[selectField]"
:name="selectField"
:options="options"
option-label="label"
option-value="value"
:error="errors[selectField]"
@change="onSelect"
/>
</div>
<div :class="['skill-step__description', { 'skill-step__description--blurred': !hasOther }]">
<TextareaField
v-model="form[descField]"
:name="descField"
label="کاری که انجام می‌دهید را بنویسید."
:row="isMobile ? 5 : 1"
:disabled="!hasOther"
:error="errors[descField]"
>
<template #appendIcon>
<SvgIcon name="file" :size="20" color="var(--color-thd-gray)" />
</template>
</TextareaField>
</div>
</div>
<div class="skill-step__divider" />
<StepActions @back="store.goToPrevStep()" />
</form>
</template>
<script setup>
import useYup from '@/composables/useYup'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import SelectField from '@/components/form/SelectField.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import TextareaField from '@/components/form/TextareaField.vue'
import StepActions from '@/features/auth/components/studentRegister/StepActions.vue'
import { useStudentRegistrationStore } from '@/features/auth/store/student-registration'
const props = defineProps({
step: { type: Number, required: true },
storeKey: { type: String, required: true },
selectField: { type: String, required: true },
descField: { type: String, required: true },
intro: { type: String, required: true },
options: { type: Array, required: true },
schema: { type: Object, required: true },
})
const store = useStudentRegistrationStore()
const isMobile = ref(window.innerWidth < 768)
const onResize = () => {
isMobile.value = window.innerWidth < 768
}
onMounted(() => window.addEventListener('resize', onResize))
onBeforeUnmount(() => window.removeEventListener('resize', onResize))
const seed = store[props.storeKey] || {}
const form = ref({
[props.selectField]: seed[props.selectField] || '',
[props.descField]: seed[props.descField] || '',
})
const hasOther = computed(() => form.value[props.selectField] === 'other')
const onSelect = () => {
if (!hasOther.value) form.value[props.descField] = ''
}
const { validate, errors } = useYup(props.schema)
const onSubmit = async () => {
const { isValid, payload } = await validate(form.value)
if (!isValid) return
store[props.storeKey] = { ...store[props.storeKey], ...payload }
store.markStepAsCompleted(props.step)
store.goToNextStep()
}
store.updateCurrentStep(props.step)
</script>
<style lang="scss" scoped>
.skill-step {
&__row {
display: grid;
grid-template-columns: 1fr;
gap: 0.5rem 0.625rem;
margin-bottom: 1rem;
@media (min-width: 1024px) {
grid-template-columns: repeat(12, 1fr);
}
}
&__intro {
@media (min-width: 1024px) {
grid-column: span 6;
}
p {
font-family: var(--font-family-fa);
font-weight: 300;
font-size: 0.875rem;
line-height: 1.7;
color: #a7a7a7;
text-align: justify;
margin: 0;
}
}
&__select {
@media (min-width: 1024px) {
grid-column: span 6;
}
}
&__description {
grid-column: 1 / -1;
transition: filter 0.2s ease;
&--blurred {
filter: blur(2px);
}
}
&__divider {
border-block-end: 1px solid var(--color-thd-gray);
margin: 1rem 0 1.5rem;
}
}
</style>
@@ -0,0 +1,76 @@
<template>
<div class="step-actions">
<div v-if="showBack" class="step-actions__back">
<BaseButton
variant="transparent"
type="button"
:text="backText"
custom-class="step-actions__back-btn"
@click="emit('back')"
>
<template #prependIcon>
<SvgIcon name="caret-right" :size="12" color="var(--color-sec-gray)" />
</template>
</BaseButton>
</div>
<div class="step-actions__next">
<BaseButton
type="submit"
:text="nextText"
:loading="loading"
custom-class="step-actions__next-btn"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
</template>
</BaseButton>
</div>
</div>
</template>
<script setup>
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
defineProps({
showBack: { type: Boolean, default: true },
loading: { type: Boolean, default: false },
nextText: { type: String, default: 'مرحله بعدی' },
backText: { type: String, default: 'مرحله قبلی' },
})
const emit = defineEmits(['back'])
</script>
<style lang="scss" scoped>
.step-actions {
display: flex;
gap: 0.5rem;
&__back {
flex: 1 1 60%;
display: flex;
justify-content: flex-start;
}
&__next {
flex: 1 1 40%;
max-width: 16rem;
margin-inline-start: auto;
@media (min-width: 768px) {
flex: 0 0 16rem;
}
}
&__back-btn {
color: #969696;
padding: 0 1rem;
width: fit-content;
}
&__next-btn {
width: 100%;
}
}
</style>
@@ -0,0 +1,128 @@
<template>
<BasicModal
width="85%"
max-width="48rem"
min-width="auto"
:closable="false"
:show-close-button="false"
>
<template #default>
<div class="register-success">
<img :src="flower" alt="" class="register-success__image" />
<div class="register-success__content">
<h3 class="register-success__title">از پاسخدهی شما ممنونیم.</h3>
<p class="register-success__desc">
اطلاعات و مدارک شما برای ما با موفقیت ارسال شده است. پس از بررسی و تأیید هویت و مدارک
توسط مجموعه، حساب کاربری شما فعال میشود
</p>
<div class="register-success__actions">
<BaseButton
text="خروج"
:loading="logoutMutation.isPending.value"
custom-class="register-success__btn"
@click="onLogout"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
</template>
</BaseButton>
</div>
</div>
</div>
</template>
</BasicModal>
</template>
<script setup>
import { useRouter } from 'vue-router'
import { gallery } from '@/utils/gallery'
import useAuth from '@/composables/useAuth'
import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { useLogoutMutation } from '@/services/query/auth'
import { useStudentRegistrationStore } from '@/features/auth/store/student-registration'
defineOptions({ name: 'StudentRegisterSuccessModal' })
const router = useRouter()
const { clearSession } = useAuth()
const store = useStudentRegistrationStore()
const logoutMutation = useLogoutMutation()
const flower = gallery.registrationCompleteFlower
const onLogout = async () => {
try {
await logoutMutation.mutateAsync()
} catch {
/* ignore */
} finally {
clearSession()
store.reset()
router.push({ name: 'login' })
}
}
</script>
<style lang="scss" scoped>
.register-success {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 0.5rem 0;
@media (min-width: 1024px) {
flex-direction: row;
align-items: center;
padding: 1rem 2rem;
gap: 1.5rem;
}
&__image {
height: 4.8rem;
width: auto;
object-fit: contain;
}
&__content {
text-align: center;
@media (min-width: 1024px) {
text-align: right;
flex: 1;
}
}
&__title {
font-family: var(--font-family-fa);
font-weight: 700;
font-size: 1.125rem;
margin: 0 0 0.5rem;
}
&__desc {
font-family: var(--font-family-fa);
font-size: 0.875rem;
line-height: 1.7;
color: #7c7c7c;
margin: 0 0 1rem;
text-align: justify;
}
&__actions {
display: flex;
justify-content: center;
@media (min-width: 1024px) {
justify-content: flex-start;
}
}
&__btn {
min-width: 10rem;
padding: 0 1.5rem;
}
}
</style>
@@ -1,6 +1,5 @@
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/store/auth'
import { useRoute, useRouter } from 'vue-router'
export const useRedirectAfterLogin = () => {
const router = useRouter()
+2 -3
View File
@@ -18,12 +18,11 @@
<script setup>
import { ref } from 'vue'
import { LOGIN_STEPS } from '@/enums'
import AuthShell from '@/features/auth/components/AuthShell.vue'
import LoginForm from '@/features/auth/components/LoginForm.vue'
import LoginWithVerifyCodeForm from '@/features/auth/components/LoginWithVerifyCodeForm.vue'
import VerifyCodeForm from '@/features/auth/components/VerifyCodeForm.vue'
import { LOGIN_STEPS } from '@/enums'
import LoginWithVerifyCodeForm from '@/features/auth/components/LoginWithVerifyCodeForm.vue'
const step = ref(LOGIN_STEPS.LOGIN)
const mode = ref('loginWithCode')
@@ -52,18 +52,18 @@
<script setup>
import { ref } from 'vue'
import AuthShell from '@/features/auth/components/AuthShell.vue'
import AuthHeading from '@/features/auth/components/AuthHeading.vue'
import AuthCopyright from '@/features/auth/components/AuthCopyright.vue'
import BaseButton from '@/components/BaseButton.vue'
import TextField from '@/components/form/TextField.vue'
import PasswordField from '@/components/form/PasswordField.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import useYup from '@/composables/useYup'
import useAuth from '@/composables/useAuth'
import { useResetPasswordMutation } from '@/services/query/auth'
import { useRedirectAfterLogin } from '@/features/auth/composables/useRedirectAfterLogin'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import TextField from '@/components/form/TextField.vue'
import { resetPasswordSchema } from '@/features/auth/schema'
import PasswordField from '@/components/form/PasswordField.vue'
import AuthShell from '@/features/auth/components/AuthShell.vue'
import { useResetPasswordMutation } from '@/services/query/auth'
import AuthHeading from '@/features/auth/components/AuthHeading.vue'
import AuthCopyright from '@/features/auth/components/AuthCopyright.vue'
import { useRedirectAfterLogin } from '@/features/auth/composables/useRedirectAfterLogin'
const { user, applySession } = useAuth()
const redirectAfterLogin = useRedirectAfterLogin()
+1 -2
View File
@@ -14,11 +14,10 @@
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { SIGNUP_STEPS } from '@/enums'
import AuthShell from '@/features/auth/components/AuthShell.vue'
import SignupForm from '@/features/auth/components/SignupForm.vue'
import VerifyCodeForm from '@/features/auth/components/VerifyCodeForm.vue'
import { SIGNUP_STEPS } from '@/enums'
const router = useRouter()
@@ -0,0 +1,218 @@
<template>
<div class="student-register">
<div class="student-register__container">
<aside class="student-register__sidebar">
<SideBarSteps :tabs="tabs" :active-tab="activeTab" @update:active-tab="goToStep" />
</aside>
<main class="student-register__main">
<header class="student-register__heading">
<h1 class="student-register__title">{{ stepLabel.title }}</h1>
<p class="student-register__desc">{{ stepLabel.desc }}</p>
</header>
<component :is="currentComponent" />
</main>
</div>
<SuccessModal v-if="isModal('StudentRegisterSuccessModal')" />
</div>
</template>
<script setup>
import useModal from '@/composables/useModal'
import { computed, markRaw, watch } from 'vue'
import { STUDENT_REGISTRATION } from '@/enums'
import SideBarSteps from '@/features/auth/components/studentRegister/SideBarSteps.vue'
import SuccessModal from '@/features/auth/components/studentRegister/SuccessModal.vue'
import { useStudentRegistrationStore } from '@/features/auth/store/student-registration'
import PersonalInformation from '@/features/auth/components/studentRegister/PersonalInformation.vue'
import SkillAssessmentPart1 from '@/features/auth/components/studentRegister/SkillAssessmentPart1.vue'
import SkillAssessmentPart2 from '@/features/auth/components/studentRegister/SkillAssessmentPart2.vue'
import SkillAssessmentPart3 from '@/features/auth/components/studentRegister/SkillAssessmentPart3.vue'
import SkillAssessmentPart4 from '@/features/auth/components/studentRegister/SkillAssessmentPart4.vue'
import SkillAssessmentPart5 from '@/features/auth/components/studentRegister/SkillAssessmentPart5.vue'
import SkillAssessmentPart6 from '@/features/auth/components/studentRegister/SkillAssessmentPart6.vue'
import SkillAssessmentPart7 from '@/features/auth/components/studentRegister/SkillAssessmentPart7.vue'
import EducationalInformation from '@/features/auth/components/studentRegister/EducationalInformation.vue'
import ProfessionalInformation from '@/features/auth/components/studentRegister/ProfessionalInformation.vue'
const store = useStudentRegistrationStore()
const { isModal, openModal } = useModal()
const STEP_TO_COMPONENT = {
[STUDENT_REGISTRATION.PERSONAL_INFORMATION]: markRaw(PersonalInformation),
[STUDENT_REGISTRATION.EDUCATIONAL_INFORMATION]: markRaw(EducationalInformation),
[STUDENT_REGISTRATION.PROFESSIONAL_INFORMATION]: markRaw(ProfessionalInformation),
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_1]: markRaw(SkillAssessmentPart1),
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_2]: markRaw(SkillAssessmentPart2),
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_3]: markRaw(SkillAssessmentPart3),
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_4]: markRaw(SkillAssessmentPart4),
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_5]: markRaw(SkillAssessmentPart5),
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_6]: markRaw(SkillAssessmentPart6),
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_7]: markRaw(SkillAssessmentPart7),
}
const STEP_TO_TAB = {
[STUDENT_REGISTRATION.PERSONAL_INFORMATION]: 'personal',
[STUDENT_REGISTRATION.EDUCATIONAL_INFORMATION]: 'educational',
[STUDENT_REGISTRATION.PROFESSIONAL_INFORMATION]: 'professional',
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_1]: 'skill',
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_2]: 'skill',
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_3]: 'skill',
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_4]: 'skill',
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_5]: 'skill',
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_6]: 'skill',
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_7]: 'skill',
}
const TAB_TO_STEP = {
personal: STUDENT_REGISTRATION.PERSONAL_INFORMATION,
educational: STUDENT_REGISTRATION.EDUCATIONAL_INFORMATION,
professional: STUDENT_REGISTRATION.PROFESSIONAL_INFORMATION,
skill: STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_1,
}
const STEP_LABELS = {
[STUDENT_REGISTRATION.PERSONAL_INFORMATION]: {
title: 'اطلاعـــــات شخصـــــی',
desc: 'لطفاً اطلاعات شخصی خود را به‌صورت کامل و دقیق وارد کنید',
},
[STUDENT_REGISTRATION.EDUCATIONAL_INFORMATION]: {
title: 'اطلاعـــــات تحصیلــــی و شغلی',
desc: 'لطفاً اطلاعات تحصیلی و شغلی خود را به‌صورت کامل و دقیق وارد کنید',
},
[STUDENT_REGISTRATION.PROFESSIONAL_INFORMATION]: {
title: 'اطلاعات فعالیت تبلیغاتی و حرفه‌ای',
desc: 'لطفاً اطلاعات تبلیغاتی و حرفه‌ای خود را به‌صورت کامل و دقیق وارد کنید',
},
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_1]: {
title: 'بخش مهارت (بخش اول)',
desc: 'مهارت‌ها و قابلیت‌های خود را معرفی کنید.',
},
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_2]: {
title: 'بخش مهارت (بخش دوم)',
desc: 'مهارت‌ها و قابلیت‌های خود را معرفی کنید.',
},
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_3]: {
title: 'بخش مهارت (بخش سوم)',
desc: 'مهارت‌ها و قابلیت‌های خود را معرفی کنید.',
},
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_4]: {
title: 'بخش مهارت (بخش چهارم)',
desc: 'مهارت‌ها و قابلیت‌های خود را معرفی کنید.',
},
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_5]: {
title: 'بخش مهارت (بخش پنجم)',
desc: 'مهارت‌ها و قابلیت‌های خود را معرفی کنید.',
},
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_6]: {
title: 'بخش مهارت (بخش ششم)',
desc: 'مهارت‌ها و قابلیت‌های خود را معرفی کنید.',
},
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_7]: {
title: 'بخش مهارت (بخش هفتم)',
desc: 'مهارت‌ها و قابلیت‌های خود را معرفی کنید.',
},
}
const tabs = [
{ key: 'personal', icon: 'user' },
{ key: 'educational', icon: 'book' },
{ key: 'professional', icon: 'file' },
{ key: 'skill', icon: 'check-square' },
]
const activeTab = computed(() => STEP_TO_TAB[store.currentStep] || 'personal')
const currentComponent = computed(
() =>
STEP_TO_COMPONENT[store.currentStep] ||
STEP_TO_COMPONENT[STUDENT_REGISTRATION.PERSONAL_INFORMATION]
)
const stepLabel = computed(
() => STEP_LABELS[store.currentStep] || STEP_LABELS[STUDENT_REGISTRATION.PERSONAL_INFORMATION]
)
const goToStep = (tabKey) => {
const targetStep = TAB_TO_STEP[tabKey]
if (!targetStep) return
if (store.canGoToStep(targetStep)) {
store.updateCurrentStep(targetStep)
}
}
watch(
() => store.isProfileCompleted,
(val) => {
if (val) openModal('StudentRegisterSuccessModal')
},
{ immediate: true }
)
</script>
<style lang="scss" scoped>
.student-register {
min-height: 100dvh;
width: 100%;
&__container {
height: 100dvh;
max-width: 95%;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.5rem;
@media (min-width: 1024px) {
flex-direction: row;
align-items: stretch;
padding: 1rem;
gap: 0.75rem;
}
}
&__sidebar {
width: 100%;
@media (min-width: 1024px) {
width: 8.333%;
min-width: 5rem;
max-width: 6.5rem;
}
}
&__main {
flex: 1;
min-width: 0;
overflow-y: auto;
padding: 0.5rem;
@media (min-width: 1024px) {
padding: 1rem 1.5rem;
}
}
&__heading {
margin-bottom: 1rem;
}
&__title {
font-family: var(--font-family-fa);
font-weight: 500;
font-size: 1.125rem;
color: #4c4c4c;
margin: 0 0 0.25rem;
}
&__desc {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.75rem;
color: #adadad;
margin: 0;
}
}
</style>
+6
View File
@@ -17,4 +17,10 @@ export default [
component: () => import('@/features/auth/pages/ResetPasswordPage.vue'),
meta: { public: true, layout: 'public', title: 'تنظیم رمز عبور جدید' },
},
{
path: '/student-register',
name: 'student-register',
component: () => import('@/features/auth/pages/StudentRegisterPage.vue'),
meta: { layout: 'public', title: 'تکمیل ثبت نام' },
},
]
+1 -2
View File
@@ -1,7 +1,6 @@
import { object, string } from 'yup'
import { phoneNumberRule } from '@/constants/rules/phoneNumberRule'
import { passwordRule } from '@/constants/rules/passwordRule'
import { phoneNumberRule } from '@/constants/rules/phoneNumberRule'
import { passwordConfirmationRule } from '@/constants/rules/passwordConfirmationRule'
export const loginSchema = object().shape({
@@ -0,0 +1,115 @@
import { array, mixed, number, object, string } from 'yup'
import { phoneNumberRule } from '@/constants/rules/phoneNumberRule'
import { nationalCodeRule } from '@/constants/rules/nationalCodeRule'
const requiredString = (min = 0) => {
const base = string().required()
return min > 0 ? base.min(min) : base
}
export const personalInformationSchema = object().shape({
avatar: mixed().required(),
avatarId: mixed().notRequired(),
firstName: requiredString(3),
lastName: requiredString(3),
birthDate: string().required(),
nationalCode: nationalCodeRule,
maritalStatus: string().required(),
gender: string().required(),
phoneNumber: phoneNumberRule,
provinceId: mixed().required(),
cityId: mixed().required(),
address: requiredString(10),
})
export const educationalInformationSchema = object().shape({
educationStatus: string().required(),
seminaryLevel: string().required(),
universityLevel: string().required(),
universityName: requiredString(5),
fieldOfStudy: requiredString(5),
workExperienceSummary: requiredString(10),
activitySummary: requiredString(10),
})
export const professionalInformationSchema = object().shape({
propagationExperienceYears: number()
.typeError('سنوات تجربه تبلیغ باید عدد باشد')
.required()
.min(0)
.max(70),
propagationMethodDescription: requiredString(10),
specializedTopics: requiredString(10),
propagationPlatforms: array()
.of(string())
.min(1, 'حداقل یک بستر فعالیت را انتخاب کنید')
.max(6, 'حداکثر ۶ بستر فعالیت قابل انتخاب است')
.required()
.test(
'no-both-none-and-others',
'نمی‌توانید همزمان "فعالیتی نداشته‌ام" و سایر گزینه‌ها را انتخاب کنید',
(value) => {
if (!value) return false
const hasNone = value.includes('none')
const hasOthers = value.some((v) => v !== 'none')
return !(hasNone && hasOthers)
}
),
onlinePlatformDetails: string().when('propagationPlatforms', {
is: (val) => Array.isArray(val) && val.includes('online'),
then: (s) => s.required().min(10),
otherwise: (s) => s.notRequired().nullable(),
}),
otherPlatformDetails: string().when('propagationPlatforms', {
is: (val) => Array.isArray(val) && val.includes('other'),
then: (s) => s.required().min(10),
otherwise: (s) => s.notRequired().nullable(),
}),
})
const skillSelectWithOtherSchema = (selectField, descField) =>
object().shape({
[selectField]: string().required(),
[descField]: string().when(selectField, {
is: 'other',
then: (s) => s.required().min(10),
otherwise: (s) => s.notRequired().nullable(),
}),
})
export const skillAssessmentPart1Schema = skillSelectWithOtherSchema(
'responseToLowSatisfaction',
'responseToLowSatisfactionDescription'
)
export const skillAssessmentPart2Schema = skillSelectWithOtherSchema(
'responseToSessionCancellation',
'responseToSessionCancellationDescription'
)
export const skillAssessmentPart3Schema = skillSelectWithOtherSchema(
'responseToAudienceConflict',
'responseToAudienceConflictDescription'
)
export const skillAssessmentPart4Schema = skillSelectWithOtherSchema(
'responseToCompetingPropagator',
'responseToCompetingPropagatorDescription'
)
export const skillAssessmentPart5Schema = object().shape({
responseToUnqualifiedAdvisors: requiredString(10),
relevantCertificates: requiredString(10),
hijabApproach: requiredString(10),
})
export const skillAssessmentPart6Schema = object().shape({
faithProductionAudio: mixed().required(),
faithProductionId: mixed().notRequired(),
})
export const skillAssessmentPart7Schema = object().shape({
leaderMessageVideo: mixed().required(),
leaderMessageId: mixed().notRequired(),
})
@@ -0,0 +1,220 @@
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { STUDENT_REGISTRATION, STUDENT_REGISTRATION_ORDER } from '@/enums'
const STORAGE_KEY = 'student-registration'
const emptyState = () => ({
isProfileCompleted: false,
personal: {
avatar: null,
avatarId: '',
firstName: '',
lastName: '',
birthDate: '',
nationalCode: '',
maritalStatus: '',
gender: '',
phoneNumber: '',
provinceId: null,
cityId: null,
address: '',
},
educational: {
educationStatus: '',
seminaryLevel: '',
universityLevel: '',
universityName: '',
fieldOfStudy: '',
workExperienceSummary: '',
activitySummary: '',
},
professional: {
propagationExperienceYears: 0,
propagationMethodDescription: '',
specializedTopics: '',
propagationPlatforms: [],
},
skill1: {
responseToLowSatisfaction: '',
responseToLowSatisfactionDescription: '',
},
skill2: {
responseToSessionCancellation: '',
responseToSessionCancellationDescription: '',
},
skill3: {
responseToAudienceConflict: '',
responseToAudienceConflictDescription: '',
},
skill4: {
responseToCompetingPropagator: '',
responseToCompetingPropagatorDescription: '',
},
skill5: {
responseToUnqualifiedAdvisors: '',
relevantCertificates: '',
hijabApproach: '',
},
skill6: {
faithProductionAudio: null,
faithProductionId: '',
},
skill7: {
leaderMessageVideo: null,
leaderMessageId: '',
},
currentStep: STUDENT_REGISTRATION.PERSONAL_INFORMATION,
completedSteps: [],
registrationMedia: {},
})
const readPersisted = () => {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return null
return JSON.parse(raw)
} catch {
return null
}
}
export const useStudentRegistrationStore = defineStore('studentRegistration', () => {
const persisted = readPersisted() || {}
const initial = { ...emptyState(), ...persisted }
const isProfileCompleted = ref(initial.isProfileCompleted)
const personal = ref({ ...emptyState().personal, ...persisted.personal })
const educational = ref({ ...emptyState().educational, ...persisted.educational })
const professional = ref({ ...emptyState().professional, ...persisted.professional })
const skill1 = ref({ ...emptyState().skill1, ...persisted.skill1 })
const skill2 = ref({ ...emptyState().skill2, ...persisted.skill2 })
const skill3 = ref({ ...emptyState().skill3, ...persisted.skill3 })
const skill4 = ref({ ...emptyState().skill4, ...persisted.skill4 })
const skill5 = ref({ ...emptyState().skill5, ...persisted.skill5 })
const skill6 = ref({ ...emptyState().skill6, ...persisted.skill6 })
const skill7 = ref({ ...emptyState().skill7, ...persisted.skill7 })
const currentStep = ref(initial.currentStep)
const completedSteps = ref(initial.completedSteps || [])
const registrationMedia = ref(initial.registrationMedia || {})
const snapshot = computed(() => ({
isProfileCompleted: isProfileCompleted.value,
personal: personal.value,
educational: educational.value,
professional: professional.value,
skill1: skill1.value,
skill2: skill2.value,
skill3: skill3.value,
skill4: skill4.value,
skill5: skill5.value,
skill6: skill6.value,
skill7: skill7.value,
currentStep: currentStep.value,
completedSteps: completedSteps.value,
registrationMedia: registrationMedia.value,
}))
watch(
snapshot,
(value) => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(value))
} catch {
/* storage may be unavailable */
}
},
{ deep: true }
)
const markStepAsCompleted = (stepKey) => {
if (!completedSteps.value.includes(stepKey)) {
completedSteps.value = [...completedSteps.value, stepKey]
}
}
const updateCurrentStep = (stepKey) => {
if (stepKey) currentStep.value = stepKey
}
const goToNextStep = () => {
const idx = STUDENT_REGISTRATION_ORDER.indexOf(currentStep.value)
if (idx >= 0 && idx < STUDENT_REGISTRATION_ORDER.length - 1) {
currentStep.value = STUDENT_REGISTRATION_ORDER[idx + 1]
}
}
const goToPrevStep = () => {
const idx = STUDENT_REGISTRATION_ORDER.indexOf(currentStep.value)
if (idx > 0) currentStep.value = STUDENT_REGISTRATION_ORDER[idx - 1]
}
const canGoToStep = (stepKey) => {
const currentIndex = STUDENT_REGISTRATION_ORDER.indexOf(currentStep.value)
const targetIndex = STUDENT_REGISTRATION_ORDER.indexOf(stepKey)
return targetIndex <= currentIndex + 1
}
const buildCompletePayload = () => ({
...personal.value,
...educational.value,
...professional.value,
...skill1.value,
...skill2.value,
...skill3.value,
...skill4.value,
...skill5.value,
...skill6.value,
...skill7.value,
avatarId: personal.value.avatar?.uploadId
? Number.parseInt(personal.value.avatar.uploadId)
: personal.value.avatarId,
})
const reset = () => {
const fresh = emptyState()
isProfileCompleted.value = fresh.isProfileCompleted
personal.value = fresh.personal
educational.value = fresh.educational
professional.value = fresh.professional
skill1.value = fresh.skill1
skill2.value = fresh.skill2
skill3.value = fresh.skill3
skill4.value = fresh.skill4
skill5.value = fresh.skill5
skill6.value = fresh.skill6
skill7.value = fresh.skill7
currentStep.value = fresh.currentStep
completedSteps.value = fresh.completedSteps
registrationMedia.value = fresh.registrationMedia
try {
localStorage.removeItem(STORAGE_KEY)
} catch {
/* ignore */
}
}
return {
isProfileCompleted,
personal,
educational,
professional,
skill1,
skill2,
skill3,
skill4,
skill5,
skill6,
skill7,
currentStep,
completedSteps,
registrationMedia,
markStepAsCompleted,
updateCurrentStep,
goToNextStep,
goToPrevStep,
canGoToStep,
buildCompletePayload,
reset,
}
})