diff --git a/src/components/form/SelectField.vue b/src/components/form/SelectField.vue index d7305a8..30e93ab 100644 --- a/src/components/form/SelectField.vue +++ b/src/components/form/SelectField.vue @@ -371,12 +371,15 @@ onBeforeUnmount(() => unmountFloating()) align-items: center; gap: 0.5rem; width: 100%; - padding: 0.25rem 0.75rem; + min-height: 2.5rem; + padding: 0.5rem 0.75rem; border: none; border-radius: 1.5rem; background: transparent; text-align: right; font-family: var(--font-family-fa); + font-weight: 400; + line-height: 1.5; cursor: pointer; transition: background-color 0.15s; diff --git a/src/enums/index.js b/src/enums/index.js index b71dc3f..21f7767 100644 --- a/src/enums/index.js +++ b/src/enums/index.js @@ -53,6 +53,8 @@ export const USER_STATUS = Object.freeze({ export const MARITAL_STATUS = Object.freeze({ single: 'مجرد', married: 'متاهل', + divorced: 'مطلقه', + widowed: 'بیوه', }) export const GENDER = Object.freeze({ diff --git a/src/features/admin/terms/components/modals/AttachCourseToTermModal.vue b/src/features/admin/terms/components/modals/AttachCourseToTermModal.vue index 933261f..ec03c25 100644 --- a/src/features/admin/terms/components/modals/AttachCourseToTermModal.vue +++ b/src/features/admin/terms/components/modals/AttachCourseToTermModal.vue @@ -163,8 +163,6 @@ const invalidate = () => { } const onAttach = async (template) => { - console.log(template) - if (!termId.value) return pendingId.value = template.id try { diff --git a/src/features/auth/components/studentRegister/EducationalInformation.vue b/src/features/auth/components/studentRegister/EducationalInformation.vue index fd8a942..beb5a71 100644 --- a/src/features/auth/components/studentRegister/EducationalInformation.vue +++ b/src/features/auth/components/studentRegister/EducationalInformation.vue @@ -89,9 +89,9 @@ diff --git a/src/features/auth/pages/StudentRegisterPage.vue b/src/features/auth/pages/StudentRegisterPage.vue index c2b7a9d..c5baec6 100644 --- a/src/features/auth/pages/StudentRegisterPage.vue +++ b/src/features/auth/pages/StudentRegisterPage.vue @@ -2,16 +2,26 @@
+
+ مرحله {{ currentStepNumber }} از {{ totalSteps }} + {{ progressPercent }}٪ +
+

{{ stepLabel.title }}

{{ stepLabel.desc }}

- +
+ در حال بازیابی اطلاعات ثبت‌نام… +
+
@@ -23,10 +33,10 @@ import useModal from '@/composables/useModal' import { computed, markRaw, watch } from 'vue' import { STUDENT_REGISTRATION } from '@/enums' -import { useGetRegisterDataQuery } from '@/services/query/auth' 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 { useGetRegisterDataQuery, useGetRegistrationVideoQuery } from '@/services/query/auth' 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' @@ -41,15 +51,21 @@ import ProfessionalInformation from '@/features/auth/components/studentRegister/ const store = useStudentRegistrationStore() const { isModal, openModal } = useModal() -const { data: registerData } = useGetRegisterDataQuery() +const { data: registerData, isPending: isRegistrationDataPending } = useGetRegisterDataQuery() watch( registerData, (value) => { if (value && Object.keys(value).length > 0) store.hydrateFromRegisterData(value) }, - { immediate: true } + { immediate: true, flush: 'sync' } ) +const { data: registrationMedia } = useGetRegistrationVideoQuery({ retry: false }) +watch(registrationMedia, (value) => store.setRegistrationMedia(value), { + immediate: true, + flush: 'sync', +}) + const STEP_TO_COMPONENT = { [STUDENT_REGISTRATION.PERSONAL_INFORMATION]: markRaw(PersonalInformation), [STUDENT_REGISTRATION.EDUCATIONAL_INFORMATION]: markRaw(EducationalInformation), @@ -63,26 +79,6 @@ const STEP_TO_COMPONENT = { [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: 'اطلاعـــــات شخصـــــی', @@ -126,14 +122,33 @@ const STEP_LABELS = { }, } -const tabs = [ - { key: 'personal', icon: 'user' }, - { key: 'educational', icon: 'book' }, - { key: 'professional', icon: 'file' }, - { key: 'skill', icon: 'check-square' }, +const STEP_NAVIGATION = [ + { step: STUDENT_REGISTRATION.PERSONAL_INFORMATION, title: 'مشخصات فردی', icon: 'user' }, + { step: STUDENT_REGISTRATION.EDUCATIONAL_INFORMATION, title: 'اطلاعات تحصیلی', icon: 'book' }, + { step: STUDENT_REGISTRATION.PROFESSIONAL_INFORMATION, title: 'فعالیت‌های تبلیغی', icon: 'file' }, + { step: STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_1, title: 'مهارت ۱', icon: 'check-square' }, + { step: STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_2, title: 'مهارت ۲', icon: 'check-square' }, + { step: STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_3, title: 'مهارت ۳', icon: 'check-square' }, + { step: STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_4, title: 'مهارت ۴', icon: 'check-square' }, + { step: STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_5, title: 'مهارت ۵', icon: 'check-square' }, + { step: STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_6, title: 'مهارت ۶', icon: 'check-square' }, + { step: STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_7, title: 'مهارت ۷', icon: 'check-square' }, ] -const activeTab = computed(() => STEP_TO_TAB[store.currentStep] || 'personal') +const tabs = computed(() => + STEP_NAVIGATION.map((tab) => ({ + ...tab, + completed: store.completedSteps.includes(tab.step), + disabled: !store.canGoToStep(tab.step), + })) +) + +const totalSteps = STEP_NAVIGATION.length +const currentStepNumber = computed(() => { + const index = STEP_NAVIGATION.findIndex((item) => item.step === store.currentStep) + return index >= 0 ? index + 1 : 1 +}) +const progressPercent = computed(() => Math.round((currentStepNumber.value / totalSteps) * 100)) const currentComponent = computed( () => @@ -145,12 +160,8 @@ 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) - } +const goToStep = (step) => { + store.updateCurrentStep(step) } watch( @@ -188,9 +199,8 @@ watch( width: 100%; @media (min-width: 1024px) { - width: 8.333%; - min-width: 5rem; - max-width: 6.5rem; + width: 13.5rem; + min-width: 13.5rem; } } @@ -206,23 +216,97 @@ watch( } &__heading { + margin-bottom: 1.25rem; + } + + &__step-meta { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.375rem; + color: #6f6f6f; + font-family: var(--font-family-number-fa); + font-size: 0.875rem; + font-weight: 500; + } + + &__progress { + width: 100%; + height: 0.35rem; margin-bottom: 1rem; + overflow: hidden; + border-radius: 999px; + background: #f2f2f2; + + span { + display: block; + height: 100%; + border-radius: inherit; + background: var(--color-primary); + transition: width 0.2s ease; + } } &__title { font-family: var(--font-family-fa); - font-weight: 500; - font-size: 1.125rem; - color: #4c4c4c; - margin: 0 0 0.25rem; + font-weight: 600; + font-size: 1.375rem; + color: #3f3f3f; + margin: 0 0 0.375rem; } &__desc { font-family: var(--font-family-fa); font-weight: 400; - font-size: 0.75rem; - color: #adadad; + font-size: 0.9375rem; + line-height: 1.8; + color: #747474; margin: 0; } + + &__loading { + padding: 3rem 1rem; + color: #747474; + font-family: var(--font-family-fa); + font-size: 1rem; + font-weight: 500; + text-align: center; + } + + &__main :deep(.text-field__label), + &__main :deep(.textarea-field__label), + &__main :deep(.select-field__label), + &__main :deep(.datepicker-field__label) { + color: #626262; + font-size: 0.9375rem; + font-weight: 400; + } + + &__main :deep(.text-field__input), + &__main :deep(.textarea-field__input), + &__main :deep(.select-field__trigger), + &__main :deep(.datepicker-field__trigger) { + min-height: 2.6rem; + font-size: 1rem; + font-weight: 400; + } + + &__main :deep(.line-title__fa) { + font-size: 1rem; + font-weight: 600; + } + + &__main :deep(.line-title__en) { + font-size: 0.8125rem; + font-weight: 400; + } + + &__main :deep(.text-field__error), + &__main :deep(.textarea-field__error), + &__main :deep(.select-field__error), + &__main :deep(.datepicker-field__error) { + font-size: 0.8125rem; + font-weight: 400; + } } diff --git a/src/features/auth/schema/student-register.js b/src/features/auth/schema/student-register.js index 133987d..7f60ad9 100644 --- a/src/features/auth/schema/student-register.js +++ b/src/features/auth/schema/student-register.js @@ -1,6 +1,5 @@ import { array, mixed, number, object, string } from 'yup' -import { phoneNumberRule } from '@/constants/rules/phoneNumberRule' import { nationalCodeRule } from '@/constants/rules/nationalCodeRule' const requiredString = (min = 0) => { @@ -16,7 +15,9 @@ export const personalInformationSchema = object().shape({ nationalCode: nationalCodeRule, maritalStatus: string().required(), gender: string().required(), - phone: phoneNumberRule, + // The verified phone is read-only here and may be returned in international + // format (for example +98912...). Do not re-validate it as a local 09 number. + phone: string().required(), provinceId: mixed().required(), cityId: mixed().required(), address: requiredString(10), @@ -118,11 +119,11 @@ export const skillAssessmentPart5Schema = object().shape({ }) export const skillAssessmentPart6Schema = object().shape({ - faithProductionAudio: mixed().required(), + faithProductionAudio: mixed().nullable().notRequired(), faithProductionId: mixed().notRequired(), }) export const skillAssessmentPart7Schema = object().shape({ - leaderMessageVideo: mixed().required(), + leaderMessageVideo: mixed().nullable().notRequired(), leaderMessageId: mixed().notRequired(), }) diff --git a/src/features/auth/store/student-registration.js b/src/features/auth/store/student-registration.js index ba9fb81..ab3a99c 100644 --- a/src/features/auth/store/student-registration.js +++ b/src/features/auth/store/student-registration.js @@ -145,6 +145,21 @@ const readPersisted = () => { } } +const normalizeCompletedSteps = (steps) => + Array.isArray(steps) ? STUDENT_REGISTRATION_ORDER.filter((step) => steps.includes(step)) : [] + +const firstIncompleteIndex = (steps) => { + const index = STUDENT_REGISTRATION_ORDER.findIndex((step) => !steps.includes(step)) + return index === -1 ? STUDENT_REGISTRATION_ORDER.length - 1 : index +} + +const resolveCurrentStep = (candidate, completed) => { + const candidateIndex = STUDENT_REGISTRATION_ORDER.indexOf(candidate) + const reachableIndex = firstIncompleteIndex(completed) + if (candidateIndex >= 0 && candidateIndex <= reachableIndex) return candidate + return STUDENT_REGISTRATION_ORDER[reachableIndex] +} + export const useStudentRegistrationStore = defineStore('studentRegistration', () => { const persisted = readPersisted() || {} const initial = { ...emptyState(), ...persisted } @@ -160,8 +175,8 @@ export const useStudentRegistrationStore = defineStore('studentRegistration', () 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 completedSteps = ref(normalizeCompletedSteps(initial.completedSteps)) + const currentStep = ref(resolveCurrentStep(initial.currentStep, completedSteps.value)) const registrationMedia = ref(initial.registrationMedia || {}) const sectionRefs = { @@ -207,18 +222,33 @@ export const useStudentRegistrationStore = defineStore('studentRegistration', () ) const markStepAsCompleted = (stepKey) => { - if (!completedSteps.value.includes(stepKey)) { + if (STUDENT_REGISTRATION_ORDER.includes(stepKey) && !completedSteps.value.includes(stepKey)) { completedSteps.value = [...completedSteps.value, stepKey] } } const updateCurrentStep = (stepKey) => { - if (stepKey) currentStep.value = stepKey + if (canGoToStep(stepKey)) currentStep.value = stepKey + } + + const updateSection = (section, value) => { + const target = sectionRefs[section] + if (!target || !value || typeof value !== 'object') return + target.value = { ...target.value, ...value } + } + + const setRegistrationMedia = (value) => { + if (!value || typeof value !== 'object') return + registrationMedia.value = { ...registrationMedia.value, ...value } } const goToNextStep = () => { const idx = STUDENT_REGISTRATION_ORDER.indexOf(currentStep.value) - if (idx >= 0 && idx < STUDENT_REGISTRATION_ORDER.length - 1) { + if ( + idx >= 0 && + idx < STUDENT_REGISTRATION_ORDER.length - 1 && + completedSteps.value.includes(currentStep.value) + ) { currentStep.value = STUDENT_REGISTRATION_ORDER[idx + 1] } } @@ -229,9 +259,8 @@ export const useStudentRegistrationStore = defineStore('studentRegistration', () } const canGoToStep = (stepKey) => { - const currentIndex = STUDENT_REGISTRATION_ORDER.indexOf(currentStep.value) const targetIndex = STUDENT_REGISTRATION_ORDER.indexOf(stepKey) - return targetIndex <= currentIndex + 1 + return targetIndex >= 0 && targetIndex <= firstIncompleteIndex(completedSteps.value) } const buildCompletePayload = () => ({ @@ -307,6 +336,8 @@ export const useStudentRegistrationStore = defineStore('studentRegistration', () registrationMedia, markStepAsCompleted, updateCurrentStep, + updateSection, + setRegistrationMedia, goToNextStep, goToPrevStep, canGoToStep, diff --git a/src/features/student/sessions/pages/StudentSessionDetailsPage.vue b/src/features/student/sessions/pages/StudentSessionDetailsPage.vue index 9ff3de9..77fa8fd 100644 --- a/src/features/student/sessions/pages/StudentSessionDetailsPage.vue +++ b/src/features/student/sessions/pages/StudentSessionDetailsPage.vue @@ -31,10 +31,7 @@ />
- +
diff --git a/src/services/mock/fixtures/me.js b/src/services/mock/fixtures/me.js index 532c9ae..6cbacd8 100644 --- a/src/services/mock/fixtures/me.js +++ b/src/services/mock/fixtures/me.js @@ -18,7 +18,7 @@ export const currentMe = { lastName: 'محمدی', fullName: 'سجاد محمدی', phoneNumber: '09123456789', - nationalCode: '0079827498', + nationalCode: '0079827497', status: 'approved', address: { address: 'خیابان آزادی، پلاک ۱۲', diff --git a/src/services/mock/routes/auth.js b/src/services/mock/routes/auth.js index 0d03b28..343d953 100644 --- a/src/services/mock/routes/auth.js +++ b/src/services/mock/routes/auth.js @@ -4,6 +4,7 @@ import { endpoints } from '@/services/api/endpoints' import { currentMe } from '@/services/mock/fixtures/me' const fakeToken = 'mock-token-1234567890' +let registerData = {} register('POST', endpoints.login, () => ({ data: { user: currentMe, token: fakeToken }, @@ -86,6 +87,13 @@ register('POST', endpoints.completeProfile, ({ data }) => ({ data: { user: { ...currentMe, ...data } }, })) +register('GET', endpoints.registerData, () => ({ data: registerData })) + +register('POST', endpoints.registerData, ({ data }) => { + registerData = { ...registerData, ...data?.data } + return { data: registerData } +}) + register('GET', endpoints.getRegistrationQuestionVideo, () => ({ data: { url: '', id: 0 }, })) diff --git a/src/services/query/auth.js b/src/services/query/auth.js index 9bf7009..c123830 100644 --- a/src/services/query/auth.js +++ b/src/services/query/auth.js @@ -60,10 +60,21 @@ export const useCompleteProfileMutation = () => useMutation({ mutationFn: apiCom export const useUpdateProfileMutation = () => useMutation({ mutationFn: apiUpdateProfile }) +const getRegistrationVideoIfAvailable = async () => { + try { + return await apiGetRegistrationQuestionVideo() + } catch (error) { + // The migrated backend does not expose this optional content endpoint yet. + // Treat a missing route as "no tutorial uploaded" so registration remains usable. + if (error?.response?.status === 404) return {} + throw error + } +} + export const useGetRegistrationVideoQuery = (options = {}) => useQuery({ queryKey: authKeys.registrationVideo(), - queryFn: () => apiGetRegistrationQuestionVideo(), + queryFn: getRegistrationVideoIfAvailable, select: (response) => response?.data ?? response, ...options, }) diff --git a/src/utils/date-convertor.js b/src/utils/date-convertor.js index b40353f..d8ee485 100644 --- a/src/utils/date-convertor.js +++ b/src/utils/date-convertor.js @@ -35,8 +35,7 @@ export function gregorianToJalaliString(isoString, separator = '/', persianDigit jd = jd.replace(/\d/g, (d) => enToFa[d]) } return `${jy}${separator}${jm}${separator}${jd}` - } catch (error) { - console.error('Date conversion error:', error) + } catch { return '' } } @@ -83,8 +82,7 @@ export function gregorianToJalaliStringLong(isoString, separator = '/', persianD } return ` ${jy}${separator}${jm}${separator}${jd}، ${hh}:${mm}` - } catch (error) { - console.error('Date conversion error:', error) + } catch { return '' } } @@ -98,8 +96,7 @@ export function JalaliToGregorianString(jalaliStr) { 2, '0' )}T00:00:00.000Z` - } catch (error) { - console.log('Jalali to Gregorian conversion error:', error) + } catch { return '' } } @@ -119,8 +116,7 @@ export function JalaliToGregorianStringWithTime(jalaliDateTimeStr) { 2, '0' )}T${timePart}:00.000Z` - } catch (error) { - console.log('Jalali to Gregorian with time conversion error:', error) + } catch { return '' } }