Compare commits
2 Commits
a4432a30cb
...
e700609236
| Author | SHA1 | Date | |
|---|---|---|---|
| e700609236 | |||
| 2c617258f4 |
@@ -0,0 +1,12 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="12" cy="12" r="11.625" stroke="#EEEEEE" stroke-width="0.75"/>
|
||||||
|
<g clip-path="url(#clip0_1_21196)">
|
||||||
|
<path d="M15.375 15.375L8.625 8.625" stroke="#B2B2B2" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M14.1094 8.625H8.625V14.1094" stroke="#B2B2B2" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_1_21196">
|
||||||
|
<rect width="13.5" height="13.5" fill="white" transform="translate(5.25 5.25)"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 604 B |
@@ -0,0 +1,11 @@
|
|||||||
|
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_1_21191)">
|
||||||
|
<path d="M6.5 11.375C9.19239 11.375 11.375 9.19239 11.375 6.5C11.375 3.80761 9.19239 1.625 6.5 1.625C3.80761 1.625 1.625 3.80761 1.625 6.5C1.625 9.19239 3.80761 11.375 6.5 11.375Z" stroke="#B8B8B8" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M6.5 3.65625V6.5H9.34375" stroke="#B8B8B8" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_1_21191">
|
||||||
|
<rect width="12.75" height="12.75" fill="white" transform="scale(1.01961)"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 666 B |
@@ -0,0 +1,10 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_1_21269)">
|
||||||
|
<path d="M5.625 3.1154V16.8841C5.62704 16.994 5.65802 17.1014 5.7148 17.1955C5.77158 17.2896 5.85217 17.367 5.94843 17.42C6.0447 17.473 6.15323 17.4997 6.2631 17.4974C6.37297 17.4951 6.48028 17.4638 6.57422 17.4068L17.8305 10.5224C17.9204 10.468 17.9947 10.3913 18.0463 10.2998C18.0979 10.2082 18.1251 10.1049 18.1251 9.99977C18.1251 9.89467 18.0979 9.79135 18.0463 9.69979C17.9947 9.60823 17.9204 9.53154 17.8305 9.47711L6.57422 2.59274C6.48028 2.53571 6.37297 2.50447 6.2631 2.50214C6.15323 2.49982 6.0447 2.52651 5.94843 2.57951C5.85217 2.63252 5.77158 2.70996 5.7148 2.80405C5.65802 2.89813 5.62704 3.00552 5.625 3.1154Z" stroke="#848484" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_1_21269">
|
||||||
|
<rect width="19.5" height="19.5" fill="white" transform="scale(1.02564)"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 988 B |
@@ -0,0 +1,11 @@
|
|||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_1_21319)">
|
||||||
|
<path d="M11.8125 2.625H2.1875C1.94588 2.625 1.75 2.82088 1.75 3.0625V10.9375C1.75 11.1791 1.94588 11.375 2.1875 11.375H11.8125C12.0541 11.375 12.25 11.1791 12.25 10.9375V3.0625C12.25 2.82088 12.0541 2.625 11.8125 2.625Z" stroke="#B8B8B8" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M9.1875 4.8125C9.1875 5.39266 8.95703 5.94906 8.5468 6.3593C8.13656 6.76953 7.58016 7 7 7C6.41984 7 5.86344 6.76953 5.4532 6.3593C5.04297 5.94906 4.8125 5.39266 4.8125 4.8125" stroke="#B8B8B8" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_1_21319">
|
||||||
|
<rect width="13.5" height="13.5" fill="white" transform="scale(1.03704)"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 855 B |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_1_21209)">
|
||||||
|
<path d="M5.48438 8.53125C7.16712 8.53125 8.53125 7.16712 8.53125 5.48438C8.53125 3.80163 7.16712 2.4375 5.48438 2.4375C3.80163 2.4375 2.4375 3.80163 2.4375 5.48438C2.4375 7.16712 3.80163 8.53125 5.48438 8.53125Z" stroke="#B8B8B8" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M1.21875 10.5625C2.2623 9.3209 3.73547 8.53125 5.48438 8.53125C7.23328 8.53125 8.70645 9.3209 9.75 10.5625" stroke="#B8B8B8" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M10.1562 3.49096C10.4243 4.12144 10.5625 4.79952 10.5625 5.48463C10.5625 6.16975 10.4243 6.84782 10.1562 7.47831" stroke="#B8B8B8" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M11.6468 2.84375C12.0033 3.67846 12.1871 4.57672 12.1871 5.48438C12.1871 6.39203 12.0033 7.29029 11.6468 8.125" stroke="#B8B8B8" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_1_21209">
|
||||||
|
<rect width="12.75" height="12.75" fill="white" transform="scale(1.01961)"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_1_21228)">
|
||||||
|
<path d="M8.125 5.6875L5.6875 4.0625V7.3125L8.125 5.6875Z" stroke="#B8B8B8" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M10.9688 2.4375H2.03125C1.80688 2.4375 1.625 2.61938 1.625 2.84375V8.53125C1.625 8.75562 1.80688 8.9375 2.03125 8.9375H10.9688C11.1931 8.9375 11.375 8.75562 11.375 8.53125V2.84375C11.375 2.61938 11.1931 2.4375 10.9688 2.4375Z" stroke="#B8B8B8" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M1.625 10.5625H11.375" stroke="#B8B8B8" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_1_21228">
|
||||||
|
<rect width="12.75" height="12.75" fill="white" transform="scale(1.01961)"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 863 B |
@@ -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)
|
||||||
|
|||||||
Vendored
+6
@@ -8,6 +8,7 @@ export type IconName =
|
|||||||
| 'bell'
|
| 'bell'
|
||||||
| 'book'
|
| 'book'
|
||||||
| 'calendar'
|
| 'calendar'
|
||||||
|
| 'card-corner-arrow'
|
||||||
| 'caret-down'
|
| 'caret-down'
|
||||||
| 'caret-left'
|
| 'caret-left'
|
||||||
| 'caret-right'
|
| 'caret-right'
|
||||||
@@ -15,6 +16,7 @@ export type IconName =
|
|||||||
| 'chat-centered-dots'
|
| 'chat-centered-dots'
|
||||||
| 'check'
|
| 'check'
|
||||||
| 'check-square'
|
| 'check-square'
|
||||||
|
| 'clock'
|
||||||
| 'close'
|
| 'close'
|
||||||
| 'copy'
|
| 'copy'
|
||||||
| 'eye'
|
| 'eye'
|
||||||
@@ -33,16 +35,20 @@ export type IconName =
|
|||||||
| 'paper-plane-right'
|
| 'paper-plane-right'
|
||||||
| 'pencil'
|
| 'pencil'
|
||||||
| 'phone'
|
| 'phone'
|
||||||
|
| 'play'
|
||||||
| 'plus'
|
| 'plus'
|
||||||
| 'scroll'
|
| 'scroll'
|
||||||
|
| 'shopping-bag'
|
||||||
| 'spinner'
|
| 'spinner'
|
||||||
| 'square'
|
| 'square'
|
||||||
| 'telegram'
|
| 'telegram'
|
||||||
| 'trash'
|
| 'trash'
|
||||||
| 'upload'
|
| 'upload'
|
||||||
| 'user'
|
| 'user'
|
||||||
|
| 'user-sound'
|
||||||
| 'users'
|
| 'users'
|
||||||
| 'users-three'
|
| 'users-three'
|
||||||
|
| 'video'
|
||||||
| 'warning'
|
| 'warning'
|
||||||
|
|
||||||
export const iconNames: readonly IconName[]
|
export const iconNames: readonly IconName[]
|
||||||
|
|||||||
@@ -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} جلسه`
|
||||||
|
|||||||
@@ -1,69 +1,226 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="student-dashboard">
|
<div class="dashboard">
|
||||||
<div class="student-dashboard__welcome">
|
<div class="dashboard__main">
|
||||||
<img :src="flower" alt="flower" class="student-dashboard__flower" />
|
<!-- گزارش عملکرد -->
|
||||||
<div>
|
<section class="dashboard__section">
|
||||||
<p class="student-dashboard__hi">سلام بر شما</p>
|
<BoxedIconTitleBlock title="گزارش عملکرد" desc="مروری بر شاخصهای آموزشی و فعالیتهای شما.">
|
||||||
<p class="student-dashboard__greeting">به سامانه موسسه بانو خوش آمدید.</p>
|
<template #icon>
|
||||||
</div>
|
<SvgIcon name="heart" :size="24" color="var(--color-primary)" />
|
||||||
|
</template>
|
||||||
|
</BoxedIconTitleBlock>
|
||||||
|
|
||||||
|
<div class="dashboard__stats">
|
||||||
|
<DashboardStatCard
|
||||||
|
v-for="(stat, i) in performanceStats"
|
||||||
|
:key="i"
|
||||||
|
:icon="stat.icon"
|
||||||
|
:label="stat.label"
|
||||||
|
:value="stat.value"
|
||||||
|
:unit="stat.unit"
|
||||||
|
:subtitle="stat.subtitle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- فعالیت های اخیر -->
|
||||||
|
<section class="dashboard__section">
|
||||||
|
<BoxedIconTitleBlock
|
||||||
|
title="فعالیت های اخیر"
|
||||||
|
desc="گزارشی از جدیدترین خریدها و دورههای تکمیلشده"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<SvgIcon name="heart" :size="24" color="var(--color-primary)" />
|
||||||
|
</template>
|
||||||
|
</BoxedIconTitleBlock>
|
||||||
|
|
||||||
|
<div class="dashboard__activities">
|
||||||
|
<div v-for="(act, i) in activities" :key="i" class="dashboard__activity">
|
||||||
|
<div class="dashboard__pill">
|
||||||
|
<SvgIcon :name="act.pillIcon" :size="'1.125rem'" class="dashboard__pill-icon" />
|
||||||
|
<span class="dashboard__pill-label">{{ act.pillLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<DashboardActivityCard :title="act.title" :meta="act.meta" :meta-icon="act.metaIcon" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="student-dashboard__placeholder">این بخش در مراحل بعدی توسعه مییابد.</p>
|
<aside class="dashboard__aside">
|
||||||
|
<DashboardProfileCard
|
||||||
|
:name="profile.name"
|
||||||
|
:location="profile.location"
|
||||||
|
:stats="profile.stats"
|
||||||
|
/>
|
||||||
|
<DashboardCalendar :month-label="calendar.month" :days="calendar.days" />
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { gallery } from '@/utils/gallery'
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||||
|
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
|
||||||
|
import DashboardStatCard from '@/features/student/pages/components/DashboardStatCard.vue'
|
||||||
|
import DashboardCalendar from '@/features/student/pages/components/DashboardCalendar.vue'
|
||||||
|
import DashboardProfileCard from '@/features/student/pages/components/DashboardProfileCard.vue'
|
||||||
|
import DashboardActivityCard from '@/features/student/pages/components/DashboardActivityCard.vue'
|
||||||
|
|
||||||
const flower = gallery.registrationCompleteFlower
|
const performanceStats = [
|
||||||
|
{
|
||||||
|
icon: 'clock',
|
||||||
|
label: 'ساعات آموزشی سپریشده',
|
||||||
|
value: '۲۵',
|
||||||
|
unit: 'ساعت',
|
||||||
|
subtitle: 'گذرانده شده',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'video',
|
||||||
|
label: 'دوره های خریداری شده',
|
||||||
|
value: '۶',
|
||||||
|
unit: 'دوره',
|
||||||
|
subtitle: 'خریداری شده',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'user-sound',
|
||||||
|
label: 'تعداد جلسات اعزام شده',
|
||||||
|
value: '۱۲',
|
||||||
|
unit: 'جلسه',
|
||||||
|
subtitle: 'اعزام شده است',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const activities = [
|
||||||
|
{
|
||||||
|
pillIcon: 'shopping-bag',
|
||||||
|
pillLabel: 'آخرین دوره خریداری شده',
|
||||||
|
title: 'دوره تاریخچه ایران قدیم',
|
||||||
|
metaIcon: 'calendar',
|
||||||
|
meta: [
|
||||||
|
{ label: 'تاریخ شروع:', value: '۱۴۰۰/۷/۱۹, ۸:۳۰', numeric: true },
|
||||||
|
{ label: 'استاد:', value: 'علیرضا حسنی' },
|
||||||
|
{ label: 'تاریخ پایان:', value: '۱۴۰۰/۱۱/۱۰, ۸:۳۰', numeric: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pillIcon: 'video',
|
||||||
|
pillLabel: 'اخرین جلسه دیده شده',
|
||||||
|
title: 'دوره تخصصی تربیت مبلغ بین الملل',
|
||||||
|
metaIcon: 'video',
|
||||||
|
meta: [
|
||||||
|
{ label: 'جلسه:', value: 'بیستم و دوم' },
|
||||||
|
{ label: 'استاد:', value: 'علیرضا حسنی' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const profile = {
|
||||||
|
name: 'سید احمد علیزاده',
|
||||||
|
location: 'قم، ایران',
|
||||||
|
stats: [
|
||||||
|
{ label: 'مدت عضویت', value: '۲۳', unit: 'روز' },
|
||||||
|
{ label: 'دورههای تکمیلشده', value: '۱۲', unit: 'عدد' },
|
||||||
|
{ label: 'دورههای فعال', value: '۵', unit: 'عدد' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendar = {
|
||||||
|
month: 'شهریور ماه',
|
||||||
|
days: [
|
||||||
|
{ weekday: 'شنبه', day: '۱۵' },
|
||||||
|
{ weekday: 'یکشنبه', day: '۱۴' },
|
||||||
|
{ weekday: 'دوشنبه', day: '۱۳', marker: true },
|
||||||
|
{ weekday: 'سه شنبه', day: '۱۲' },
|
||||||
|
{ weekday: 'چهارشنبه', day: '۱۱', selected: true },
|
||||||
|
{ weekday: 'پنجشنبه', day: '۱۰' },
|
||||||
|
{ weekday: 'جمعه', day: '۹' },
|
||||||
|
],
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.student-dashboard {
|
.dashboard {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column-reverse;
|
||||||
gap: 1.5rem;
|
gap: 2rem;
|
||||||
|
|
||||||
&__welcome {
|
@media (min-width: 1024px) {
|
||||||
display: flex;
|
flex-direction: row;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: flex-start;
|
|
||||||
gap: 0.625rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__flower {
|
&__main {
|
||||||
object-fit: contain;
|
flex: 1;
|
||||||
max-height: 2.75rem;
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__hi {
|
&__section {
|
||||||
font-family: var(--font-family-fa);
|
display: flex;
|
||||||
font-weight: 400;
|
flex-direction: column;
|
||||||
font-size: 1rem;
|
gap: 1.25rem;
|
||||||
color: var(--color-prim-gray);
|
|
||||||
margin: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__greeting {
|
&__stats {
|
||||||
font-family: var(--font-family-fa);
|
display: grid;
|
||||||
font-weight: 500;
|
grid-template-columns: 1fr;
|
||||||
font-size: 1rem;
|
gap: 0.875rem;
|
||||||
margin: 0;
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 640px) {
|
||||||
font-size: 1.5rem;
|
grid-template-columns: repeat(3, 1fr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__placeholder {
|
&__activities {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1.25rem;
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__activity {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__pill {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: rgba(255, 255, 255, 58%);
|
||||||
|
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
|
||||||
|
border-radius: 0.9375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__pill-icon {
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__pill-label {
|
||||||
|
order: 1;
|
||||||
font-family: var(--font-family-fa);
|
font-family: var(--font-family-fa);
|
||||||
font-size: 0.875rem;
|
font-weight: 500;
|
||||||
color: var(--color-prim-gray);
|
font-size: 1.125rem;
|
||||||
text-align: center;
|
color: #4b4b4b;
|
||||||
padding: 3rem 0;
|
}
|
||||||
background: #fff;
|
|
||||||
border-radius: 1rem;
|
&__aside {
|
||||||
margin: 0;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
width: 19.625rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-inline-start: 1px solid #e6e6e6;
|
||||||
|
padding-inline-start: 2rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<template>
|
||||||
|
<div class="activity-card">
|
||||||
|
<SvgIcon name="card-corner-arrow" :size="'1.5rem'" class="activity-card__corner" />
|
||||||
|
|
||||||
|
<div class="activity-card__thumb">
|
||||||
|
<SvgIcon name="play" :size="'1.5rem'" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="activity-card__body">
|
||||||
|
<p class="activity-card__title">{{ title }}</p>
|
||||||
|
|
||||||
|
<div class="activity-card__meta">
|
||||||
|
<SvgIcon :name="metaIcon" :size="'0.875rem'" class="activity-card__meta-icon" />
|
||||||
|
<div class="activity-card__rows">
|
||||||
|
<p v-for="(row, i) in meta" :key="i" class="activity-card__row">
|
||||||
|
<span class="activity-card__row-label">{{ row.label }}</span>
|
||||||
|
<span
|
||||||
|
class="activity-card__row-value"
|
||||||
|
:class="{ 'activity-card__row-value--num': row.numeric }"
|
||||||
|
>
|
||||||
|
{{ row.value }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
/** rows of { label, value, numeric? } */
|
||||||
|
meta: { type: Array, default: () => [] },
|
||||||
|
/** @type {import('vue').PropType<import('@/components/icons/icon-names').IconName>} */
|
||||||
|
metaIcon: { type: String, default: 'calendar' },
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.activity-card {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
min-height: 5.9375rem;
|
||||||
|
background: rgba(255, 255, 255, 58%);
|
||||||
|
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
|
||||||
|
border-radius: 0.9375rem;
|
||||||
|
|
||||||
|
&__corner {
|
||||||
|
position: absolute;
|
||||||
|
inset-block-start: 0.75rem;
|
||||||
|
inset-inline-end: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__thumb {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 3.6875rem;
|
||||||
|
height: 3.6875rem;
|
||||||
|
background: #f4f4f4;
|
||||||
|
border-radius: 0.9375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.625rem;
|
||||||
|
text-align: start;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-family-fa);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #4b4b4b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__meta-icon {
|
||||||
|
order: 2;
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__rows {
|
||||||
|
order: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-family-fa);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: #4b4b4b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row-value {
|
||||||
|
&--num {
|
||||||
|
font-family: var(--font-family-number-fa);
|
||||||
|
direction: ltr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<template>
|
||||||
|
<div class="calendar">
|
||||||
|
<p class="calendar__title">{{ monthLabel }}</p>
|
||||||
|
|
||||||
|
<div class="calendar__week">
|
||||||
|
<div
|
||||||
|
v-for="(d, i) in days"
|
||||||
|
:key="i"
|
||||||
|
class="calendar__day"
|
||||||
|
:class="{ 'calendar__day--active': d.selected }"
|
||||||
|
>
|
||||||
|
<span v-if="d.marker" class="calendar__marker" />
|
||||||
|
<span class="calendar__weekday">{{ d.weekday }}</span>
|
||||||
|
<span class="calendar__date">{{ d.day }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
monthLabel: { type: String, default: '' },
|
||||||
|
/** ordered right-to-left: { weekday, day, selected?, marker? } */
|
||||||
|
days: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.calendar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1.125rem 1rem;
|
||||||
|
background: rgba(255, 255, 255, 58%);
|
||||||
|
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
|
||||||
|
border-radius: 0.9375rem;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0;
|
||||||
|
text-align: start;
|
||||||
|
font-family: var(--font-family-fa);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__week {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__day {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.375rem 0.125rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
background: var(--color-secondary);
|
||||||
|
box-shadow: 0 9px 9.75px rgba(0, 112, 116, 19%);
|
||||||
|
|
||||||
|
.calendar__weekday,
|
||||||
|
.calendar__date {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__marker {
|
||||||
|
position: absolute;
|
||||||
|
inset-block-start: 0.125rem;
|
||||||
|
inset-inline-start: 0.25rem;
|
||||||
|
width: 0.25rem;
|
||||||
|
height: 0.25rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__weekday {
|
||||||
|
font-family: var(--font-family-fa);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
color: #828282;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__date {
|
||||||
|
font-family: var(--font-family-number-fa);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #717171;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<template>
|
||||||
|
<div class="profile-card">
|
||||||
|
<div class="profile-card__avatar">
|
||||||
|
<img v-if="avatarUrl" :src="avatarUrl" :alt="name" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="profile-card__name">{{ name }}</p>
|
||||||
|
|
||||||
|
<div class="profile-card__location">
|
||||||
|
<SvgIcon name="map-pin-simple-area" :size="'0.9375rem'" />
|
||||||
|
<span>{{ location }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-card__stats">
|
||||||
|
<div v-for="(stat, i) in stats" :key="i" class="profile-card__stat">
|
||||||
|
<span class="profile-card__stat-label">{{ stat.label }}</span>
|
||||||
|
<span class="profile-card__stat-value">
|
||||||
|
{{ stat.value }}
|
||||||
|
<span class="profile-card__stat-unit">{{ stat.unit }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
name: { type: String, default: '' },
|
||||||
|
location: { type: String, default: '' },
|
||||||
|
avatarUrl: { type: String, default: '' },
|
||||||
|
/** mini-stats of { label, value, unit } */
|
||||||
|
stats: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.profile-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 2rem 1.25rem 1.5rem;
|
||||||
|
background: rgba(255, 255, 255, 58%);
|
||||||
|
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
|
||||||
|
border-radius: 0.9375rem;
|
||||||
|
|
||||||
|
&__avatar {
|
||||||
|
width: 7.75rem;
|
||||||
|
height: 7.75rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f4f4f4;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-family-fa);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__location {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-family: var(--font-family-fa);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #989898;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stats {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stat-label {
|
||||||
|
font-family: var(--font-family-fa);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: #a7a7a7;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stat-value {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-family: var(--font-family-number-fa);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 1.5625rem;
|
||||||
|
color: #818181;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stat-unit {
|
||||||
|
font-family: var(--font-family-fa);
|
||||||
|
font-weight: 300;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #929292;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<template>
|
||||||
|
<div class="stat-card">
|
||||||
|
<SvgIcon name="card-corner-arrow" :size="'1.5rem'" class="stat-card__corner" />
|
||||||
|
|
||||||
|
<div class="stat-card__head">
|
||||||
|
<SvgIcon :name="icon" :size="'1.0625rem'" class="stat-card__head-icon" />
|
||||||
|
<span class="stat-card__label">{{ label }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card__body">
|
||||||
|
<span class="stat-card__value">{{ value }}</span>
|
||||||
|
<div class="stat-card__meta">
|
||||||
|
<span class="stat-card__unit">{{ unit }}</span>
|
||||||
|
<span class="stat-card__subtitle">{{ subtitle }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
/** @type {import('vue').PropType<import('@/components/icons/icon-names').IconName>} */
|
||||||
|
icon: { type: String, required: true },
|
||||||
|
label: { type: String, default: '' },
|
||||||
|
value: { type: [String, Number], default: '' },
|
||||||
|
unit: { type: String, default: '' },
|
||||||
|
subtitle: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.stat-card {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1.125rem 1.25rem;
|
||||||
|
min-height: 8.75rem;
|
||||||
|
background: rgba(255, 255, 255, 58%);
|
||||||
|
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
|
||||||
|
border-radius: 0.9375rem;
|
||||||
|
|
||||||
|
&__corner {
|
||||||
|
position: absolute;
|
||||||
|
inset-block-start: 0.875rem;
|
||||||
|
inset-inline-end: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__head-icon {
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
order: 1;
|
||||||
|
font-family: var(--font-family-fa);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: #4b4b4b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 0.625rem;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
font-family: var(--font-family-number-fa);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 4rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-shadow: 0 5.25px 8.625px rgba(243, 102, 117, 18%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__unit {
|
||||||
|
font-family: var(--font-family-fa);
|
||||||
|
font-weight: 300;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: #6e6e6e;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__subtitle {
|
||||||
|
font-family: var(--font-family-fa);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #6e6e6e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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