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 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)
|
||||
|
||||
Vendored
+6
@@ -8,6 +8,7 @@ export type IconName =
|
||||
| 'bell'
|
||||
| 'book'
|
||||
| 'calendar'
|
||||
| 'card-corner-arrow'
|
||||
| 'caret-down'
|
||||
| 'caret-left'
|
||||
| 'caret-right'
|
||||
@@ -15,6 +16,7 @@ export type IconName =
|
||||
| 'chat-centered-dots'
|
||||
| 'check'
|
||||
| 'check-square'
|
||||
| 'clock'
|
||||
| 'close'
|
||||
| 'copy'
|
||||
| 'eye'
|
||||
@@ -33,16 +35,20 @@ export type IconName =
|
||||
| 'paper-plane-right'
|
||||
| 'pencil'
|
||||
| 'phone'
|
||||
| 'play'
|
||||
| 'plus'
|
||||
| 'scroll'
|
||||
| 'shopping-bag'
|
||||
| 'spinner'
|
||||
| 'square'
|
||||
| 'telegram'
|
||||
| 'trash'
|
||||
| 'upload'
|
||||
| 'user'
|
||||
| 'user-sound'
|
||||
| 'users'
|
||||
| 'users-three'
|
||||
| 'video'
|
||||
| 'warning'
|
||||
|
||||
export const iconNames: readonly IconName[]
|
||||
|
||||
@@ -21,10 +21,6 @@
|
||||
</div>
|
||||
|
||||
<div class="assignment-details__grid">
|
||||
<LineInfoBlock
|
||||
title="تاریخ شروع"
|
||||
:numeric-desc="formatJalaaliDate(assignment.startDate) || '—'"
|
||||
/>
|
||||
<LineInfoBlock title="تاریخ پایان" :numeric-desc="deadlineLabel" />
|
||||
<LineInfoBlock title="مدت زمان" :numeric-desc="durationLabel" />
|
||||
<LineInfoBlock title="اولویت" :desc="priorityLabel" />
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -39,11 +39,9 @@
|
||||
:src="mediaUrl"
|
||||
:video-id="session.id"
|
||||
/>
|
||||
<VoiceRecorder
|
||||
v-else-if="contentType === 'voice'"
|
||||
:model-value="mediaUrl"
|
||||
:disabled="true"
|
||||
/>
|
||||
<div v-else-if="contentType === 'voice'" class="session-details__voice">
|
||||
<VoiceRecorder :model-value="mediaUrl" :disabled="true" />
|
||||
</div>
|
||||
<a
|
||||
v-else-if="mediaUrl"
|
||||
:href="mediaUrl"
|
||||
@@ -111,9 +109,9 @@ const courseTitle = computed(() => 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;
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
|
||||
+50
-45
@@ -17,19 +17,19 @@
|
||||
<LineInfoBlock title="نام و نام خانوادگی" :numeric-desc="fullName" />
|
||||
<LineInfoBlock
|
||||
title="تاریخ تولد"
|
||||
:numeric-desc="profile.faBirthDate || formatJalaaliDate(profile.birthDate) || ''"
|
||||
:numeric-desc="formatJalaaliDate(personal.birthDate) || ''"
|
||||
/>
|
||||
<LineInfoBlock title="شماره تماس" :numeric-desc="user.phoneNumber" />
|
||||
<LineInfoBlock title="کد ملی" :numeric-desc="user.nationalCode" />
|
||||
<LineInfoBlock title="شماره تماس" :numeric-desc="personal.phone" />
|
||||
<LineInfoBlock title="کد ملی" :numeric-desc="personal.nationalCode" />
|
||||
<LineInfoBlock
|
||||
title="وضعیت تاهل"
|
||||
:desc="profile.faMaritalStatus || MARITAL_STATUS[profile.maritalStatus] || ''"
|
||||
:desc="MARITAL_STATUS[personal.maritalStatus] || ''"
|
||||
/>
|
||||
<LineInfoBlock title="جنسیت" :desc="profile.faGender || GENDER[profile.gender] || ''" />
|
||||
<LineInfoBlock title="استان" :desc="user?.province?.name || '-'" />
|
||||
<LineInfoBlock title="شهر" :desc="user?.city?.name || '-'" />
|
||||
<LineInfoBlock title="جنسیت" :desc="GENDER[personal.gender] || ''" />
|
||||
<LineInfoBlock title="استان" :desc="personal.province || '-'" />
|
||||
<LineInfoBlock title="شهر" :desc="personal.city || '-'" />
|
||||
<div class="user-verification-details__cell user-verification-details__cell--span-3">
|
||||
<LineInfoBlock title="آدرس" :desc="user?.address || '-'" />
|
||||
<LineInfoBlock title="آدرس" :desc="personal.address || '-'" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -39,15 +39,15 @@
|
||||
<div class="user-verification-details__grid user-verification-details__grid--4">
|
||||
<LineInfoBlock
|
||||
title="وضعیت تحصیلی"
|
||||
:desc="profile.faEducationStatus || EDUCATION_STATUS[profile.educationStatus] || ''"
|
||||
:desc="EDUCATION_STATUS[profile.educationStatus] || ''"
|
||||
/>
|
||||
<LineInfoBlock
|
||||
title="آخرین مدرک تحصیلی (طلبه)"
|
||||
:desc="profile.faSeminaryLevel || SEMINARY_LEVEL[profile.seminaryLevel] || ''"
|
||||
:desc="SEMINARY_LEVEL[profile.seminaryLevel] || ''"
|
||||
/>
|
||||
<LineInfoBlock
|
||||
title="آخرین مدرک تحصیلی"
|
||||
:desc="profile.faUniversityLevel || UNIVERSITY_LEVEL[profile.universityLevel] || ''"
|
||||
:desc="UNIVERSITY_LEVEL[profile.universityLevel] || ''"
|
||||
/>
|
||||
<LineInfoBlock title="نام حوزه علمیه/دانشگاه" :desc="profile.universityName || ''" />
|
||||
<div class="user-verification-details__cell user-verification-details__cell--full">
|
||||
@@ -56,30 +56,12 @@
|
||||
:desc="profile.fieldOfStudy || ''"
|
||||
/>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
<section class="user-verification-details__section">
|
||||
<LineTitleBlock title="اطلاعات شغلی" title-en="Job information" />
|
||||
<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">
|
||||
<LineInfoBlock
|
||||
title="خلاصهای از سوابق شغلی"
|
||||
@@ -190,7 +172,9 @@
|
||||
<section v-if="faithProduction?.url" class="user-verification-details__section">
|
||||
<LineTitleBlock title="بخش صوت" title-en="Audio section" />
|
||||
<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 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 { 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)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -428,6 +417,22 @@ const leaderMessage = computed(() =>
|
||||
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 {
|
||||
border-block-end: 1px solid var(--color-thd-gray);
|
||||
margin-block: 1.5rem;
|
||||
|
||||
@@ -14,13 +14,13 @@
|
||||
</AuthHeading>
|
||||
|
||||
<TextField
|
||||
v-model="form.verifyCode"
|
||||
name="verifyCode"
|
||||
v-model="form.code"
|
||||
name="code"
|
||||
label="کد تایید را وارد کنید"
|
||||
inputmode="numeric"
|
||||
:convert-digits="true"
|
||||
:error="errors.verifyCode"
|
||||
@blur="validateAt('verifyCode', form.verifyCode)"
|
||||
:error="errors.code"
|
||||
@blur="validateAt('code', form.code)"
|
||||
>
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="check" :size="22" color="var(--color-thd-gray)" />
|
||||
@@ -74,7 +74,7 @@ const props = defineProps({
|
||||
const emit = defineEmits(['back', 'verified'])
|
||||
|
||||
const schema = verifyCodeSchema
|
||||
const form = ref({ verifyCode: '' })
|
||||
const form = ref({ code: '' })
|
||||
const { validate, validateAt, errors } = useYup(schema)
|
||||
|
||||
const { applySession } = useAuth()
|
||||
@@ -106,7 +106,7 @@ const resending = computed(
|
||||
const onSubmit = async () => {
|
||||
const { isValid, payload } = await validate(form.value)
|
||||
if (!isValid) return
|
||||
const code = payload.verifyCode
|
||||
const code = payload.code
|
||||
|
||||
if (props.mode === 'loginWithCode') {
|
||||
const response = await verifyLoginMutation.mutateAsync({
|
||||
@@ -127,8 +127,10 @@ const onSubmit = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
await verifySignupMutation.mutateAsync({ phone: props.phoneNumber, verifyCode: code })
|
||||
emit('verified', { phone: props.phoneNumber, verifyCode: code })
|
||||
const response = await verifySignupMutation.mutateAsync({ phone: props.phoneNumber, code })
|
||||
applySession(response?.data ?? response)
|
||||
emit('verified', { phone: props.phoneNumber, code })
|
||||
redirectAfterLogin()
|
||||
}
|
||||
|
||||
const onResend = async () => {
|
||||
|
||||
@@ -112,6 +112,32 @@ let mediaStream = null
|
||||
let chunks = []
|
||||
let timerHandle = null
|
||||
let timerStart = 0
|
||||
let recorderType = null
|
||||
|
||||
// The backend only accepts these extensions. Browsers can't record mp3/wav, but
|
||||
// most can record into an mp4 container (→ .m4a audio / .mp4 video), so prefer a
|
||||
// supported type whose extension the backend allows.
|
||||
const ACCEPTED_TYPES = {
|
||||
audio: [
|
||||
{ mime: 'audio/mp4', ext: 'm4a' },
|
||||
{ mime: 'audio/mpeg', ext: 'mp3' },
|
||||
{ mime: 'audio/wav', ext: 'wav' },
|
||||
],
|
||||
video: [
|
||||
{ mime: 'video/mp4', ext: 'mp4' },
|
||||
{ mime: 'video/webm', ext: 'webm' },
|
||||
],
|
||||
}
|
||||
|
||||
const pickRecorderType = (kind) => {
|
||||
const candidates = ACCEPTED_TYPES[kind] || []
|
||||
const supported = candidates.find((c) => window.MediaRecorder?.isTypeSupported?.(c.mime))
|
||||
if (supported) return supported
|
||||
// Last-resort fallbacks so recording still works even if extensions differ.
|
||||
return kind === 'video'
|
||||
? { mime: 'video/webm', ext: 'webm' }
|
||||
: { mime: 'audio/webm', ext: 'webm' }
|
||||
}
|
||||
|
||||
const hintText = computed(() =>
|
||||
props.kind === 'video' ? 'ویدیوی خود را ضبط کنید' : 'صدای خود را ضبط کنید'
|
||||
@@ -148,7 +174,8 @@ const onStart = async () => {
|
||||
liveEl.value.srcObject = mediaStream
|
||||
}
|
||||
chunks = []
|
||||
mediaRecorder = new window.MediaRecorder(mediaStream)
|
||||
recorderType = pickRecorderType(props.kind)
|
||||
mediaRecorder = new window.MediaRecorder(mediaStream, { mimeType: recorderType.mime })
|
||||
mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data?.size > 0) chunks.push(e.data)
|
||||
}
|
||||
@@ -176,9 +203,9 @@ const onStop = () => {
|
||||
const handleStop = async () => {
|
||||
isRecording.value = false
|
||||
stopTracks()
|
||||
const mime = props.kind === 'video' ? 'video/webm' : 'audio/webm'
|
||||
const mime = recorderType?.mime || mediaRecorder?.mimeType || 'audio/webm'
|
||||
const ext = recorderType?.ext || 'webm'
|
||||
const blob = new Blob(chunks, { type: mime })
|
||||
const ext = props.kind === 'video' ? 'webm' : 'webm'
|
||||
const file = new File([blob], `recording.${ext}`, { type: mime, lastModified: Date.now() })
|
||||
|
||||
if (previewUrl.value && previewUrl.value.startsWith('blob:')) {
|
||||
|
||||
@@ -17,24 +17,11 @@
|
||||
<div class="personal-info__row">
|
||||
<div class="personal-info__cell">
|
||||
<TextField
|
||||
v-model="form.firstName"
|
||||
name="firstName"
|
||||
label="نام"
|
||||
:error="errors.firstName"
|
||||
@blur="validateAt('firstName', form.firstName)"
|
||||
>
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="user" :size="20" color="var(--color-thd-gray)" />
|
||||
</template>
|
||||
</TextField>
|
||||
</div>
|
||||
<div class="personal-info__cell">
|
||||
<TextField
|
||||
v-model="form.lastName"
|
||||
name="lastName"
|
||||
label="نام خانوادگی"
|
||||
:error="errors.lastName"
|
||||
@blur="validateAt('lastName', form.lastName)"
|
||||
v-model="form.name"
|
||||
name="name"
|
||||
label="نام و نام خانوادگی"
|
||||
:error="errors.name"
|
||||
@blur="validateAt('name', form.name)"
|
||||
>
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="user" :size="20" color="var(--color-thd-gray)" />
|
||||
@@ -57,6 +44,7 @@
|
||||
label="کد ملی"
|
||||
inputmode="numeric"
|
||||
:convert-digits="true"
|
||||
:disabled="!!initialNationalCode"
|
||||
:error="errors.nationalCode"
|
||||
@blur="validateAt('nationalCode', form.nationalCode)"
|
||||
>
|
||||
@@ -153,7 +141,7 @@
|
||||
|
||||
<StepActions
|
||||
:show-back="false"
|
||||
:loading="uploadMutation.isPending.value || saveRegisterMutation.isPending.value"
|
||||
:loading="uploadMutation.isPending.value || updateProfileMutation.isPending.value"
|
||||
/>
|
||||
</form>
|
||||
</template>
|
||||
@@ -164,6 +152,7 @@ import useYup from '@/composables/useYup'
|
||||
import useAuth from '@/composables/useAuth'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import { convertToJalali } from '@/utils/date-utils'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import TextField from '@/components/form/TextField.vue'
|
||||
import SelectField from '@/components/form/SelectField.vue'
|
||||
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
||||
@@ -173,15 +162,15 @@ import TextareaField from '@/components/form/TextareaField.vue'
|
||||
import DatePickerField from '@/components/form/DatePickerField.vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { GENDER, MARITAL_STATUS, STUDENT_REGISTRATION } from '@/enums'
|
||||
import { toKeyValueList } from '@/features/auth/store/student-registration'
|
||||
import { personalInformationSchema } from '@/features/auth/schema/student-register'
|
||||
import StepActions from '@/features/auth/components/studentRegister/StepActions.vue'
|
||||
import { useStudentRegistrationStore } from '@/features/auth/store/student-registration'
|
||||
import { useSaveRegisterDataMutation, useUploadMediaMutation } from '@/services/query/auth'
|
||||
import { useGetCitiesOfProvinceQuery, useGetProvincesQuery } from '@/services/query/common'
|
||||
import { authKeys, useUpdateProfileMutation, useUploadMediaMutation } from '@/services/query/auth'
|
||||
|
||||
const store = useStudentRegistrationStore()
|
||||
const { user } = useAuth()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const isMobile = ref(window.innerWidth < 768)
|
||||
const onResize = () => {
|
||||
@@ -207,12 +196,12 @@ const maxBirthDate = computed(() => {
|
||||
})
|
||||
|
||||
const initialPhone = ref(user.value?.phone || store.personal.phone || '')
|
||||
const initialNationalCode = ref(user.value?.nationalCode || store.personal.nationalCode || '')
|
||||
|
||||
const form = ref({
|
||||
firstName: store.personal.firstName || '',
|
||||
lastName: store.personal.lastName || '',
|
||||
name: user.value?.name || store.personal.name || '',
|
||||
birthDate: store.personal.birthDate || '',
|
||||
nationalCode: store.personal.nationalCode || '',
|
||||
nationalCode: initialNationalCode.value,
|
||||
maritalStatus: store.personal.maritalStatus || '',
|
||||
gender: store.personal.gender || '',
|
||||
phone: initialPhone.value,
|
||||
@@ -264,13 +253,30 @@ const onAvatarCropped = async (file) => {
|
||||
|
||||
const onAvatarError = (msg) => toast.error(msg)
|
||||
|
||||
const saveRegisterMutation = useSaveRegisterDataMutation()
|
||||
const updateProfileMutation = useUpdateProfileMutation()
|
||||
|
||||
const onSubmit = async () => {
|
||||
const { isValid, payload } = await validate(form.value)
|
||||
if (!isValid) return
|
||||
const next = { ...payload, avatar: form.value.avatar }
|
||||
await saveRegisterMutation.mutateAsync(toKeyValueList(next))
|
||||
// Personal info maps to the user model and is persisted via PATCH /me.
|
||||
// (http auto-converts camelCase → snake_case: birthday, national_code, …)
|
||||
const body = {
|
||||
name: payload.name,
|
||||
birthday: payload.birthDate,
|
||||
nationalCode: payload.nationalCode,
|
||||
marriageStatus: payload.maritalStatus,
|
||||
gender: payload.gender,
|
||||
phone: payload.phone,
|
||||
provinceId: payload.provinceId,
|
||||
cityId: payload.cityId,
|
||||
address: payload.address,
|
||||
...(form.value.avatar?.uploadId
|
||||
? { avatarMediaId: Number.parseInt(form.value.avatar.uploadId) }
|
||||
: {}),
|
||||
}
|
||||
await updateProfileMutation.mutateAsync(body)
|
||||
await queryClient.invalidateQueries({ queryKey: authKeys.me() })
|
||||
store.personal = { ...store.personal, ...next }
|
||||
store.markStepAsCompleted(STUDENT_REGISTRATION.PERSONAL_INFORMATION)
|
||||
store.goToNextStep()
|
||||
|
||||
@@ -24,7 +24,7 @@ const router = useRouter()
|
||||
const step = ref(SIGNUP_STEPS.SIGNUP)
|
||||
const phoneNumber = ref('')
|
||||
|
||||
const handleNext = ({ phoneNumber: phone }) => {
|
||||
const handleNext = ({ phone }) => {
|
||||
phoneNumber.value = phone
|
||||
step.value = SIGNUP_STEPS.VERIFY_CODE
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export const signupSchema = object().shape({
|
||||
})
|
||||
|
||||
export const verifyCodeSchema = object().shape({
|
||||
verifyCode: string().required().length(6),
|
||||
code: string().required().length(6),
|
||||
})
|
||||
|
||||
export const resetPasswordSchema = object().shape({
|
||||
|
||||
@@ -11,13 +11,12 @@ const requiredString = (min = 0) => {
|
||||
export const personalInformationSchema = object().shape({
|
||||
avatar: mixed().notRequired(),
|
||||
avatarId: mixed().notRequired(),
|
||||
firstName: requiredString(3),
|
||||
lastName: requiredString(3),
|
||||
name: requiredString(3),
|
||||
birthDate: string().required(),
|
||||
nationalCode: nationalCodeRule,
|
||||
maritalStatus: string().required(),
|
||||
gender: string().required(),
|
||||
phoneNumber: phoneNumberRule,
|
||||
phone: phoneNumberRule,
|
||||
provinceId: mixed().required(),
|
||||
cityId: mixed().required(),
|
||||
address: requiredString(10),
|
||||
|
||||
@@ -9,8 +9,7 @@ const emptyState = () => ({
|
||||
personal: {
|
||||
avatar: null,
|
||||
avatarId: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
name: '',
|
||||
birthDate: '',
|
||||
nationalCode: '',
|
||||
maritalStatus: '',
|
||||
@@ -75,8 +74,7 @@ const SECTION_FIELDS = {
|
||||
personal: [
|
||||
'avatar',
|
||||
'avatarId',
|
||||
'firstName',
|
||||
'lastName',
|
||||
'name',
|
||||
'birthDate',
|
||||
'nationalCode',
|
||||
'maritalStatus',
|
||||
|
||||
@@ -5,12 +5,6 @@
|
||||
|
||||
<div class="ses__stats">
|
||||
<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-value">{{ questionsCountText }}</span>
|
||||
</div>
|
||||
@@ -65,11 +59,6 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['start'])
|
||||
|
||||
const attemptsCountText = computed(() =>
|
||||
props.exam.attemptsCount == null
|
||||
? `${(props.exam.attempts || []).length} آزمون`
|
||||
: `${props.exam.attemptsCount} آزمون`
|
||||
)
|
||||
const questionsCountText = computed(() =>
|
||||
props.exam.questionsCount == null ? '—' : `${props.exam.questionsCount}`
|
||||
)
|
||||
|
||||
@@ -14,12 +14,12 @@
|
||||
<p class="student-lesson-item__title">{{ lesson.title || '—' }}</p>
|
||||
<Badge
|
||||
variant="primary"
|
||||
label="زمان یادگیری :"
|
||||
:value="hoursText"
|
||||
label="ظرفیت :"
|
||||
:value="capacityText"
|
||||
class="student-lesson-item__hours"
|
||||
>
|
||||
<template #prepend>
|
||||
<SvgIcon name="calendar" :size="11" color="#007074" />
|
||||
<SvgIcon name="users-three" :size="11" color="#007074" />
|
||||
</template>
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -75,8 +75,8 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['show-details'])
|
||||
|
||||
const hoursText = computed(() =>
|
||||
props.lesson.learningHours == null ? '—' : `${props.lesson.learningHours} ساعت`
|
||||
const capacityText = computed(() =>
|
||||
props.lesson.capacity == null ? '—' : `${props.lesson.capacity} نفر`
|
||||
)
|
||||
const sessionsText = computed(() =>
|
||||
props.lesson.sessionsCount == null ? '—' : `${props.lesson.sessionsCount} جلسه`
|
||||
|
||||
@@ -1,69 +1,226 @@
|
||||
<template>
|
||||
<div class="student-dashboard">
|
||||
<div class="student-dashboard__welcome">
|
||||
<img :src="flower" alt="flower" class="student-dashboard__flower" />
|
||||
<div>
|
||||
<p class="student-dashboard__hi">سلام بر شما</p>
|
||||
<p class="student-dashboard__greeting">به سامانه موسسه بانو خوش آمدید.</p>
|
||||
</div>
|
||||
<div class="dashboard">
|
||||
<div class="dashboard__main">
|
||||
<!-- گزارش عملکرد -->
|
||||
<section class="dashboard__section">
|
||||
<BoxedIconTitleBlock title="گزارش عملکرد" desc="مروری بر شاخصهای آموزشی و فعالیتهای شما.">
|
||||
<template #icon>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.student-dashboard {
|
||||
.dashboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
flex-direction: column-reverse;
|
||||
gap: 2rem;
|
||||
|
||||
&__welcome {
|
||||
display: flex;
|
||||
@media (min-width: 1024px) {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
&__flower {
|
||||
object-fit: contain;
|
||||
max-height: 2.75rem;
|
||||
&__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
&__hi {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 400;
|
||||
font-size: 1rem;
|
||||
color: var(--color-prim-gray);
|
||||
margin: 0;
|
||||
&__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
&__greeting {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
&__stats {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.875rem;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
font-size: 1.5rem;
|
||||
@media (min-width: 640px) {
|
||||
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-size: 0.875rem;
|
||||
color: var(--color-prim-gray);
|
||||
text-align: center;
|
||||
padding: 3rem 0;
|
||||
background: #fff;
|
||||
border-radius: 1rem;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
font-size: 1.125rem;
|
||||
color: #4b4b4b;
|
||||
}
|
||||
|
||||
&__aside {
|
||||
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>
|
||||
|
||||
@@ -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">
|
||||
<DatePickerField
|
||||
v-model="form.birthDate"
|
||||
name="birthDate"
|
||||
v-model="form.birthday"
|
||||
name="birthday"
|
||||
label="تاریخ تولد"
|
||||
:max="todayIso"
|
||||
:error="errors.birthDate"
|
||||
:error="errors.birthday"
|
||||
/>
|
||||
</div>
|
||||
<div class="edit-profile__cell edit-profile__cell--third">
|
||||
@@ -253,7 +253,7 @@ const initialPhoneNumber = ref('')
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
birthDate: '',
|
||||
birthday: '',
|
||||
nationalCode: '',
|
||||
marriageStatus: '',
|
||||
gender: '',
|
||||
@@ -298,34 +298,38 @@ const onProvinceChange = () => {
|
||||
|
||||
const { data: profile } = useGetMeQuery()
|
||||
|
||||
watch(profile, (user) => {
|
||||
if (!user) return
|
||||
const fullName =
|
||||
user.name || [user.firstName, user.lastName].filter(Boolean).join(' ').trim() || ''
|
||||
const phone = user.phone || user.phoneNumber || ''
|
||||
form.value = {
|
||||
...form.value,
|
||||
name: fullName,
|
||||
birthDate: user.profile?.birthDate || '',
|
||||
nationalCode: user.nationalCode || '',
|
||||
marriageStatus: user.profile?.marriageStatus || '',
|
||||
gender: user.profile?.gender || '',
|
||||
phone,
|
||||
provinceId: user?.province?.id || '',
|
||||
cityId: user?.city?.id || '',
|
||||
address: user?.address || '',
|
||||
avatarMediaId: user.profile?.avatarMediaId || null,
|
||||
}
|
||||
initialNationalCode.value = user.nationalCode || ''
|
||||
initialPhoneNumber.value = phone
|
||||
if (user.avatarUrl) avatar.value = { url: user.avatarUrl }
|
||||
})
|
||||
watch(
|
||||
profile,
|
||||
(user) => {
|
||||
if (!user) return
|
||||
const fullName =
|
||||
user.name || [user.firstName, user.lastName].filter(Boolean).join(' ').trim() || ''
|
||||
const phone = user.phone || user.phoneNumber || ''
|
||||
form.value = {
|
||||
...form.value,
|
||||
name: fullName,
|
||||
birthday: user?.birthday || '',
|
||||
nationalCode: user.nationalCode || '',
|
||||
marriageStatus: user?.marriageStatus || '',
|
||||
gender: user?.gender || '',
|
||||
phone,
|
||||
provinceId: user?.province?.id || '',
|
||||
cityId: user?.city?.id || '',
|
||||
address: user?.address || '',
|
||||
avatarMediaId: user?.avatarMediaId || null,
|
||||
}
|
||||
initialNationalCode.value = user.nationalCode || ''
|
||||
initialPhoneNumber.value = phone
|
||||
if (user.avatarUrl) avatar.value = { url: user.avatarUrl }
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const uploadMutation = useUploadMediaMutation()
|
||||
|
||||
const onAvatarCropped = async (file) => {
|
||||
try {
|
||||
const formData = objectToFormData({ file, subType: 'avatar', context: 'user' })
|
||||
const formData = objectToFormData({ file, purpose: 'avatar', context: 'user' })
|
||||
const response = await uploadMutation.mutateAsync(formData)
|
||||
const payload = response?.data ?? response
|
||||
avatar.value = { url: payload?.url, uploadId: payload?.uploadId, ...payload }
|
||||
@@ -344,7 +348,7 @@ const onSubmit = async () => {
|
||||
const { isValid, payload } = await validate(form.value)
|
||||
if (!isValid) return
|
||||
// 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
|
||||
// separately via POST /reset-password.
|
||||
const body = {
|
||||
@@ -471,7 +475,7 @@ const onCancel = () => router.push({ name: 'student-dashboard' })
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.625rem;
|
||||
justify-content: space-between;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ export const studentProfileSchema = object().shape({
|
||||
name: string().required().min(2),
|
||||
phone: phoneNumberRule,
|
||||
nationalCode: nationalCodeRule,
|
||||
birthDate: string().nullable().notRequired(),
|
||||
birthday: string().nullable().notRequired(),
|
||||
marriageStatus: string().nullable().notRequired(),
|
||||
gender: string().nullable().notRequired(),
|
||||
provinceId: string().nullable().notRequired(),
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<p class="student-session-item__title">{{ session.title || '—' }}</p>
|
||||
<div class="student-session-item__hours">
|
||||
<SvgIcon name="calendar" :size="9" color="#007074" />
|
||||
<span class="student-session-item__hours-label">زمان جلسه :</span>
|
||||
<span class="student-session-item__hours-value">{{ durationText }}</span>
|
||||
<span class="student-session-item__hours-label">مهلت :</span>
|
||||
<span class="student-session-item__hours-value">{{ deadlineText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
<Badge v-if="isComplete" 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.needsAssignment" icon="paper-plane">دارای تکلیف</Badge>
|
||||
<Badge v-if="session.hasQuiz" icon="file">دارای آزمون</Badge>
|
||||
<Badge v-if="session.homeworksCount > 0" icon="paper-plane">دارای تکلیف</Badge>
|
||||
<Badge v-if="session.examsCount > 0" icon="file">دارای آزمون</Badge>
|
||||
<Badge v-if="isOnline" icon="users-three">جلسه آنلاین</Badge>
|
||||
</div>
|
||||
|
||||
@@ -37,6 +37,7 @@ import { computed } from 'vue'
|
||||
import Badge from '@/components/Badge.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import { formatJalaaliDate } from '@/utils/date-utils'
|
||||
|
||||
const props = defineProps({
|
||||
session: { type: Object, required: true },
|
||||
@@ -44,8 +45,8 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['start'])
|
||||
|
||||
const durationText = computed(() =>
|
||||
props.session.durationHours == null ? '—' : `${props.session.durationHours} ساعت`
|
||||
const deadlineText = computed(
|
||||
() => formatJalaaliDate(props.session.deadline || props.session.endDate) || '—'
|
||||
)
|
||||
|
||||
const isOnline = computed(() => props.session.type === 'online')
|
||||
|
||||
@@ -119,8 +119,6 @@ const { data: session, isLoading } = useStudentSessionQuery(sessionId, {
|
||||
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 homeworkPagination = ref({ page: 1, perPage: 20 })
|
||||
const { data: homeworksData } = useStudentHomeworksListQuery(homeworkFilters, homeworkPagination, {
|
||||
@@ -152,7 +150,14 @@ const homeworkId = computed(
|
||||
)
|
||||
|
||||
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 payload = response?.data ?? response
|
||||
return payload?.id ?? null
|
||||
|
||||
@@ -15,7 +15,7 @@ export const endpoints = {
|
||||
resetPassword: '/auth/password/reset',
|
||||
|
||||
resendVerificationCodeForRegister: '/resend-verification-code',
|
||||
verifyCode: '/verify-code',
|
||||
verifyCode: '/auth/login/otp/verify',
|
||||
completeProfile: '/complete-profile',
|
||||
getRegistrationQuestionVideo: '/verification-registration-media',
|
||||
|
||||
|
||||
@@ -13,21 +13,12 @@ export const adminTicketsKeys = {
|
||||
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 = {}) =>
|
||||
useQuery({
|
||||
queryKey: ['admin', 'tickets', 'list', filtersRef, paginationRef],
|
||||
queryFn: () =>
|
||||
apiGetAdminTickets({ ...cleanFilters(filtersRef.value), ...paginationRef.value }),
|
||||
select: selectList,
|
||||
select: (response) => response?.data ?? response,
|
||||
...options,
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user