diff --git a/src/components/form/VoiceRecorder.vue b/src/components/form/VoiceRecorder.vue
index 852c807..7b0cf2d 100644
--- a/src/components/form/VoiceRecorder.vue
+++ b/src/components/form/VoiceRecorder.vue
@@ -121,6 +121,23 @@ let mediaStream = null
let chunks = []
let timerHandle = null
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 s = Math.floor(elapsedMs.value / 1000)
@@ -163,16 +180,19 @@ const startRecording = async () => {
try {
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
chunks = []
- mediaRecorder = new window.MediaRecorder(mediaStream)
+ recorderType = pickRecorderType()
+ mediaRecorder = new window.MediaRecorder(mediaStream, { mimeType: recorderType.mime })
mediaRecorder.ondataavailable = (e) => {
if (e.data?.size > 0) chunks.push(e.data)
}
mediaRecorder.onstop = () => {
isRecording.value = false
stopTracks()
- const blob = new Blob(chunks, { type: 'audio/webm' })
- const file = new File([blob], `recording-${Date.now()}.webm`, {
- type: 'audio/webm',
+ const mime = recorderType?.mime || mediaRecorder?.mimeType || 'audio/webm'
+ const ext = recorderType?.ext || 'webm'
+ const blob = new Blob(chunks, { type: mime })
+ const file = new File([blob], `recording-${Date.now()}.${ext}`, {
+ type: mime,
lastModified: Date.now(),
})
setRecording(file)
diff --git a/src/features/admin/assignments/components/modals/AssignmentDetailsModal.vue b/src/features/admin/assignments/components/modals/AssignmentDetailsModal.vue
index 0c071e7..bfb4a3f 100644
--- a/src/features/admin/assignments/components/modals/AssignmentDetailsModal.vue
+++ b/src/features/admin/assignments/components/modals/AssignmentDetailsModal.vue
@@ -21,10 +21,6 @@
-
diff --git a/src/features/admin/exams/pages/ExamFormPage.vue b/src/features/admin/exams/pages/ExamFormPage.vue
index 7835968..b243e4a 100644
--- a/src/features/admin/exams/pages/ExamFormPage.vue
+++ b/src/features/admin/exams/pages/ExamFormPage.vue
@@ -198,7 +198,6 @@ const normalizeExistingQuestions = (raw = []) => {
optionText: o.optionText || '',
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) => {
for (const payload of list) {
- // Sequential so question position ordering is preserved on the backend.
// eslint-disable-next-line no-await-in-loop
await addQuestionMutation.mutateAsync({ examId: id, payload })
}
@@ -302,7 +300,7 @@ const onSubmit = async () => {
if (targetExamId && newQuestions.length > 0) {
await postQuestionsSequentially(targetExamId, newQuestions)
}
- await queryClient.invalidateQueries({ queryKey: adminExamsKeys.all })
+ await queryClient.invalidateQueries({ queryKey: adminExamsKeys.all, refetchType: 'all' })
router.push({ name: 'admin-exams' })
}
diff --git a/src/features/admin/messages/pages/TicketsListPage.vue b/src/features/admin/messages/pages/TicketsListPage.vue
index a00906e..9f67dfc 100644
--- a/src/features/admin/messages/pages/TicketsListPage.vue
+++ b/src/features/admin/messages/pages/TicketsListPage.vue
@@ -59,7 +59,8 @@ const { data, isLoading } = useAdminTicketsListQuery(filters, pagination, {
keepPreviousData: true,
})
-const tickets = computed(() => data.value?.data ?? [])
+const tickets = computed(() => data.value?.items ?? [])
+
const paginationMeta = computed(() => ({
page: pagination.value.page,
perPage: pagination.value.perPage,
diff --git a/src/features/admin/sessions/components/modals/SessionDetailsModal.vue b/src/features/admin/sessions/components/modals/SessionDetailsModal.vue
index 9da2db8..15db997 100644
--- a/src/features/admin/sessions/components/modals/SessionDetailsModal.vue
+++ b/src/features/admin/sessions/components/modals/SessionDetailsModal.vue
@@ -39,11 +39,9 @@
:src="mediaUrl"
:video-id="session.id"
/>
-
+
+
+
session.value?.course?.title || '—')
const isOnline = computed(() => session.value?.type === 'online')
const collectionToContentType = (collectionName) => {
- if (collectionName === 'videos') return 'video'
+ if (collectionName === 'video') return 'video'
if (collectionName === 'voice') return 'voice'
- if (collectionName === 'pdfs') return 'text'
+ if (collectionName === 'pdf') return 'text'
return ''
}
@@ -173,6 +171,22 @@ const mediaFileName = computed(() => contentMedia.value?.fileName || 'فایل
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 {
display: inline-flex;
align-items: center;
diff --git a/src/features/admin/sessions/pages/SessionFormPage.vue b/src/features/admin/sessions/pages/SessionFormPage.vue
index e538e90..2e48417 100644
--- a/src/features/admin/sessions/pages/SessionFormPage.vue
+++ b/src/features/admin/sessions/pages/SessionFormPage.vue
@@ -357,7 +357,10 @@ const onSubmit = async () => {
} else {
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' })
}
diff --git a/src/features/admin/users/components/verifications/modals/UserVerificationDetailsModal.vue b/src/features/admin/users/components/verifications/modals/UserVerificationDetailsModal.vue
index 7f31a98..a3e144e 100644
--- a/src/features/admin/users/components/verifications/modals/UserVerificationDetailsModal.vue
+++ b/src/features/admin/users/components/verifications/modals/UserVerificationDetailsModal.vue
@@ -17,19 +17,19 @@
-
-
+
+
-
-
-
+
+
+
-
+
@@ -39,15 +39,15 @@
@@ -56,30 +56,12 @@
:desc="profile.fieldOfStudy || ''"
/>
-
-
-
-
-
-
-
-
-
-
+
+
+
@@ -214,6 +198,7 @@ import useModal from '@/composables/useModal'
import BasicModal from '@/components/BasicModal.vue'
import { formatJalaaliDate } from '@/utils/date-utils'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
+import VoiceRecorder from '@/components/form/VoiceRecorder.vue'
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
import { useAdminUserQuery, useAdminUserRegisterDataQuery } from '@/services/query/admin-users'
import {
@@ -227,7 +212,6 @@ import {
SEMINARY_LEVEL,
SESSION_CANCELLATION_RESPONSE,
UNIVERSITY_LEVEL,
- VERIFICATION_MEDIA_TYPE,
} from '@/enums'
defineOptions({ name: 'UserVerificationDetailsModal' })
@@ -248,15 +232,23 @@ const { data: registerData } = useAdminUserRegisterDataQuery(userIdRef, {
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 fullName = computed(
- () =>
- user.value?.name || `${user.value?.firstName || ''} ${user.value?.lastName || ''}`.trim() || '—'
-)
+const fullName = computed(() => user.value?.name || '—')
+const personal = computed(() => {
+ 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(() =>
Array.isArray(profile.value.propagationPlatforms) ? profile.value.propagationPlatforms : []
@@ -276,13 +268,10 @@ const onlinePlatformDetails = computed(
() => platforms.value.find((p) => p.platform === 'online')?.platformDetails || ''
)
-const verificationMedia = computed(() => user.value?.verification?.media || [])
-const faithProduction = computed(() =>
- verificationMedia.value.find((m) => m.type === VERIFICATION_MEDIA_TYPE.FAITH_PRODUCTION)
-)
-const leaderMessage = computed(() =>
- verificationMedia.value.find((m) => m.type === VERIFICATION_MEDIA_TYPE.LEADER_MESSAGE)
-)
+// Skill parts 6 & 7 store the uploaded media objects in register-data under
+// `faithProductionAudio` / `leaderMessageVideo` (each carries a `url`).
+const faithProduction = computed(() => profile.value.faithProductionAudio || null)
+const leaderMessage = computed(() => profile.value.leaderMessageVideo || null)