@@ -14,13 +14,13 @@
|
||||
</AuthHeading>
|
||||
|
||||
<TextField
|
||||
v-model="form.verifyCode"
|
||||
name="verifyCode"
|
||||
v-model="form.code"
|
||||
name="code"
|
||||
label="کد تایید را وارد کنید"
|
||||
inputmode="numeric"
|
||||
:convert-digits="true"
|
||||
:error="errors.verifyCode"
|
||||
@blur="validateAt('verifyCode', form.verifyCode)"
|
||||
:error="errors.code"
|
||||
@blur="validateAt('code', form.code)"
|
||||
>
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="check" :size="22" color="var(--color-thd-gray)" />
|
||||
@@ -74,7 +74,7 @@ const props = defineProps({
|
||||
const emit = defineEmits(['back', 'verified'])
|
||||
|
||||
const schema = verifyCodeSchema
|
||||
const form = ref({ verifyCode: '' })
|
||||
const form = ref({ code: '' })
|
||||
const { validate, validateAt, errors } = useYup(schema)
|
||||
|
||||
const { applySession } = useAuth()
|
||||
@@ -106,7 +106,7 @@ const resending = computed(
|
||||
const onSubmit = async () => {
|
||||
const { isValid, payload } = await validate(form.value)
|
||||
if (!isValid) return
|
||||
const code = payload.verifyCode
|
||||
const code = payload.code
|
||||
|
||||
if (props.mode === 'loginWithCode') {
|
||||
const response = await verifyLoginMutation.mutateAsync({
|
||||
@@ -127,8 +127,10 @@ const onSubmit = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
await verifySignupMutation.mutateAsync({ phone: props.phoneNumber, verifyCode: code })
|
||||
emit('verified', { phone: props.phoneNumber, verifyCode: code })
|
||||
const response = await verifySignupMutation.mutateAsync({ phone: props.phoneNumber, code })
|
||||
applySession(response?.data ?? response)
|
||||
emit('verified', { phone: props.phoneNumber, code })
|
||||
redirectAfterLogin()
|
||||
}
|
||||
|
||||
const onResend = async () => {
|
||||
|
||||
@@ -112,6 +112,32 @@ let mediaStream = null
|
||||
let chunks = []
|
||||
let timerHandle = null
|
||||
let timerStart = 0
|
||||
let recorderType = null
|
||||
|
||||
// The backend only accepts these extensions. Browsers can't record mp3/wav, but
|
||||
// most can record into an mp4 container (→ .m4a audio / .mp4 video), so prefer a
|
||||
// supported type whose extension the backend allows.
|
||||
const ACCEPTED_TYPES = {
|
||||
audio: [
|
||||
{ mime: 'audio/mp4', ext: 'm4a' },
|
||||
{ mime: 'audio/mpeg', ext: 'mp3' },
|
||||
{ mime: 'audio/wav', ext: 'wav' },
|
||||
],
|
||||
video: [
|
||||
{ mime: 'video/mp4', ext: 'mp4' },
|
||||
{ mime: 'video/webm', ext: 'webm' },
|
||||
],
|
||||
}
|
||||
|
||||
const pickRecorderType = (kind) => {
|
||||
const candidates = ACCEPTED_TYPES[kind] || []
|
||||
const supported = candidates.find((c) => window.MediaRecorder?.isTypeSupported?.(c.mime))
|
||||
if (supported) return supported
|
||||
// Last-resort fallbacks so recording still works even if extensions differ.
|
||||
return kind === 'video'
|
||||
? { mime: 'video/webm', ext: 'webm' }
|
||||
: { mime: 'audio/webm', ext: 'webm' }
|
||||
}
|
||||
|
||||
const hintText = computed(() =>
|
||||
props.kind === 'video' ? 'ویدیوی خود را ضبط کنید' : 'صدای خود را ضبط کنید'
|
||||
@@ -148,7 +174,8 @@ const onStart = async () => {
|
||||
liveEl.value.srcObject = mediaStream
|
||||
}
|
||||
chunks = []
|
||||
mediaRecorder = new window.MediaRecorder(mediaStream)
|
||||
recorderType = pickRecorderType(props.kind)
|
||||
mediaRecorder = new window.MediaRecorder(mediaStream, { mimeType: recorderType.mime })
|
||||
mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data?.size > 0) chunks.push(e.data)
|
||||
}
|
||||
@@ -176,9 +203,9 @@ const onStop = () => {
|
||||
const handleStop = async () => {
|
||||
isRecording.value = false
|
||||
stopTracks()
|
||||
const mime = props.kind === 'video' ? 'video/webm' : 'audio/webm'
|
||||
const mime = recorderType?.mime || mediaRecorder?.mimeType || 'audio/webm'
|
||||
const ext = recorderType?.ext || '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:')) {
|
||||
|
||||
@@ -17,24 +17,11 @@
|
||||
<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)"
|
||||
v-model="form.name"
|
||||
name="name"
|
||||
label="نام و نام خانوادگی"
|
||||
:error="errors.name"
|
||||
@blur="validateAt('name', form.name)"
|
||||
>
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="user" :size="20" color="var(--color-thd-gray)" />
|
||||
@@ -57,6 +44,7 @@
|
||||
label="کد ملی"
|
||||
inputmode="numeric"
|
||||
:convert-digits="true"
|
||||
:disabled="!!initialNationalCode"
|
||||
:error="errors.nationalCode"
|
||||
@blur="validateAt('nationalCode', form.nationalCode)"
|
||||
>
|
||||
@@ -153,7 +141,7 @@
|
||||
|
||||
<StepActions
|
||||
:show-back="false"
|
||||
:loading="uploadMutation.isPending.value || saveRegisterMutation.isPending.value"
|
||||
:loading="uploadMutation.isPending.value || updateProfileMutation.isPending.value"
|
||||
/>
|
||||
</form>
|
||||
</template>
|
||||
@@ -164,6 +152,7 @@ import useYup from '@/composables/useYup'
|
||||
import useAuth from '@/composables/useAuth'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import { convertToJalali } from '@/utils/date-utils'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import TextField from '@/components/form/TextField.vue'
|
||||
import SelectField from '@/components/form/SelectField.vue'
|
||||
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
||||
@@ -173,15 +162,15 @@ import TextareaField from '@/components/form/TextareaField.vue'
|
||||
import DatePickerField from '@/components/form/DatePickerField.vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { GENDER, MARITAL_STATUS, STUDENT_REGISTRATION } from '@/enums'
|
||||
import { toKeyValueList } from '@/features/auth/store/student-registration'
|
||||
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 { useSaveRegisterDataMutation, useUploadMediaMutation } from '@/services/query/auth'
|
||||
import { useGetCitiesOfProvinceQuery, useGetProvincesQuery } from '@/services/query/common'
|
||||
import { authKeys, useUpdateProfileMutation, useUploadMediaMutation } from '@/services/query/auth'
|
||||
|
||||
const store = useStudentRegistrationStore()
|
||||
const { user } = useAuth()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const isMobile = ref(window.innerWidth < 768)
|
||||
const onResize = () => {
|
||||
@@ -207,12 +196,12 @@ const maxBirthDate = computed(() => {
|
||||
})
|
||||
|
||||
const initialPhone = ref(user.value?.phone || store.personal.phone || '')
|
||||
const initialNationalCode = ref(user.value?.nationalCode || store.personal.nationalCode || '')
|
||||
|
||||
const form = ref({
|
||||
firstName: store.personal.firstName || '',
|
||||
lastName: store.personal.lastName || '',
|
||||
name: user.value?.name || store.personal.name || '',
|
||||
birthDate: store.personal.birthDate || '',
|
||||
nationalCode: store.personal.nationalCode || '',
|
||||
nationalCode: initialNationalCode.value,
|
||||
maritalStatus: store.personal.maritalStatus || '',
|
||||
gender: store.personal.gender || '',
|
||||
phone: initialPhone.value,
|
||||
@@ -264,13 +253,30 @@ const onAvatarCropped = async (file) => {
|
||||
|
||||
const onAvatarError = (msg) => toast.error(msg)
|
||||
|
||||
const saveRegisterMutation = useSaveRegisterDataMutation()
|
||||
const updateProfileMutation = useUpdateProfileMutation()
|
||||
|
||||
const onSubmit = async () => {
|
||||
const { isValid, payload } = await validate(form.value)
|
||||
if (!isValid) return
|
||||
const next = { ...payload, avatar: form.value.avatar }
|
||||
await saveRegisterMutation.mutateAsync(toKeyValueList(next))
|
||||
// Personal info maps to the user model and is persisted via PATCH /me.
|
||||
// (http auto-converts camelCase → snake_case: birthday, national_code, …)
|
||||
const body = {
|
||||
name: payload.name,
|
||||
birthday: payload.birthDate,
|
||||
nationalCode: payload.nationalCode,
|
||||
marriageStatus: payload.maritalStatus,
|
||||
gender: payload.gender,
|
||||
phone: payload.phone,
|
||||
provinceId: payload.provinceId,
|
||||
cityId: payload.cityId,
|
||||
address: payload.address,
|
||||
...(form.value.avatar?.uploadId
|
||||
? { avatarMediaId: Number.parseInt(form.value.avatar.uploadId) }
|
||||
: {}),
|
||||
}
|
||||
await updateProfileMutation.mutateAsync(body)
|
||||
await queryClient.invalidateQueries({ queryKey: authKeys.me() })
|
||||
store.personal = { ...store.personal, ...next }
|
||||
store.markStepAsCompleted(STUDENT_REGISTRATION.PERSONAL_INFORMATION)
|
||||
store.goToNextStep()
|
||||
|
||||
@@ -24,7 +24,7 @@ const router = useRouter()
|
||||
const step = ref(SIGNUP_STEPS.SIGNUP)
|
||||
const phoneNumber = ref('')
|
||||
|
||||
const handleNext = ({ phoneNumber: phone }) => {
|
||||
const handleNext = ({ phone }) => {
|
||||
phoneNumber.value = phone
|
||||
step.value = SIGNUP_STEPS.VERIFY_CODE
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export const signupSchema = object().shape({
|
||||
})
|
||||
|
||||
export const verifyCodeSchema = object().shape({
|
||||
verifyCode: string().required().length(6),
|
||||
code: string().required().length(6),
|
||||
})
|
||||
|
||||
export const resetPasswordSchema = object().shape({
|
||||
|
||||
@@ -11,13 +11,12 @@ const requiredString = (min = 0) => {
|
||||
export const personalInformationSchema = object().shape({
|
||||
avatar: mixed().notRequired(),
|
||||
avatarId: mixed().notRequired(),
|
||||
firstName: requiredString(3),
|
||||
lastName: requiredString(3),
|
||||
name: requiredString(3),
|
||||
birthDate: string().required(),
|
||||
nationalCode: nationalCodeRule,
|
||||
maritalStatus: string().required(),
|
||||
gender: string().required(),
|
||||
phoneNumber: phoneNumberRule,
|
||||
phone: phoneNumberRule,
|
||||
provinceId: mixed().required(),
|
||||
cityId: mixed().required(),
|
||||
address: requiredString(10),
|
||||
|
||||
@@ -9,8 +9,7 @@ const emptyState = () => ({
|
||||
personal: {
|
||||
avatar: null,
|
||||
avatarId: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
name: '',
|
||||
birthDate: '',
|
||||
nationalCode: '',
|
||||
maritalStatus: '',
|
||||
@@ -75,8 +74,7 @@ const SECTION_FIELDS = {
|
||||
personal: [
|
||||
'avatar',
|
||||
'avatarId',
|
||||
'firstName',
|
||||
'lastName',
|
||||
'name',
|
||||
'birthDate',
|
||||
'nationalCode',
|
||||
'maritalStatus',
|
||||
|
||||
Reference in New Issue
Block a user