@@ -121,6 +121,23 @@ let mediaStream = null
|
|||||||
let chunks = []
|
let chunks = []
|
||||||
let timerHandle = null
|
let timerHandle = null
|
||||||
let timerStart = 0
|
let timerStart = 0
|
||||||
|
let recorderType = null
|
||||||
|
|
||||||
|
// The backend only accepts these audio extensions. Browsers can't record mp3/wav,
|
||||||
|
// but most can record into an mp4 container (→ .m4a), so prefer a supported type
|
||||||
|
// whose extension the backend allows; fall back to webm only as a last resort.
|
||||||
|
const ACCEPTED_AUDIO_TYPES = [
|
||||||
|
{ mime: 'audio/mp4', ext: 'm4a' },
|
||||||
|
{ mime: 'audio/mpeg', ext: 'mp3' },
|
||||||
|
{ mime: 'audio/wav', ext: 'wav' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const pickRecorderType = () => {
|
||||||
|
const supported = ACCEPTED_AUDIO_TYPES.find((c) =>
|
||||||
|
window.MediaRecorder?.isTypeSupported?.(c.mime)
|
||||||
|
)
|
||||||
|
return supported || { mime: 'audio/webm', ext: 'webm' }
|
||||||
|
}
|
||||||
|
|
||||||
const formattedTime = computed(() => {
|
const formattedTime = computed(() => {
|
||||||
const s = Math.floor(elapsedMs.value / 1000)
|
const s = Math.floor(elapsedMs.value / 1000)
|
||||||
@@ -163,16 +180,19 @@ const startRecording = async () => {
|
|||||||
try {
|
try {
|
||||||
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
chunks = []
|
chunks = []
|
||||||
mediaRecorder = new window.MediaRecorder(mediaStream)
|
recorderType = pickRecorderType()
|
||||||
|
mediaRecorder = new window.MediaRecorder(mediaStream, { mimeType: recorderType.mime })
|
||||||
mediaRecorder.ondataavailable = (e) => {
|
mediaRecorder.ondataavailable = (e) => {
|
||||||
if (e.data?.size > 0) chunks.push(e.data)
|
if (e.data?.size > 0) chunks.push(e.data)
|
||||||
}
|
}
|
||||||
mediaRecorder.onstop = () => {
|
mediaRecorder.onstop = () => {
|
||||||
isRecording.value = false
|
isRecording.value = false
|
||||||
stopTracks()
|
stopTracks()
|
||||||
const blob = new Blob(chunks, { type: 'audio/webm' })
|
const mime = recorderType?.mime || mediaRecorder?.mimeType || 'audio/webm'
|
||||||
const file = new File([blob], `recording-${Date.now()}.webm`, {
|
const ext = recorderType?.ext || 'webm'
|
||||||
type: 'audio/webm',
|
const blob = new Blob(chunks, { type: mime })
|
||||||
|
const file = new File([blob], `recording-${Date.now()}.${ext}`, {
|
||||||
|
type: mime,
|
||||||
lastModified: Date.now(),
|
lastModified: Date.now(),
|
||||||
})
|
})
|
||||||
setRecording(file)
|
setRecording(file)
|
||||||
|
|||||||
@@ -21,10 +21,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="assignment-details__grid">
|
<div class="assignment-details__grid">
|
||||||
<LineInfoBlock
|
|
||||||
title="تاریخ شروع"
|
|
||||||
:numeric-desc="formatJalaaliDate(assignment.startDate) || '—'"
|
|
||||||
/>
|
|
||||||
<LineInfoBlock title="تاریخ پایان" :numeric-desc="deadlineLabel" />
|
<LineInfoBlock title="تاریخ پایان" :numeric-desc="deadlineLabel" />
|
||||||
<LineInfoBlock title="مدت زمان" :numeric-desc="durationLabel" />
|
<LineInfoBlock title="مدت زمان" :numeric-desc="durationLabel" />
|
||||||
<LineInfoBlock title="اولویت" :desc="priorityLabel" />
|
<LineInfoBlock title="اولویت" :desc="priorityLabel" />
|
||||||
|
|||||||
@@ -198,7 +198,6 @@ const normalizeExistingQuestions = (raw = []) => {
|
|||||||
optionText: o.optionText || '',
|
optionText: o.optionText || '',
|
||||||
isCorrect: !!o.isCorrect,
|
isCorrect: !!o.isCorrect,
|
||||||
})),
|
})),
|
||||||
// No `__local` flag — these came from the server, so the builder will lock them.
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -280,7 +279,6 @@ const submitting = computed(
|
|||||||
|
|
||||||
const postQuestionsSequentially = async (id, list) => {
|
const postQuestionsSequentially = async (id, list) => {
|
||||||
for (const payload of list) {
|
for (const payload of list) {
|
||||||
// Sequential so question position ordering is preserved on the backend.
|
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
await addQuestionMutation.mutateAsync({ examId: id, payload })
|
await addQuestionMutation.mutateAsync({ examId: id, payload })
|
||||||
}
|
}
|
||||||
@@ -302,7 +300,7 @@ const onSubmit = async () => {
|
|||||||
if (targetExamId && newQuestions.length > 0) {
|
if (targetExamId && newQuestions.length > 0) {
|
||||||
await postQuestionsSequentially(targetExamId, newQuestions)
|
await postQuestionsSequentially(targetExamId, newQuestions)
|
||||||
}
|
}
|
||||||
await queryClient.invalidateQueries({ queryKey: adminExamsKeys.all })
|
await queryClient.invalidateQueries({ queryKey: adminExamsKeys.all, refetchType: 'all' })
|
||||||
router.push({ name: 'admin-exams' })
|
router.push({ name: 'admin-exams' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ const { data, isLoading } = useAdminTicketsListQuery(filters, pagination, {
|
|||||||
keepPreviousData: true,
|
keepPreviousData: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const tickets = computed(() => data.value?.data ?? [])
|
const tickets = computed(() => data.value?.items ?? [])
|
||||||
|
|
||||||
const paginationMeta = computed(() => ({
|
const paginationMeta = computed(() => ({
|
||||||
page: pagination.value.page,
|
page: pagination.value.page,
|
||||||
perPage: pagination.value.perPage,
|
perPage: pagination.value.perPage,
|
||||||
|
|||||||
@@ -39,11 +39,9 @@
|
|||||||
:src="mediaUrl"
|
:src="mediaUrl"
|
||||||
:video-id="session.id"
|
:video-id="session.id"
|
||||||
/>
|
/>
|
||||||
<VoiceRecorder
|
<div v-else-if="contentType === 'voice'" class="session-details__voice">
|
||||||
v-else-if="contentType === 'voice'"
|
<VoiceRecorder :model-value="mediaUrl" :disabled="true" />
|
||||||
:model-value="mediaUrl"
|
</div>
|
||||||
:disabled="true"
|
|
||||||
/>
|
|
||||||
<a
|
<a
|
||||||
v-else-if="mediaUrl"
|
v-else-if="mediaUrl"
|
||||||
:href="mediaUrl"
|
:href="mediaUrl"
|
||||||
@@ -111,9 +109,9 @@ const courseTitle = computed(() => session.value?.course?.title || '—')
|
|||||||
const isOnline = computed(() => session.value?.type === 'online')
|
const isOnline = computed(() => session.value?.type === 'online')
|
||||||
|
|
||||||
const collectionToContentType = (collectionName) => {
|
const collectionToContentType = (collectionName) => {
|
||||||
if (collectionName === 'videos') return 'video'
|
if (collectionName === 'video') return 'video'
|
||||||
if (collectionName === 'voice') return 'voice'
|
if (collectionName === 'voice') return 'voice'
|
||||||
if (collectionName === 'pdfs') return 'text'
|
if (collectionName === 'pdf') return 'text'
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,6 +171,22 @@ const mediaFileName = computed(() => contentMedia.value?.fileName || 'فایل
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__voice {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
/* stylelint-disable-next-line selector-class-pattern */
|
||||||
|
:deep(.voice-recorder) {
|
||||||
|
min-height: auto;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* stylelint-disable-next-line selector-class-pattern */
|
||||||
|
:deep(.voice-recorder__main-btn) {
|
||||||
|
width: 4rem;
|
||||||
|
height: 4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__file {
|
&__file {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -357,7 +357,10 @@ const onSubmit = async () => {
|
|||||||
} else {
|
} else {
|
||||||
await addMutation.mutateAsync(payload)
|
await addMutation.mutateAsync(payload)
|
||||||
}
|
}
|
||||||
await queryClient.invalidateQueries({ queryKey: adminSessionsKeys.all })
|
// The list query is inactive while we're on the form page, and the global
|
||||||
|
// `refetchOnMount: false` means navigating back won't refetch a merely-stale
|
||||||
|
// query — so force a refetch of all matching queries, active or not.
|
||||||
|
await queryClient.invalidateQueries({ queryKey: adminSessionsKeys.all, refetchType: 'all' })
|
||||||
router.push({ name: 'admin-sessions' })
|
router.push({ name: 'admin-sessions' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+50
-45
@@ -17,19 +17,19 @@
|
|||||||
<LineInfoBlock title="نام و نام خانوادگی" :numeric-desc="fullName" />
|
<LineInfoBlock title="نام و نام خانوادگی" :numeric-desc="fullName" />
|
||||||
<LineInfoBlock
|
<LineInfoBlock
|
||||||
title="تاریخ تولد"
|
title="تاریخ تولد"
|
||||||
:numeric-desc="profile.faBirthDate || formatJalaaliDate(profile.birthDate) || ''"
|
:numeric-desc="formatJalaaliDate(personal.birthDate) || ''"
|
||||||
/>
|
/>
|
||||||
<LineInfoBlock title="شماره تماس" :numeric-desc="user.phoneNumber" />
|
<LineInfoBlock title="شماره تماس" :numeric-desc="personal.phone" />
|
||||||
<LineInfoBlock title="کد ملی" :numeric-desc="user.nationalCode" />
|
<LineInfoBlock title="کد ملی" :numeric-desc="personal.nationalCode" />
|
||||||
<LineInfoBlock
|
<LineInfoBlock
|
||||||
title="وضعیت تاهل"
|
title="وضعیت تاهل"
|
||||||
:desc="profile.faMaritalStatus || MARITAL_STATUS[profile.maritalStatus] || ''"
|
:desc="MARITAL_STATUS[personal.maritalStatus] || ''"
|
||||||
/>
|
/>
|
||||||
<LineInfoBlock title="جنسیت" :desc="profile.faGender || GENDER[profile.gender] || ''" />
|
<LineInfoBlock title="جنسیت" :desc="GENDER[personal.gender] || ''" />
|
||||||
<LineInfoBlock title="استان" :desc="user?.province?.name || '-'" />
|
<LineInfoBlock title="استان" :desc="personal.province || '-'" />
|
||||||
<LineInfoBlock title="شهر" :desc="user?.city?.name || '-'" />
|
<LineInfoBlock title="شهر" :desc="personal.city || '-'" />
|
||||||
<div class="user-verification-details__cell user-verification-details__cell--span-3">
|
<div class="user-verification-details__cell user-verification-details__cell--span-3">
|
||||||
<LineInfoBlock title="آدرس" :desc="user?.address || '-'" />
|
<LineInfoBlock title="آدرس" :desc="personal.address || '-'" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -39,15 +39,15 @@
|
|||||||
<div class="user-verification-details__grid user-verification-details__grid--4">
|
<div class="user-verification-details__grid user-verification-details__grid--4">
|
||||||
<LineInfoBlock
|
<LineInfoBlock
|
||||||
title="وضعیت تحصیلی"
|
title="وضعیت تحصیلی"
|
||||||
:desc="profile.faEducationStatus || EDUCATION_STATUS[profile.educationStatus] || ''"
|
:desc="EDUCATION_STATUS[profile.educationStatus] || ''"
|
||||||
/>
|
/>
|
||||||
<LineInfoBlock
|
<LineInfoBlock
|
||||||
title="آخرین مدرک تحصیلی (طلبه)"
|
title="آخرین مدرک تحصیلی (طلبه)"
|
||||||
:desc="profile.faSeminaryLevel || SEMINARY_LEVEL[profile.seminaryLevel] || ''"
|
:desc="SEMINARY_LEVEL[profile.seminaryLevel] || ''"
|
||||||
/>
|
/>
|
||||||
<LineInfoBlock
|
<LineInfoBlock
|
||||||
title="آخرین مدرک تحصیلی"
|
title="آخرین مدرک تحصیلی"
|
||||||
:desc="profile.faUniversityLevel || UNIVERSITY_LEVEL[profile.universityLevel] || ''"
|
:desc="UNIVERSITY_LEVEL[profile.universityLevel] || ''"
|
||||||
/>
|
/>
|
||||||
<LineInfoBlock title="نام حوزه علمیه/دانشگاه" :desc="profile.universityName || ''" />
|
<LineInfoBlock title="نام حوزه علمیه/دانشگاه" :desc="profile.universityName || ''" />
|
||||||
<div class="user-verification-details__cell user-verification-details__cell--full">
|
<div class="user-verification-details__cell user-verification-details__cell--full">
|
||||||
@@ -56,30 +56,12 @@
|
|||||||
:desc="profile.fieldOfStudy || ''"
|
:desc="profile.fieldOfStudy || ''"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-verification-details__cell user-verification-details__cell--full">
|
|
||||||
<LineInfoBlock
|
|
||||||
title="خلاصهای از سوابق شغلی"
|
|
||||||
:desc="profile.workExperienceSummary || ''"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="user-verification-details__cell user-verification-details__cell--full">
|
|
||||||
<LineInfoBlock
|
|
||||||
title="خلاصهای از فعالیتها در موقعیتهای شغلی"
|
|
||||||
:desc="profile.activitySummary || ''"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="user-verification-details__section">
|
<section class="user-verification-details__section">
|
||||||
<LineTitleBlock title="اطلاعات شغلی" title-en="Job information" />
|
<LineTitleBlock title="اطلاعات شغلی" title-en="Job information" />
|
||||||
<div class="user-verification-details__grid user-verification-details__grid--4">
|
<div class="user-verification-details__grid user-verification-details__grid--4">
|
||||||
<div class="user-verification-details__cell user-verification-details__cell--full">
|
|
||||||
<LineInfoBlock
|
|
||||||
title="گرایش تخصصی حوزوی / رشته تحصیلی دانشگاهی"
|
|
||||||
:desc="profile.fieldOfStudy || ''"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="user-verification-details__cell user-verification-details__cell--full">
|
<div class="user-verification-details__cell user-verification-details__cell--full">
|
||||||
<LineInfoBlock
|
<LineInfoBlock
|
||||||
title="خلاصهای از سوابق شغلی"
|
title="خلاصهای از سوابق شغلی"
|
||||||
@@ -190,7 +172,9 @@
|
|||||||
<section v-if="faithProduction?.url" class="user-verification-details__section">
|
<section v-if="faithProduction?.url" class="user-verification-details__section">
|
||||||
<LineTitleBlock title="بخش صوت" title-en="Audio section" />
|
<LineTitleBlock title="بخش صوت" title-en="Audio section" />
|
||||||
<LineInfoBlock title="نظر و تحلیل از ویدئو ارائه شده" />
|
<LineInfoBlock title="نظر و تحلیل از ویدئو ارائه شده" />
|
||||||
<audio :src="faithProduction.url" controls class="user-verification-details__audio" />
|
<div class="user-verification-details__voice">
|
||||||
|
<VoiceRecorder :model-value="faithProduction" disabled />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="leaderMessage?.url" class="user-verification-details__section">
|
<section v-if="leaderMessage?.url" class="user-verification-details__section">
|
||||||
@@ -214,6 +198,7 @@ import useModal from '@/composables/useModal'
|
|||||||
import BasicModal from '@/components/BasicModal.vue'
|
import BasicModal from '@/components/BasicModal.vue'
|
||||||
import { formatJalaaliDate } from '@/utils/date-utils'
|
import { formatJalaaliDate } from '@/utils/date-utils'
|
||||||
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
||||||
|
import VoiceRecorder from '@/components/form/VoiceRecorder.vue'
|
||||||
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
|
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
|
||||||
import { useAdminUserQuery, useAdminUserRegisterDataQuery } from '@/services/query/admin-users'
|
import { useAdminUserQuery, useAdminUserRegisterDataQuery } from '@/services/query/admin-users'
|
||||||
import {
|
import {
|
||||||
@@ -227,7 +212,6 @@ import {
|
|||||||
SEMINARY_LEVEL,
|
SEMINARY_LEVEL,
|
||||||
SESSION_CANCELLATION_RESPONSE,
|
SESSION_CANCELLATION_RESPONSE,
|
||||||
UNIVERSITY_LEVEL,
|
UNIVERSITY_LEVEL,
|
||||||
VERIFICATION_MEDIA_TYPE,
|
|
||||||
} from '@/enums'
|
} from '@/enums'
|
||||||
|
|
||||||
defineOptions({ name: 'UserVerificationDetailsModal' })
|
defineOptions({ name: 'UserVerificationDetailsModal' })
|
||||||
@@ -248,15 +232,23 @@ const { data: registerData } = useAdminUserRegisterDataQuery(userIdRef, {
|
|||||||
|
|
||||||
const user = computed(() => modalData.value.user || fetched.value || null)
|
const user = computed(() => modalData.value.user || fetched.value || null)
|
||||||
|
|
||||||
// The register-data endpoint returns the answers the user gave during the
|
|
||||||
// registration steps — the same fields the sections below render. Merge it over
|
|
||||||
// the user's stored profile so reviewing admins see what was actually submitted.
|
|
||||||
const profile = computed(() => ({ ...user.value?.profile, ...registerData.value }))
|
const profile = computed(() => ({ ...user.value?.profile, ...registerData.value }))
|
||||||
|
|
||||||
const fullName = computed(
|
const fullName = computed(() => user.value?.name || '—')
|
||||||
() =>
|
const personal = computed(() => {
|
||||||
user.value?.name || `${user.value?.firstName || ''} ${user.value?.lastName || ''}`.trim() || '—'
|
const u = user.value || {}
|
||||||
)
|
const p = u.profile || {}
|
||||||
|
return {
|
||||||
|
birthDate: u.birthday || p.birthDate || '',
|
||||||
|
phone: u.phone || u.phoneNumber || '',
|
||||||
|
nationalCode: u.nationalCode || '',
|
||||||
|
maritalStatus: u.marriageStatus || p.maritalStatus || '',
|
||||||
|
gender: u.gender || p.gender || '',
|
||||||
|
address: typeof u.address === 'string' ? u.address : u.address?.address || '',
|
||||||
|
province: u.province?.name || u.address?.province?.name || '',
|
||||||
|
city: u.city?.name || u.address?.city?.name || '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const platforms = computed(() =>
|
const platforms = computed(() =>
|
||||||
Array.isArray(profile.value.propagationPlatforms) ? profile.value.propagationPlatforms : []
|
Array.isArray(profile.value.propagationPlatforms) ? profile.value.propagationPlatforms : []
|
||||||
@@ -276,13 +268,10 @@ const onlinePlatformDetails = computed(
|
|||||||
() => platforms.value.find((p) => p.platform === 'online')?.platformDetails || ''
|
() => platforms.value.find((p) => p.platform === 'online')?.platformDetails || ''
|
||||||
)
|
)
|
||||||
|
|
||||||
const verificationMedia = computed(() => user.value?.verification?.media || [])
|
// Skill parts 6 & 7 store the uploaded media objects in register-data under
|
||||||
const faithProduction = computed(() =>
|
// `faithProductionAudio` / `leaderMessageVideo` (each carries a `url`).
|
||||||
verificationMedia.value.find((m) => m.type === VERIFICATION_MEDIA_TYPE.FAITH_PRODUCTION)
|
const faithProduction = computed(() => profile.value.faithProductionAudio || null)
|
||||||
)
|
const leaderMessage = computed(() => profile.value.leaderMessageVideo || null)
|
||||||
const leaderMessage = computed(() =>
|
|
||||||
verificationMedia.value.find((m) => m.type === VERIFICATION_MEDIA_TYPE.LEADER_MESSAGE)
|
|
||||||
)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -428,6 +417,22 @@ const leaderMessage = computed(() =>
|
|||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__voice {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
|
||||||
|
/* stylelint-disable-next-line selector-class-pattern */
|
||||||
|
:deep(.voice-recorder) {
|
||||||
|
min-height: auto;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* stylelint-disable-next-line selector-class-pattern */
|
||||||
|
:deep(.voice-recorder__main-btn) {
|
||||||
|
width: 4rem;
|
||||||
|
height: 4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__divider {
|
&__divider {
|
||||||
border-block-end: 1px solid var(--color-thd-gray);
|
border-block-end: 1px solid var(--color-thd-gray);
|
||||||
margin-block: 1.5rem;
|
margin-block: 1.5rem;
|
||||||
|
|||||||
@@ -14,13 +14,13 @@
|
|||||||
</AuthHeading>
|
</AuthHeading>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
v-model="form.verifyCode"
|
v-model="form.code"
|
||||||
name="verifyCode"
|
name="code"
|
||||||
label="کد تایید را وارد کنید"
|
label="کد تایید را وارد کنید"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
:convert-digits="true"
|
:convert-digits="true"
|
||||||
:error="errors.verifyCode"
|
:error="errors.code"
|
||||||
@blur="validateAt('verifyCode', form.verifyCode)"
|
@blur="validateAt('code', form.code)"
|
||||||
>
|
>
|
||||||
<template #appendIcon>
|
<template #appendIcon>
|
||||||
<SvgIcon name="check" :size="22" color="var(--color-thd-gray)" />
|
<SvgIcon name="check" :size="22" color="var(--color-thd-gray)" />
|
||||||
@@ -74,7 +74,7 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['back', 'verified'])
|
const emit = defineEmits(['back', 'verified'])
|
||||||
|
|
||||||
const schema = verifyCodeSchema
|
const schema = verifyCodeSchema
|
||||||
const form = ref({ verifyCode: '' })
|
const form = ref({ code: '' })
|
||||||
const { validate, validateAt, errors } = useYup(schema)
|
const { validate, validateAt, errors } = useYup(schema)
|
||||||
|
|
||||||
const { applySession } = useAuth()
|
const { applySession } = useAuth()
|
||||||
@@ -106,7 +106,7 @@ const resending = computed(
|
|||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
const { isValid, payload } = await validate(form.value)
|
const { isValid, payload } = await validate(form.value)
|
||||||
if (!isValid) return
|
if (!isValid) return
|
||||||
const code = payload.verifyCode
|
const code = payload.code
|
||||||
|
|
||||||
if (props.mode === 'loginWithCode') {
|
if (props.mode === 'loginWithCode') {
|
||||||
const response = await verifyLoginMutation.mutateAsync({
|
const response = await verifyLoginMutation.mutateAsync({
|
||||||
@@ -127,8 +127,10 @@ const onSubmit = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await verifySignupMutation.mutateAsync({ phone: props.phoneNumber, verifyCode: code })
|
const response = await verifySignupMutation.mutateAsync({ phone: props.phoneNumber, code })
|
||||||
emit('verified', { phone: props.phoneNumber, verifyCode: code })
|
applySession(response?.data ?? response)
|
||||||
|
emit('verified', { phone: props.phoneNumber, code })
|
||||||
|
redirectAfterLogin()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onResend = async () => {
|
const onResend = async () => {
|
||||||
|
|||||||
@@ -112,6 +112,32 @@ let mediaStream = null
|
|||||||
let chunks = []
|
let chunks = []
|
||||||
let timerHandle = null
|
let timerHandle = null
|
||||||
let timerStart = 0
|
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(() =>
|
const hintText = computed(() =>
|
||||||
props.kind === 'video' ? 'ویدیوی خود را ضبط کنید' : 'صدای خود را ضبط کنید'
|
props.kind === 'video' ? 'ویدیوی خود را ضبط کنید' : 'صدای خود را ضبط کنید'
|
||||||
@@ -148,7 +174,8 @@ const onStart = async () => {
|
|||||||
liveEl.value.srcObject = mediaStream
|
liveEl.value.srcObject = mediaStream
|
||||||
}
|
}
|
||||||
chunks = []
|
chunks = []
|
||||||
mediaRecorder = new window.MediaRecorder(mediaStream)
|
recorderType = pickRecorderType(props.kind)
|
||||||
|
mediaRecorder = new window.MediaRecorder(mediaStream, { mimeType: recorderType.mime })
|
||||||
mediaRecorder.ondataavailable = (e) => {
|
mediaRecorder.ondataavailable = (e) => {
|
||||||
if (e.data?.size > 0) chunks.push(e.data)
|
if (e.data?.size > 0) chunks.push(e.data)
|
||||||
}
|
}
|
||||||
@@ -176,9 +203,9 @@ const onStop = () => {
|
|||||||
const handleStop = async () => {
|
const handleStop = async () => {
|
||||||
isRecording.value = false
|
isRecording.value = false
|
||||||
stopTracks()
|
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 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() })
|
const file = new File([blob], `recording.${ext}`, { type: mime, lastModified: Date.now() })
|
||||||
|
|
||||||
if (previewUrl.value && previewUrl.value.startsWith('blob:')) {
|
if (previewUrl.value && previewUrl.value.startsWith('blob:')) {
|
||||||
|
|||||||
@@ -17,24 +17,11 @@
|
|||||||
<div class="personal-info__row">
|
<div class="personal-info__row">
|
||||||
<div class="personal-info__cell">
|
<div class="personal-info__cell">
|
||||||
<TextField
|
<TextField
|
||||||
v-model="form.firstName"
|
v-model="form.name"
|
||||||
name="firstName"
|
name="name"
|
||||||
label="نام"
|
label="نام و نام خانوادگی"
|
||||||
:error="errors.firstName"
|
:error="errors.name"
|
||||||
@blur="validateAt('firstName', form.firstName)"
|
@blur="validateAt('name', form.name)"
|
||||||
>
|
|
||||||
<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>
|
<template #appendIcon>
|
||||||
<SvgIcon name="user" :size="20" color="var(--color-thd-gray)" />
|
<SvgIcon name="user" :size="20" color="var(--color-thd-gray)" />
|
||||||
@@ -57,6 +44,7 @@
|
|||||||
label="کد ملی"
|
label="کد ملی"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
:convert-digits="true"
|
:convert-digits="true"
|
||||||
|
:disabled="!!initialNationalCode"
|
||||||
:error="errors.nationalCode"
|
:error="errors.nationalCode"
|
||||||
@blur="validateAt('nationalCode', form.nationalCode)"
|
@blur="validateAt('nationalCode', form.nationalCode)"
|
||||||
>
|
>
|
||||||
@@ -153,7 +141,7 @@
|
|||||||
|
|
||||||
<StepActions
|
<StepActions
|
||||||
:show-back="false"
|
:show-back="false"
|
||||||
:loading="uploadMutation.isPending.value || saveRegisterMutation.isPending.value"
|
:loading="uploadMutation.isPending.value || updateProfileMutation.isPending.value"
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
@@ -164,6 +152,7 @@ import useYup from '@/composables/useYup'
|
|||||||
import useAuth from '@/composables/useAuth'
|
import useAuth from '@/composables/useAuth'
|
||||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||||
import { convertToJalali } from '@/utils/date-utils'
|
import { convertToJalali } from '@/utils/date-utils'
|
||||||
|
import { useQueryClient } from '@tanstack/vue-query'
|
||||||
import TextField from '@/components/form/TextField.vue'
|
import TextField from '@/components/form/TextField.vue'
|
||||||
import SelectField from '@/components/form/SelectField.vue'
|
import SelectField from '@/components/form/SelectField.vue'
|
||||||
import LineTitleBlock from '@/components/LineTitleBlock.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 DatePickerField from '@/components/form/DatePickerField.vue'
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { GENDER, MARITAL_STATUS, STUDENT_REGISTRATION } from '@/enums'
|
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 { personalInformationSchema } from '@/features/auth/schema/student-register'
|
||||||
import StepActions from '@/features/auth/components/studentRegister/StepActions.vue'
|
import StepActions from '@/features/auth/components/studentRegister/StepActions.vue'
|
||||||
import { useStudentRegistrationStore } from '@/features/auth/store/student-registration'
|
import { useStudentRegistrationStore } from '@/features/auth/store/student-registration'
|
||||||
import { useSaveRegisterDataMutation, useUploadMediaMutation } from '@/services/query/auth'
|
|
||||||
import { useGetCitiesOfProvinceQuery, useGetProvincesQuery } from '@/services/query/common'
|
import { useGetCitiesOfProvinceQuery, useGetProvincesQuery } from '@/services/query/common'
|
||||||
|
import { authKeys, useUpdateProfileMutation, useUploadMediaMutation } from '@/services/query/auth'
|
||||||
|
|
||||||
const store = useStudentRegistrationStore()
|
const store = useStudentRegistrationStore()
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const isMobile = ref(window.innerWidth < 768)
|
const isMobile = ref(window.innerWidth < 768)
|
||||||
const onResize = () => {
|
const onResize = () => {
|
||||||
@@ -207,12 +196,12 @@ const maxBirthDate = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const initialPhone = ref(user.value?.phone || store.personal.phone || '')
|
const initialPhone = ref(user.value?.phone || store.personal.phone || '')
|
||||||
|
const initialNationalCode = ref(user.value?.nationalCode || store.personal.nationalCode || '')
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
firstName: store.personal.firstName || '',
|
name: user.value?.name || store.personal.name || '',
|
||||||
lastName: store.personal.lastName || '',
|
|
||||||
birthDate: store.personal.birthDate || '',
|
birthDate: store.personal.birthDate || '',
|
||||||
nationalCode: store.personal.nationalCode || '',
|
nationalCode: initialNationalCode.value,
|
||||||
maritalStatus: store.personal.maritalStatus || '',
|
maritalStatus: store.personal.maritalStatus || '',
|
||||||
gender: store.personal.gender || '',
|
gender: store.personal.gender || '',
|
||||||
phone: initialPhone.value,
|
phone: initialPhone.value,
|
||||||
@@ -264,13 +253,30 @@ const onAvatarCropped = async (file) => {
|
|||||||
|
|
||||||
const onAvatarError = (msg) => toast.error(msg)
|
const onAvatarError = (msg) => toast.error(msg)
|
||||||
|
|
||||||
const saveRegisterMutation = useSaveRegisterDataMutation()
|
const updateProfileMutation = useUpdateProfileMutation()
|
||||||
|
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
const { isValid, payload } = await validate(form.value)
|
const { isValid, payload } = await validate(form.value)
|
||||||
if (!isValid) return
|
if (!isValid) return
|
||||||
const next = { ...payload, avatar: form.value.avatar }
|
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.personal = { ...store.personal, ...next }
|
||||||
store.markStepAsCompleted(STUDENT_REGISTRATION.PERSONAL_INFORMATION)
|
store.markStepAsCompleted(STUDENT_REGISTRATION.PERSONAL_INFORMATION)
|
||||||
store.goToNextStep()
|
store.goToNextStep()
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const router = useRouter()
|
|||||||
const step = ref(SIGNUP_STEPS.SIGNUP)
|
const step = ref(SIGNUP_STEPS.SIGNUP)
|
||||||
const phoneNumber = ref('')
|
const phoneNumber = ref('')
|
||||||
|
|
||||||
const handleNext = ({ phoneNumber: phone }) => {
|
const handleNext = ({ phone }) => {
|
||||||
phoneNumber.value = phone
|
phoneNumber.value = phone
|
||||||
step.value = SIGNUP_STEPS.VERIFY_CODE
|
step.value = SIGNUP_STEPS.VERIFY_CODE
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export const signupSchema = object().shape({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const verifyCodeSchema = object().shape({
|
export const verifyCodeSchema = object().shape({
|
||||||
verifyCode: string().required().length(6),
|
code: string().required().length(6),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const resetPasswordSchema = object().shape({
|
export const resetPasswordSchema = object().shape({
|
||||||
|
|||||||
@@ -11,13 +11,12 @@ const requiredString = (min = 0) => {
|
|||||||
export const personalInformationSchema = object().shape({
|
export const personalInformationSchema = object().shape({
|
||||||
avatar: mixed().notRequired(),
|
avatar: mixed().notRequired(),
|
||||||
avatarId: mixed().notRequired(),
|
avatarId: mixed().notRequired(),
|
||||||
firstName: requiredString(3),
|
name: requiredString(3),
|
||||||
lastName: requiredString(3),
|
|
||||||
birthDate: string().required(),
|
birthDate: string().required(),
|
||||||
nationalCode: nationalCodeRule,
|
nationalCode: nationalCodeRule,
|
||||||
maritalStatus: string().required(),
|
maritalStatus: string().required(),
|
||||||
gender: string().required(),
|
gender: string().required(),
|
||||||
phoneNumber: phoneNumberRule,
|
phone: phoneNumberRule,
|
||||||
provinceId: mixed().required(),
|
provinceId: mixed().required(),
|
||||||
cityId: mixed().required(),
|
cityId: mixed().required(),
|
||||||
address: requiredString(10),
|
address: requiredString(10),
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ const emptyState = () => ({
|
|||||||
personal: {
|
personal: {
|
||||||
avatar: null,
|
avatar: null,
|
||||||
avatarId: '',
|
avatarId: '',
|
||||||
firstName: '',
|
name: '',
|
||||||
lastName: '',
|
|
||||||
birthDate: '',
|
birthDate: '',
|
||||||
nationalCode: '',
|
nationalCode: '',
|
||||||
maritalStatus: '',
|
maritalStatus: '',
|
||||||
@@ -75,8 +74,7 @@ const SECTION_FIELDS = {
|
|||||||
personal: [
|
personal: [
|
||||||
'avatar',
|
'avatar',
|
||||||
'avatarId',
|
'avatarId',
|
||||||
'firstName',
|
'name',
|
||||||
'lastName',
|
|
||||||
'birthDate',
|
'birthDate',
|
||||||
'nationalCode',
|
'nationalCode',
|
||||||
'maritalStatus',
|
'maritalStatus',
|
||||||
|
|||||||
@@ -5,12 +5,6 @@
|
|||||||
|
|
||||||
<div class="ses__stats">
|
<div class="ses__stats">
|
||||||
<div class="ses__stat">
|
<div class="ses__stat">
|
||||||
<SvgIcon name="check" :size="12" color="#5d5d5d" />
|
|
||||||
<span class="ses__stat-label">تعداد آزمونها :</span>
|
|
||||||
<span class="ses__stat-value">{{ attemptsCountText }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="ses__stat">
|
|
||||||
<SvgIcon name="scroll" :size="12" color="#5d5d5d" />
|
|
||||||
<span class="ses__stat-label">تعداد سوال :</span>
|
<span class="ses__stat-label">تعداد سوال :</span>
|
||||||
<span class="ses__stat-value">{{ questionsCountText }}</span>
|
<span class="ses__stat-value">{{ questionsCountText }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,11 +59,6 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['start'])
|
const emit = defineEmits(['start'])
|
||||||
|
|
||||||
const attemptsCountText = computed(() =>
|
|
||||||
props.exam.attemptsCount == null
|
|
||||||
? `${(props.exam.attempts || []).length} آزمون`
|
|
||||||
: `${props.exam.attemptsCount} آزمون`
|
|
||||||
)
|
|
||||||
const questionsCountText = computed(() =>
|
const questionsCountText = computed(() =>
|
||||||
props.exam.questionsCount == null ? '—' : `${props.exam.questionsCount}`
|
props.exam.questionsCount == null ? '—' : `${props.exam.questionsCount}`
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,12 +14,12 @@
|
|||||||
<p class="student-lesson-item__title">{{ lesson.title || '—' }}</p>
|
<p class="student-lesson-item__title">{{ lesson.title || '—' }}</p>
|
||||||
<Badge
|
<Badge
|
||||||
variant="primary"
|
variant="primary"
|
||||||
label="زمان یادگیری :"
|
label="ظرفیت :"
|
||||||
:value="hoursText"
|
:value="capacityText"
|
||||||
class="student-lesson-item__hours"
|
class="student-lesson-item__hours"
|
||||||
>
|
>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<SvgIcon name="calendar" :size="11" color="#007074" />
|
<SvgIcon name="users-three" :size="11" color="#007074" />
|
||||||
</template>
|
</template>
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,8 +75,8 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['show-details'])
|
const emit = defineEmits(['show-details'])
|
||||||
|
|
||||||
const hoursText = computed(() =>
|
const capacityText = computed(() =>
|
||||||
props.lesson.learningHours == null ? '—' : `${props.lesson.learningHours} ساعت`
|
props.lesson.capacity == null ? '—' : `${props.lesson.capacity} نفر`
|
||||||
)
|
)
|
||||||
const sessionsText = computed(() =>
|
const sessionsText = computed(() =>
|
||||||
props.lesson.sessionsCount == null ? '—' : `${props.lesson.sessionsCount} جلسه`
|
props.lesson.sessionsCount == null ? '—' : `${props.lesson.sessionsCount} جلسه`
|
||||||
|
|||||||
@@ -41,11 +41,11 @@
|
|||||||
|
|
||||||
<div class="edit-profile__cell edit-profile__cell--third">
|
<div class="edit-profile__cell edit-profile__cell--third">
|
||||||
<DatePickerField
|
<DatePickerField
|
||||||
v-model="form.birthDate"
|
v-model="form.birthday"
|
||||||
name="birthDate"
|
name="birthday"
|
||||||
label="تاریخ تولد"
|
label="تاریخ تولد"
|
||||||
:max="todayIso"
|
:max="todayIso"
|
||||||
:error="errors.birthDate"
|
:error="errors.birthday"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="edit-profile__cell edit-profile__cell--third">
|
<div class="edit-profile__cell edit-profile__cell--third">
|
||||||
@@ -253,7 +253,7 @@ const initialPhoneNumber = ref('')
|
|||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
name: '',
|
name: '',
|
||||||
birthDate: '',
|
birthday: '',
|
||||||
nationalCode: '',
|
nationalCode: '',
|
||||||
marriageStatus: '',
|
marriageStatus: '',
|
||||||
gender: '',
|
gender: '',
|
||||||
@@ -298,34 +298,38 @@ const onProvinceChange = () => {
|
|||||||
|
|
||||||
const { data: profile } = useGetMeQuery()
|
const { data: profile } = useGetMeQuery()
|
||||||
|
|
||||||
watch(profile, (user) => {
|
watch(
|
||||||
if (!user) return
|
profile,
|
||||||
const fullName =
|
(user) => {
|
||||||
user.name || [user.firstName, user.lastName].filter(Boolean).join(' ').trim() || ''
|
if (!user) return
|
||||||
const phone = user.phone || user.phoneNumber || ''
|
const fullName =
|
||||||
form.value = {
|
user.name || [user.firstName, user.lastName].filter(Boolean).join(' ').trim() || ''
|
||||||
...form.value,
|
const phone = user.phone || user.phoneNumber || ''
|
||||||
name: fullName,
|
form.value = {
|
||||||
birthDate: user.profile?.birthDate || '',
|
...form.value,
|
||||||
nationalCode: user.nationalCode || '',
|
name: fullName,
|
||||||
marriageStatus: user.profile?.marriageStatus || '',
|
birthday: user?.birthday || '',
|
||||||
gender: user.profile?.gender || '',
|
nationalCode: user.nationalCode || '',
|
||||||
phone,
|
marriageStatus: user?.marriageStatus || '',
|
||||||
provinceId: user?.province?.id || '',
|
gender: user?.gender || '',
|
||||||
cityId: user?.city?.id || '',
|
phone,
|
||||||
address: user?.address || '',
|
provinceId: user?.province?.id || '',
|
||||||
avatarMediaId: user.profile?.avatarMediaId || null,
|
cityId: user?.city?.id || '',
|
||||||
}
|
address: user?.address || '',
|
||||||
initialNationalCode.value = user.nationalCode || ''
|
avatarMediaId: user?.avatarMediaId || null,
|
||||||
initialPhoneNumber.value = phone
|
}
|
||||||
if (user.avatarUrl) avatar.value = { url: user.avatarUrl }
|
initialNationalCode.value = user.nationalCode || ''
|
||||||
})
|
initialPhoneNumber.value = phone
|
||||||
|
if (user.avatarUrl) avatar.value = { url: user.avatarUrl }
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
const uploadMutation = useUploadMediaMutation()
|
const uploadMutation = useUploadMediaMutation()
|
||||||
|
|
||||||
const onAvatarCropped = async (file) => {
|
const onAvatarCropped = async (file) => {
|
||||||
try {
|
try {
|
||||||
const formData = objectToFormData({ file, subType: 'avatar', context: 'user' })
|
const formData = objectToFormData({ file, purpose: 'avatar', context: 'user' })
|
||||||
const response = await uploadMutation.mutateAsync(formData)
|
const response = await uploadMutation.mutateAsync(formData)
|
||||||
const payload = response?.data ?? response
|
const payload = response?.data ?? response
|
||||||
avatar.value = { url: payload?.url, uploadId: payload?.uploadId, ...payload }
|
avatar.value = { url: payload?.url, uploadId: payload?.uploadId, ...payload }
|
||||||
@@ -344,7 +348,7 @@ const onSubmit = async () => {
|
|||||||
const { isValid, payload } = await validate(form.value)
|
const { isValid, payload } = await validate(form.value)
|
||||||
if (!isValid) return
|
if (!isValid) return
|
||||||
// Backend PATCH /me accepts: name, email, phone, avatar_media_id.
|
// Backend PATCH /me accepts: name, email, phone, avatar_media_id.
|
||||||
// Other UI-only fields (province/city/address/birthDate/…) are not in
|
// Other UI-only fields (province/city/address/birthday/…) are not in
|
||||||
// the backend Postman doc and are dropped here. Password is handled
|
// the backend Postman doc and are dropped here. Password is handled
|
||||||
// separately via POST /reset-password.
|
// separately via POST /reset-password.
|
||||||
const body = {
|
const body = {
|
||||||
@@ -471,7 +475,7 @@ const onCancel = () => router.push({ name: 'student-dashboard' })
|
|||||||
&__actions {
|
&__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 0.625rem;
|
justify-content: space-between;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export const studentProfileSchema = object().shape({
|
|||||||
name: string().required().min(2),
|
name: string().required().min(2),
|
||||||
phone: phoneNumberRule,
|
phone: phoneNumberRule,
|
||||||
nationalCode: nationalCodeRule,
|
nationalCode: nationalCodeRule,
|
||||||
birthDate: string().nullable().notRequired(),
|
birthday: string().nullable().notRequired(),
|
||||||
marriageStatus: string().nullable().notRequired(),
|
marriageStatus: string().nullable().notRequired(),
|
||||||
gender: string().nullable().notRequired(),
|
gender: string().nullable().notRequired(),
|
||||||
provinceId: string().nullable().notRequired(),
|
provinceId: string().nullable().notRequired(),
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
<p class="student-session-item__title">{{ session.title || '—' }}</p>
|
<p class="student-session-item__title">{{ session.title || '—' }}</p>
|
||||||
<div class="student-session-item__hours">
|
<div class="student-session-item__hours">
|
||||||
<SvgIcon name="calendar" :size="9" color="#007074" />
|
<SvgIcon name="calendar" :size="9" color="#007074" />
|
||||||
<span class="student-session-item__hours-label">زمان جلسه :</span>
|
<span class="student-session-item__hours-label">مهلت :</span>
|
||||||
<span class="student-session-item__hours-value">{{ durationText }}</span>
|
<span class="student-session-item__hours-value">{{ deadlineText }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -13,8 +13,8 @@
|
|||||||
<Badge v-if="isComplete" variant="success">تکمیل شده</Badge>
|
<Badge v-if="isComplete" variant="success">تکمیل شده</Badge>
|
||||||
<Badge v-if="session.isSeen" variant="success">دیده شده</Badge>
|
<Badge v-if="session.isSeen" variant="success">دیده شده</Badge>
|
||||||
<Badge v-if="session.grade != null" variant="primary" label="نمره" :value="session.grade" />
|
<Badge v-if="session.grade != null" variant="primary" label="نمره" :value="session.grade" />
|
||||||
<Badge v-if="session.needsAssignment" icon="paper-plane">دارای تکلیف</Badge>
|
<Badge v-if="session.homeworksCount > 0" icon="paper-plane">دارای تکلیف</Badge>
|
||||||
<Badge v-if="session.hasQuiz" icon="file">دارای آزمون</Badge>
|
<Badge v-if="session.examsCount > 0" icon="file">دارای آزمون</Badge>
|
||||||
<Badge v-if="isOnline" icon="users-three">جلسه آنلاین</Badge>
|
<Badge v-if="isOnline" icon="users-three">جلسه آنلاین</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -37,6 +37,7 @@ import { computed } from 'vue'
|
|||||||
import Badge from '@/components/Badge.vue'
|
import Badge from '@/components/Badge.vue'
|
||||||
import BaseButton from '@/components/BaseButton.vue'
|
import BaseButton from '@/components/BaseButton.vue'
|
||||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||||
|
import { formatJalaaliDate } from '@/utils/date-utils'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
session: { type: Object, required: true },
|
session: { type: Object, required: true },
|
||||||
@@ -44,8 +45,8 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['start'])
|
const emit = defineEmits(['start'])
|
||||||
|
|
||||||
const durationText = computed(() =>
|
const deadlineText = computed(
|
||||||
props.session.durationHours == null ? '—' : `${props.session.durationHours} ساعت`
|
() => formatJalaaliDate(props.session.deadline || props.session.endDate) || '—'
|
||||||
)
|
)
|
||||||
|
|
||||||
const isOnline = computed(() => props.session.type === 'online')
|
const isOnline = computed(() => props.session.type === 'online')
|
||||||
|
|||||||
@@ -119,8 +119,6 @@ const { data: session, isLoading } = useStudentSessionQuery(sessionId, {
|
|||||||
enabled: () => !!sessionId.value,
|
enabled: () => !!sessionId.value,
|
||||||
})
|
})
|
||||||
|
|
||||||
// The homework for this session comes from the dedicated endpoint (the session
|
|
||||||
// payload itself doesn't embed homeworks).
|
|
||||||
const homeworkFilters = computed(() => ({ sessionId: sessionId.value }))
|
const homeworkFilters = computed(() => ({ sessionId: sessionId.value }))
|
||||||
const homeworkPagination = ref({ page: 1, perPage: 20 })
|
const homeworkPagination = ref({ page: 1, perPage: 20 })
|
||||||
const { data: homeworksData } = useStudentHomeworksListQuery(homeworkFilters, homeworkPagination, {
|
const { data: homeworksData } = useStudentHomeworksListQuery(homeworkFilters, homeworkPagination, {
|
||||||
@@ -152,7 +150,14 @@ const homeworkId = computed(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const uploadHomeworkFile = async (file) => {
|
const uploadHomeworkFile = async (file) => {
|
||||||
const fd = objectToFormData({ file, purpose: 'homework_file', context: 'homework' })
|
// The backend validates allowed extensions per `purpose`: `homework_file`
|
||||||
|
// accepts documents/images only, so a recorded voice must go up as `voice`.
|
||||||
|
const isAudio = (file?.type || '').startsWith('audio/')
|
||||||
|
const fd = objectToFormData({
|
||||||
|
file,
|
||||||
|
purpose: isAudio ? 'voice' : 'homework_file',
|
||||||
|
context: 'homework',
|
||||||
|
})
|
||||||
const response = await uploadMediaMutation.mutateAsync(fd)
|
const response = await uploadMediaMutation.mutateAsync(fd)
|
||||||
const payload = response?.data ?? response
|
const payload = response?.data ?? response
|
||||||
return payload?.id ?? null
|
return payload?.id ?? null
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const endpoints = {
|
|||||||
resetPassword: '/auth/password/reset',
|
resetPassword: '/auth/password/reset',
|
||||||
|
|
||||||
resendVerificationCodeForRegister: '/resend-verification-code',
|
resendVerificationCodeForRegister: '/resend-verification-code',
|
||||||
verifyCode: '/verify-code',
|
verifyCode: '/auth/login/otp/verify',
|
||||||
completeProfile: '/complete-profile',
|
completeProfile: '/complete-profile',
|
||||||
getRegistrationQuestionVideo: '/verification-registration-media',
|
getRegistrationQuestionVideo: '/verification-registration-media',
|
||||||
|
|
||||||
|
|||||||
@@ -13,21 +13,12 @@ export const adminTicketsKeys = {
|
|||||||
detail: (id) => ['admin', 'tickets', 'detail', id],
|
detail: (id) => ['admin', 'tickets', 'detail', id],
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backend may wrap the list as `{success, data: [...]}` (flat) or
|
|
||||||
// `{data: {data: [...], meta}}` (Laravel default). Normalize.
|
|
||||||
const selectList = (response) => {
|
|
||||||
const inner = response?.data
|
|
||||||
if (Array.isArray(inner)) return { data: inner, meta: response?.meta }
|
|
||||||
if (Array.isArray(inner?.data)) return { data: inner.data, meta: inner?.meta ?? response?.meta }
|
|
||||||
return { data: [], meta: response?.meta }
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useAdminTicketsListQuery = (filtersRef, paginationRef, options = {}) =>
|
export const useAdminTicketsListQuery = (filtersRef, paginationRef, options = {}) =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: ['admin', 'tickets', 'list', filtersRef, paginationRef],
|
queryKey: ['admin', 'tickets', 'list', filtersRef, paginationRef],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
apiGetAdminTickets({ ...cleanFilters(filtersRef.value), ...paginationRef.value }),
|
apiGetAdminTickets({ ...cleanFilters(filtersRef.value), ...paginationRef.value }),
|
||||||
select: selectList,
|
select: (response) => response?.data ?? response,
|
||||||
...options,
|
...options,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user