This commit is contained in:
sajjadtalkhabi
2026-05-15 17:12:51 +03:30
parent b8ced45375
commit c82701888a
101 changed files with 8125 additions and 96 deletions
+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<g clip-path="url(#clip0_1_32404)">
<path d="M12 12.375C12.6213 12.375 13.125 11.8713 13.125 11.25C13.125 10.6287 12.6213 10.125 12 10.125C11.3787 10.125 10.875 10.6287 10.875 11.25C10.875 11.8713 11.3787 12.375 12 12.375Z" fill="#B3B3B3"/>
<path d="M7.875 12.375C8.49632 12.375 9 11.8713 9 11.25C9 10.6287 8.49632 10.125 7.875 10.125C7.25368 10.125 6.75 10.6287 6.75 11.25C6.75 11.8713 7.25368 12.375 7.875 12.375Z" fill="#B3B3B3"/>
<path d="M16.125 12.375C16.7463 12.375 17.25 11.8713 17.25 11.25C17.25 10.6287 16.7463 10.125 16.125 10.125C15.5037 10.125 15 10.6287 15 11.25C15 11.8713 15.5037 12.375 16.125 12.375Z" fill="#B3B3B3"/>
<path d="M9.85031 18L11.3503 20.625C11.4159 20.74 11.5107 20.8356 11.6251 20.9021C11.7395 20.9685 11.8695 21.0036 12.0019 21.0036C12.1342 21.0036 12.2642 20.9685 12.3787 20.9021C12.4931 20.8356 12.5879 20.74 12.6534 20.625L14.1534 18H20.25C20.4489 18 20.6397 17.921 20.7803 17.7803C20.921 17.6397 21 17.4489 21 17.25V5.25C21 5.05109 20.921 4.86032 20.7803 4.71967C20.6397 4.57902 20.4489 4.5 20.25 4.5H3.75C3.55109 4.5 3.36032 4.57902 3.21967 4.71967C3.07902 4.86032 3 5.05109 3 5.25V17.25C3 17.4489 3.07902 17.6397 3.21967 17.7803C3.36032 17.921 3.55109 18 3.75 18H9.85031Z" stroke="#B3B3B3" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1_32404">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1_32401)">
<path d="M12 21C12 21 2.25 15.75 2.25 9.5625C2.25 8.21984 2.78337 6.93217 3.73277 5.98277C4.68217 5.03337 5.96984 4.5 7.3125 4.5C9.43031 4.5 11.2444 5.65406 12 7.5C12.7556 5.65406 14.5697 4.5 16.6875 4.5C18.0302 4.5 19.3178 5.03337 20.2672 5.98277C21.2166 6.93217 21.75 8.21984 21.75 9.5625C21.75 15.75 12 21 12 21Z" stroke="#B3B3B3" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1_32401">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 625 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23" fill="none">
<path d="M12.4258 5.68491L13.3926 4.71811C13.7731 4.3375 14.2248 4.03558 14.722 3.82959C15.2192 3.6236 15.7521 3.51758 16.2903 3.51758C16.8285 3.51758 17.3614 3.6236 17.8586 3.82959C18.3558 4.03558 18.8076 4.3375 19.1881 4.71811C19.5687 5.09861 19.8706 5.55036 20.0766 6.04756C20.2826 6.54477 20.3886 7.07768 20.3886 7.61586C20.3886 8.15405 20.2826 8.68696 20.0766 9.18416C19.8706 9.68136 19.5687 10.1331 19.1881 10.5136L17.0453 12.6564L16.1365 13.5652C15.7556 13.9462 15.3032 14.2483 14.8054 14.4544C14.3076 14.6604 13.774 14.7662 13.2352 14.7658C12.6964 14.7654 12.163 14.6587 11.6655 14.452C11.168 14.2452 10.7161 13.9424 10.3357 13.5608C9.94202 13.1672 9.6329 12.6973 9.42735 12.18C9.2218 11.6626 9.12416 11.1087 9.14042 10.5523" stroke="#DDDDDD" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.0742 16.8152L9.10745 17.782C8.7265 18.163 8.27417 18.4651 7.77633 18.6711C7.2785 18.8772 6.74492 18.983 6.20614 18.9826C5.66737 18.9822 5.13395 18.8755 4.63643 18.6688C4.1389 18.462 3.68703 18.1592 3.30667 17.7776C2.54025 17.0085 2.11034 15.9667 2.11133 14.8809C2.11232 13.7952 2.54412 12.7542 3.31194 11.9865L6.3635 8.93491C6.744 8.5543 7.19575 8.25238 7.69296 8.04639C8.19016 7.8404 8.72307 7.73438 9.26126 7.73438C9.79944 7.73438 10.3324 7.8404 10.8296 8.04639C11.3268 8.25238 11.7785 8.5543 12.159 8.93491C12.5543 9.32844 12.8648 9.79882 13.0713 10.317C13.2778 10.8352 13.3759 11.3902 13.3596 11.9478" stroke="#DDDDDD" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" fill="none">
<g clip-path="url(#clip0_1_33264)">
<path d="M5.625 4.21875C6.40165 4.21875 7.03125 3.58915 7.03125 2.8125C7.03125 2.03585 6.40165 1.40625 5.625 1.40625C4.84835 1.40625 4.21875 2.03585 4.21875 2.8125C4.21875 3.58915 4.84835 4.21875 5.625 4.21875Z" stroke="#989898" stroke-width="0.5775" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.625 4.21875V7.73438" stroke="#989898" stroke-width="0.5775" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.38281 6.11133C9.03428 6.375 10.1953 7.00298 10.1953 7.73423C10.1953 8.70498 8.14922 9.49204 5.625 9.49204C3.10078 9.49204 1.05469 8.70498 1.05469 7.73423C1.05469 7.00298 2.21572 6.37588 3.86719 6.11133" stroke="#989898" stroke-width="0.5775" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1_33264">
<rect width="11.25" height="11.25" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 952 B

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23" fill="none">
<g clip-path="url(#clip0_1_27293)">
<path d="M14.7656 5.625C14.7656 3.68337 13.1916 2.10938 11.25 2.10938C9.30837 2.10938 7.73438 3.68337 7.73438 5.625V11.25C7.73438 13.1916 9.30837 14.7656 11.25 14.7656C13.1916 14.7656 14.7656 13.1916 14.7656 11.25V5.625Z" stroke="#A7A7A7" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.25 17.5781V21.0938" stroke="#A7A7A7" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M17.5781 11.25C17.5781 12.9283 16.9114 14.5379 15.7247 15.7247C14.5379 16.9114 12.9283 17.5781 11.25 17.5781C9.57168 17.5781 7.96209 16.9114 6.77534 15.7247C5.58859 14.5379 4.92188 12.9283 4.92188 11.25" stroke="#A7A7A7" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1_27293">
<rect width="22.5" height="22.5" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 955 B

+1 -1
View File
@@ -1,3 +1,3 @@
<svg width="36" height="35" viewBox="0 0 36 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 36 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22.3488 15.0762C22.7483 15.0762 23.0857 14.9392 23.3611 14.6654C23.6365 14.3918 23.7742 14.0594 23.7742 13.6681C23.7742 13.2771 23.6343 12.9469 23.3545 12.6773C23.075 12.4078 22.7353 12.273 22.3356 12.273C21.9361 12.273 21.5986 12.41 21.3232 12.6838C21.0479 12.9574 20.9102 13.2898 20.9102 13.6811C20.9102 14.0721 21.0501 14.4023 21.3299 14.6719C21.6094 14.9414 21.9491 15.0762 22.3488 15.0762ZM12.9121 15.0762C13.3116 15.0762 13.649 14.9392 13.9244 14.6654C14.1998 14.3918 14.3375 14.0594 14.3375 13.6681C14.3375 13.2771 14.1976 12.9469 13.9178 12.6773C13.6383 12.4078 13.2986 12.273 12.8989 12.273C12.4994 12.273 12.1619 12.41 11.8865 12.6838C11.6111 12.9574 11.4734 13.2898 11.4734 13.6811C11.4734 14.0721 11.6133 14.4023 11.8931 14.6719C12.1727 14.9414 12.5123 15.0762 12.9121 15.0762ZM17.6326 29.577C15.8909 29.577 14.2524 29.2533 12.7171 28.6059C11.1818 27.9583 9.84618 27.0797 8.71035 25.9699C7.57452 24.8599 6.67711 23.5562 6.01813 22.0588C5.35891 20.5614 5.0293 18.9615 5.0293 17.259C5.0293 15.5544 5.36001 13.9507 6.02144 12.4481C6.68311 10.9454 7.58088 9.63823 8.71476 8.52656C9.84888 7.4149 11.1809 6.53658 12.7109 5.89162C14.2408 5.24643 15.8755 4.92383 17.615 4.92383C19.3567 4.92383 20.9952 5.24751 22.5306 5.89486C24.0659 6.54245 25.4015 7.42112 26.5373 8.53087C27.6731 9.64086 28.5705 10.9446 29.2295 12.442C29.8887 13.9393 30.2184 15.5393 30.2184 17.2418C30.2184 18.9464 29.8876 20.5501 29.2262 22.0527C28.5645 23.5554 27.6668 24.8626 26.5329 25.9742C25.3988 27.0859 24.0667 27.9642 22.5368 28.6092C21.0068 29.2544 19.3721 29.577 17.6326 29.577ZM17.6227 28.6605C20.867 28.6605 23.6212 27.5528 25.8855 25.3374C28.1499 23.1222 29.282 20.4269 29.282 17.2515C29.282 14.0763 28.1502 11.3806 25.8866 9.16445C23.6233 6.94831 20.8694 5.84023 17.6249 5.84023C14.3807 5.84023 11.6264 6.94795 9.36211 9.16337C7.09779 11.3786 5.96562 14.0739 5.96562 17.2493C5.96562 20.4245 7.09742 23.1202 9.36101 25.3363C11.6244 27.5525 14.3783 28.6605 17.6227 28.6605ZM17.6238 23.8988C18.755 23.8988 19.7995 23.6475 20.7574 23.1449C21.715 22.642 22.5105 21.959 23.1438 21.0961C23.2703 20.8802 23.2794 20.6615 23.1709 20.4398C23.0627 20.2182 22.8757 20.1074 22.6099 20.1074H12.6521C12.3804 20.1074 12.1895 20.2182 12.0793 20.4398C11.9691 20.6615 11.9835 20.8765 12.1223 21.0849C12.7423 21.9546 13.5364 22.641 14.5046 23.1441C15.4727 23.6473 16.5125 23.8988 17.6238 23.8988Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

+1 -1
View File
@@ -1,4 +1,4 @@
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1279_3967)">
<path d="M12.2344 10.875H6.79688" stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.11956 18.8005C4.07177 18.931 4.06515 19.073 4.10058 19.2074C4.13601 19.3418 4.2118 19.4621 4.31772 19.5521C4.42364 19.6421 4.55461 19.6975 4.69297 19.7108C4.83133 19.724 4.97043 19.6945 5.09152 19.6263L19.365 11.4624C19.4712 11.4036 19.5597 11.3175 19.6214 11.2129C19.6831 11.1083 19.7156 10.9891 19.7156 10.8677C19.7156 10.7463 19.6831 10.6271 19.6214 10.5225C19.5597 10.4179 19.4712 10.3317 19.365 10.273L5.09152 2.12945C4.97079 2.06193 4.83234 2.03285 4.69466 2.04609C4.55697 2.05934 4.4266 2.11428 4.32096 2.20357C4.21533 2.29286 4.13945 2.41226 4.10346 2.54582C4.06747 2.67938 4.07309 2.82074 4.11956 2.95102L6.79668 10.8753L4.11956 18.8005Z" stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 109 109" fill="none">
<g clip-path="url(#clip0_1_29013)">
<path d="M84.9609 74.7656V27.1875C84.9609 24.4835 83.8868 21.8903 81.9748 19.9783C80.0628 18.0663 77.4696 16.9922 74.7656 16.9922H16.9922" stroke="#C3C3C3" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M44.1797 44.1797H71.3672" stroke="#F55D6D" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M44.1797 57.7734H71.3672" stroke="#F55D6D" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.1953 33.9844C10.1953 33.9844 6.79688 31.4355 6.79688 27.1875C6.79688 24.4835 7.87102 21.8903 9.78301 19.9783C11.695 18.0663 14.2882 16.9922 16.9922 16.9922C19.6962 16.9922 22.2894 18.0663 24.2014 19.9783C26.1134 21.8903 27.1875 24.4835 27.1875 27.1875V81.5625C27.1875 84.2665 28.2616 86.8597 30.1736 88.7717C32.0856 90.6837 34.6788 91.7578 37.3828 91.7578M37.3828 91.7578C40.0868 91.7578 42.68 90.6837 44.592 88.7717C46.504 86.8597 47.5781 84.2665 47.5781 81.5625C47.5781 77.3145 44.1797 74.7656 44.1797 74.7656H91.7578C91.7578 74.7656 95.1562 77.3145 95.1562 81.5625C95.1562 84.2665 94.0821 86.8597 92.1701 88.7717C90.2581 90.6837 87.6649 91.7578 84.9609 91.7578H37.3828Z" stroke="#C3C3C3" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1_29013">
<rect width="108.75" height="108.75" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+1
View File
@@ -79,6 +79,7 @@ const handleClick = (event) => {
font-family: var(--font-family-fa);
text-decoration: none;
padding: 0 1rem;
white-space: nowrap;
&__text {
line-height: 1.25rem;
+113 -31
View File
@@ -1,23 +1,57 @@
<template>
<div class="tabs-block">
<div class="tabs-block__nav">
<button
<div class="tabs-block__tabs">
<button
v-for="tab in tabs"
:key="tab.name"
type="button"
class="tabs-block__tab"
:class="{ 'tabs-block__tab--active': activeTab === tab.name }"
@click="onChange(tab.name)"
>
<span class="tabs-block__tab-card">
<SvgIcon v-if="tab.icon" :name="tab.icon" :size="16" color="#bcbcbc" />
<span class="tabs-block__tab-label">{{ tab.label }}</span>
</span>
</button>
</div>
<div
v-for="tab in tabs"
:key="tab.name"
type="button"
class="tabs-block__btn"
:class="{ 'tabs-block__btn--active': activeTab === tab.name }"
@click="onChange(tab.name)"
:key="`btn-${tab.name}`"
class="tabs-block__action tabs-block__action--desktop"
>
<SvgIcon
v-if="tab.icon"
:name="tab.icon"
:size="16"
:color="activeTab === tab.name ? 'var(--color-primary)' : '#bcbcbc'"
/>
<span>{{ tab.label }}</span>
</button>
<BaseButton
v-if="tab.hasButton && activeTab === tab.name"
:text="tab.textButton"
custom-class="tabs-block__action-btn"
@click="onActionClick(tab)"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="16" color="#fff" />
</template>
</BaseButton>
</div>
</div>
<div
v-for="tab in tabs"
:key="`mbtn-${tab.name}`"
class="tabs-block__action tabs-block__action--mobile"
>
<BaseButton
v-if="tab.hasButton && activeTab === tab.name"
:text="tab.textButton"
custom-class="tabs-block__action-btn"
@click="onActionClick(tab)"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="16" color="#fff" />
</template>
</BaseButton>
</div>
<div class="tabs-block__content">
<slot :name="activeTab" />
</div>
@@ -26,7 +60,7 @@
<script setup>
import { ref, watch } from 'vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
const props = defineProps({
@@ -51,39 +85,87 @@ const onChange = (name) => {
emit('update:modelValue', name)
emit('change-tab', name)
}
const onActionClick = (tab) => {
tab.buttonAction?.(tab)
emit('button-click', tab)
}
</script>
<style lang="scss" scoped>
.tabs-block {
&__nav {
display: flex;
gap: 0.5rem;
border-block-end: 1px solid #eee;
gap: 0.625rem;
margin-bottom: 1rem;
overflow: auto hidden;
}
&__btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.625rem 1rem;
&__tabs {
flex: 1;
display: flex;
gap: 0.625rem;
}
&__tab {
flex: 1;
border: none;
background: transparent;
padding: 0;
cursor: pointer;
font-family: var(--font-family-fa);
font-size: 0.875rem;
color: #848484;
border-block-end: 2px solid transparent;
margin-block-end: -1px;
white-space: nowrap;
transition: opacity 0.15s ease;
opacity: 0.6;
&--active {
color: var(--color-primary);
border-color: var(--color-primary);
opacity: 1;
}
}
&__tab-card {
display: flex;
align-items: center;
gap: 0.375rem;
background: rgba(255, 255, 255, 80%);
box-shadow: 0 10px 15px -3px rgba(241, 241, 241, 100%);
padding: 0.875rem;
border-radius: 0.75rem;
}
&__tab-label {
font-family: var(--font-family-fa);
font-weight: 500;
font-size: 0.875rem;
color: #4b4b4b;
}
&__action {
display: flex;
align-items: stretch;
&--desktop {
display: none;
min-width: fit-content;
margin-block-end: 0.625rem;
@media (min-width: 768px) {
display: flex;
}
}
&--mobile {
display: flex;
margin-block-end: 0.625rem;
@media (min-width: 768px) {
display: none;
}
}
}
&__action-btn {
padding: 0 1.25rem;
min-width: fit-content;
}
&__content {
padding-block: 0.5rem;
}
@@ -0,0 +1,95 @@
<template>
<div class="video-player">
<video
v-if="src"
ref="player"
:src="src"
:poster="poster || ''"
controls
playsinline
class="video-player__media"
@timeupdate="onTimeUpdate"
/>
<div v-else class="video-player__placeholder">
<SvgIcon name="paper-plane-right" :size="48" color="rgba(0, 112, 116, 0.31)" />
<p>ویدیویی برای نمایش وجود ندارد</p>
</div>
</div>
</template>
<script setup>
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { onBeforeUnmount, onMounted, ref } from 'vue'
const props = defineProps({
src: { type: String, default: '' },
poster: { type: String, default: '' },
videoId: { type: [String, Number], default: '' },
})
const player = ref(null)
const storageKey = () => (props.videoId ? `video_progress_${props.videoId}` : '')
onMounted(() => {
if (!player.value || !props.videoId) return
const saved = localStorage.getItem(storageKey())
if (saved) {
const t = Number.parseFloat(saved)
if (Number.isFinite(t)) player.value.currentTime = t
}
})
const onTimeUpdate = () => {
if (!player.value || !props.videoId) return
try {
localStorage.setItem(storageKey(), String(player.value.currentTime))
} catch {
/* storage unavailable */
}
}
onBeforeUnmount(() => {
if (player.value) {
player.value.pause()
player.value.src = ''
}
})
</script>
<style lang="scss" scoped>
.video-player {
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 1.5rem;
overflow: hidden;
background: #000;
display: flex;
align-items: center;
justify-content: center;
&__media {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
&__placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
width: 100%;
height: 100%;
background: rgba(0, 112, 116, 4%);
color: #007074;
font-family: var(--font-family-fa);
font-size: 0.875rem;
}
&__placeholder p {
margin: 0;
}
}
</style>
+79
View File
@@ -0,0 +1,79 @@
<template>
<button
type="button"
class="checkbox-field"
:class="{
'checkbox-field--selected': modelValue,
'checkbox-field--disabled': disabled,
}"
:disabled="disabled"
@click="onToggle"
>
<span class="checkbox-field__box" />
<span v-if="label" class="checkbox-field__label">{{ label }}</span>
<slot />
</button>
</template>
<script setup>
const props = defineProps({
modelValue: { type: Boolean, default: false },
label: { type: String, default: '' },
disabled: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue', 'change'])
const onToggle = () => {
if (props.disabled) return
const next = !props.modelValue
emit('update:modelValue', next)
emit('change', next)
}
</script>
<style lang="scss" scoped>
.checkbox-field {
display: inline-flex;
align-items: center;
gap: 0.625rem;
padding: 0.25rem 0.5rem;
background: transparent;
border: none;
cursor: pointer;
font-family: var(--font-family-fa);
transition: opacity 0.15s ease;
&:hover {
opacity: 0.85;
}
&--disabled {
cursor: not-allowed;
opacity: 0.6;
}
&--selected .checkbox-field__box {
background: #999999;
border-color: #999999;
}
&__box {
width: 0.95rem;
height: 0.95rem;
border-radius: 0.3rem;
border: 0.75px solid #c2c2c2;
background: transparent;
flex-shrink: 0;
transition: background 0.15s ease, border-color 0.15s ease;
}
&__label {
font-weight: 400;
font-size: 0.95rem;
line-height: 1.4;
color: #5d5d5d;
white-space: nowrap;
}
}
</style>
+231
View File
@@ -0,0 +1,231 @@
<template>
<div class="image-uploader">
<div class="image-uploader__stage">
<div v-if="previewUrl" class="image-uploader__preview">
<img :src="previewUrl" :alt="fileName || 'تصویر'" />
</div>
<button
v-else
type="button"
class="image-uploader__main-btn"
:disabled="disabled"
@click="triggerFilePicker"
>
<SvgIcon name="upload" :size="48" color="var(--color-thd-gray)" />
</button>
<p v-if="fileName && previewUrl" class="image-uploader__name">{{ fileName }}</p>
</div>
<div v-if="previewUrl" class="image-uploader__actions">
<button
type="button"
class="image-uploader__btn"
:disabled="disabled"
title="جایگزینی تصویر"
@click="triggerFilePicker"
>
<SvgIcon name="upload" :size="20" color="var(--color-thd-gray)" />
</button>
<button type="button" class="image-uploader__btn" title="حذف" @click="clear">
<SvgIcon name="close" :size="20" color="var(--color-thd-gray)" />
</button>
</div>
<input
ref="fileInput"
type="file"
:accept="accept"
class="image-uploader__file-input"
@change="onFileSelected"
/>
<p v-if="error" class="image-uploader__error">{{ error }}</p>
</div>
</template>
<script setup>
import { toast } from 'vue3-toastify'
import { onBeforeUnmount, ref, watch } from 'vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
const props = defineProps({
modelValue: { type: [Object, File, String, null], default: null },
accept: { type: String, default: 'image/*' },
maxSizeKb: { type: Number, default: 5120 },
disabled: { type: Boolean, default: false },
error: { type: String, default: '' },
})
const emit = defineEmits(['update:modelValue'])
const initialUrl =
typeof props.modelValue === 'string' ? props.modelValue : props.modelValue?.url || null
const previewUrl = ref(initialUrl)
const fileName = ref(
props.modelValue instanceof File ? props.modelValue.name : props.modelValue?.name || ''
)
const fileInput = ref(null)
const revokePreview = () => {
if (previewUrl.value && previewUrl.value.startsWith('blob:')) {
URL.revokeObjectURL(previewUrl.value)
}
}
const setFile = (file) => {
revokePreview()
previewUrl.value = URL.createObjectURL(file)
fileName.value = file.name
emit('update:modelValue', file)
}
const triggerFilePicker = () => fileInput.value?.click()
const onFileSelected = (event) => {
const file = event.target?.files?.[0]
if (event.target) event.target.value = ''
if (!file) return
if (file.size > props.maxSizeKb * 1024) {
toast.error(`حجم فایل نباید بیشتر از ${props.maxSizeKb} کیلوبایت باشد.`)
return
}
setFile(file)
}
const clear = () => {
revokePreview()
previewUrl.value = null
fileName.value = ''
emit('update:modelValue', null)
}
watch(
() => props.modelValue,
(val) => {
if (val == null) {
revokePreview()
previewUrl.value = null
fileName.value = ''
}
}
)
onBeforeUnmount(revokePreview)
</script>
<style lang="scss" scoped>
.image-uploader {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: #eeeeee;
border-radius: 1.5rem;
min-height: 18rem;
justify-content: space-between;
&__stage {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.625rem;
width: 100%;
}
&__main-btn {
width: 8rem;
height: 8rem;
border-radius: 9999px;
background: #fff;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover:enabled {
transform: scale(1.02);
box-shadow: 0 4px 14px rgba(0, 0, 0, 8%);
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
&__preview {
width: 8rem;
height: 8rem;
border-radius: 1rem;
overflow: hidden;
background: #fff;
box-shadow: 0 2px 6px rgba(0, 0, 0, 6%);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&__name {
max-width: 14rem;
margin: 0;
text-align: center;
font-family: var(--font-family-fa);
font-size: 0.75rem;
color: #5d5d5d;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__actions {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
&__btn {
width: 2.5rem;
height: 2.5rem;
border-radius: 9999px;
background: #fff;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px rgba(0, 0, 0, 6%);
transition: transform 0.15s ease;
&:hover:enabled {
transform: scale(1.05);
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
&__file-input {
display: none;
}
&__error {
margin: 0;
font-family: var(--font-family-fa);
font-size: 0.75rem;
color: var(--color-error);
}
}
</style>
+422
View File
@@ -0,0 +1,422 @@
<template>
<div class="voice-recorder">
<div class="voice-recorder__stage">
<button
v-if="!isRecording && !audioUrl"
type="button"
class="voice-recorder__main-btn"
:disabled="disabled"
@click="startRecording"
>
<SvgIcon name="microphone" :size="48" color="transparent" />
</button>
<button
v-else-if="isRecording"
type="button"
class="voice-recorder__main-btn voice-recorder__main-btn--recording"
@click="stopRecording"
>
<SvgIcon name="close" :size="40" color="var(--color-error)" />
</button>
<div v-else class="voice-recorder__preview">
<button type="button" class="voice-recorder__main-btn" @click="togglePlay">
<SvgIcon
:name="isPlaying ? 'close' : 'paper-plane-right'"
:size="40"
color="var(--color-thd-gray)"
/>
</button>
<audio
ref="audioEl"
:src="audioUrl"
class="voice-recorder__audio"
@ended="onEnded"
@timeupdate="onTimeUpdate"
/>
</div>
<p v-if="isRecording" class="voice-recorder__status">
<span class="voice-recorder__dot" />
در حال ضبط...
<span class="voice-recorder__timer">{{ formattedTime }}</span>
</p>
</div>
<div class="voice-recorder__actions">
<button
v-if="audioUrl && !isRecording"
type="button"
class="voice-recorder__btn"
:title="'ضبط مجدد'"
@click="resetAndStart"
>
<SvgIcon name="microphone" :size="20" color="transparent" />
</button>
<button
type="button"
class="voice-recorder__btn"
:disabled="isRecording || disabled"
:title="'بارگذاری فایل'"
@click="triggerFilePicker"
v-if="canUpload"
>
<SvgIcon name="upload" :size="20" color="var(--color-thd-gray)" />
</button>
<input
ref="fileInput"
type="file"
accept="audio/*"
class="voice-recorder__file-input"
@change="onFileSelected"
/>
</div>
<div v-if="audioUrl" class="voice-recorder__progress">
<div class="voice-recorder__progress-fill" :style="{ width: `${progress}%` }" />
</div>
<p v-if="error" class="voice-recorder__error">{{ error }}</p>
</div>
<PermissionModal v-if="isModal('VoiceRecorderPermissionModal')" />
</template>
<script setup>
import { toast } from 'vue3-toastify'
import useModal from '@/composables/useModal'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import PermissionModal from '@/features/auth/components/studentRegister/PermissionModal.vue'
const props = defineProps({
modelValue: { type: [Object, File, String, null], default: null },
maxSeconds: { type: Number, default: 600 },
maxSizeKb: { type: Number, default: 25_600 },
disabled: { type: Boolean, default: false },
error: { type: String, default: '' },
canUpload: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue', 'recorded'])
const { openModal, isModal } = useModal()
const audioUrl = ref(
typeof props.modelValue === 'string' ? props.modelValue : props.modelValue?.url || null
)
const audioFile = ref(null)
const isRecording = ref(false)
const isPlaying = ref(false)
const elapsedMs = ref(0)
const progress = ref(0)
const audioEl = ref(null)
const fileInput = ref(null)
let mediaRecorder = null
let mediaStream = null
let chunks = []
let timerHandle = null
let timerStart = 0
const formattedTime = computed(() => {
const s = Math.floor(elapsedMs.value / 1000)
const mm = String(Math.floor(s / 60)).padStart(2, '0')
const ss = String(s % 60).padStart(2, '0')
return `${mm}:${ss}`
})
const stopTracks = () => {
if (mediaStream) {
mediaStream.getTracks().forEach((t) => t.stop())
mediaStream = null
}
}
const clearTimer = () => {
if (timerHandle) {
clearInterval(timerHandle)
timerHandle = null
}
}
const revokePreview = () => {
if (audioUrl.value && audioUrl.value.startsWith('blob:')) {
URL.revokeObjectURL(audioUrl.value)
}
}
const setRecording = (file) => {
revokePreview()
audioFile.value = file
audioUrl.value = URL.createObjectURL(file)
progress.value = 0
isPlaying.value = false
emit('update:modelValue', file)
emit('recorded', file)
}
const startRecording = async () => {
try {
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
chunks = []
mediaRecorder = new window.MediaRecorder(mediaStream)
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',
lastModified: Date.now(),
})
setRecording(file)
}
mediaRecorder.start()
isRecording.value = true
elapsedMs.value = 0
timerStart = Date.now()
timerHandle = setInterval(() => {
elapsedMs.value = Date.now() - timerStart
if (elapsedMs.value >= props.maxSeconds * 1000) stopRecording()
}, 250)
} catch {
openModal('VoiceRecorderPermissionModal', { kind: 'audio' })
}
}
const stopRecording = () => {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop()
}
clearTimer()
}
const resetAndStart = () => {
if (isPlaying.value) {
audioEl.value?.pause()
isPlaying.value = false
}
revokePreview()
audioFile.value = null
audioUrl.value = null
progress.value = 0
emit('update:modelValue', null)
startRecording()
}
const triggerFilePicker = () => fileInput.value?.click()
const onFileSelected = (event) => {
const file = event.target?.files?.[0]
if (event.target) event.target.value = ''
if (!file) return
if (file.size > props.maxSizeKb * 1024) {
toast.error(`حجم فایل صوتی نباید بیشتر از ${props.maxSizeKb} کیلوبایت باشد.`)
return
}
setRecording(file)
}
const togglePlay = () => {
if (!audioEl.value) return
if (audioEl.value.paused) {
audioEl.value.play().then(() => {
isPlaying.value = true
})
} else {
audioEl.value.pause()
isPlaying.value = false
}
}
const onEnded = () => {
isPlaying.value = false
progress.value = 0
}
const onTimeUpdate = () => {
const a = audioEl.value
if (!a?.duration) return
progress.value = (a.currentTime / a.duration) * 100
}
watch(
() => props.modelValue,
(val) => {
if (val == null) {
revokePreview()
audioFile.value = null
audioUrl.value = null
progress.value = 0
}
}
)
onBeforeUnmount(() => {
stopRecording()
stopTracks()
clearTimer()
revokePreview()
})
</script>
<style lang="scss" scoped>
.voice-recorder {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: #f3f4f6;
border-radius: 1.5rem;
min-height: 18rem;
justify-content: space-between;
height: 100%;
&__stage {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.625rem;
width: 100%;
height: 100%;
}
&__main-btn {
width: 8rem;
height: 8rem;
border-radius: 9999px;
background: #fff;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover:enabled {
transform: scale(1.02);
box-shadow: 0 4px 14px rgba(0, 0, 0, 8%);
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
&--recording {
box-shadow: 0 0 0 6px rgba(204, 40, 49, 12%);
animation: pulse 1.4s infinite;
}
}
&__preview {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
&__audio {
display: none;
}
&__status {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-family: var(--font-family-fa);
font-size: 0.875rem;
color: var(--color-error);
margin: 0;
}
&__dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 9999px;
background: var(--color-error);
}
&__timer {
margin-inline-start: 0.5rem;
font-family: var(--font-family-en);
color: #5d5d5d;
}
&__actions {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
&__btn {
width: 2.5rem;
height: 2.5rem;
border-radius: 9999px;
background: #fff;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px rgba(0, 0, 0, 6%);
transition: transform 0.15s ease;
&:hover:enabled {
transform: scale(1.05);
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
&__file-input {
display: none;
}
&__progress {
width: 100%;
height: 0.125rem;
border-radius: 9999px;
background: var(--color-thd-gray);
overflow: hidden;
}
&__progress-fill {
height: 100%;
background: rgba(0, 0, 0, 40%);
border-radius: 9999px;
transition: width 0.2s ease;
}
&__error {
margin: 0;
font-family: var(--font-family-fa);
font-size: 0.75rem;
color: var(--color-error);
}
}
@keyframes pulse {
0%,
100% {
box-shadow: 0 0 0 6px rgba(204, 40, 49, 12%);
}
50% {
box-shadow: 0 0 0 10px rgba(204, 40, 49, 18%);
}
}
</style>
+5 -1
View File
@@ -14,10 +14,13 @@ import { computed } from 'vue'
import { iconRegistry } from '@/components/icons/registry'
/** @typedef {import('@/components/icons/icon-names').IconName} IconName */
const props = defineProps({
/** @type {import('vue').PropType<IconName>} */
name: { type: String, required: true },
size: { type: [String, Number], default: '1.25rem' },
color: { type: String, default: 'currentColor' },
color: { type: String, default: 'transparent' },
})
const component = computed(() => {
@@ -25,6 +28,7 @@ const component = computed(() => {
if (!c && import.meta.env.DEV) {
console.warn(`[SvgIcon] unknown icon "${props.name}"`)
}
return c
})
+48
View File
@@ -0,0 +1,48 @@
// AUTO-GENERATED — do not edit by hand.
// Regenerated whenever src/assets/icons/*.svg changes (via scripts/vite-icon-names.js).
export type IconName =
| 'arrow-left'
| 'arrows-clockwise'
| 'attach-file'
| 'bell'
| 'book'
| 'calendar'
| 'caret-down'
| 'caret-left'
| 'caret-right'
| 'chat'
| 'chat-centered-dots'
| 'check'
| 'check-square'
| 'close'
| 'copy'
| 'eye'
| 'eye-slash'
| 'file'
| 'funnel'
| 'heart'
| 'instagram'
| 'link'
| 'list-bullets'
| 'map-pin-simple-area'
| 'menu'
| 'microphone'
| 'mood'
| 'paper-plane'
| 'paper-plane-right'
| 'pencil'
| 'phone'
| 'plus'
| 'scroll'
| 'spinner'
| 'square'
| 'telegram'
| 'trash'
| 'upload'
| 'user'
| 'users'
| 'users-three'
| 'warning'
export const iconNames: readonly IconName[]
@@ -17,26 +17,6 @@
@reset="onFilterReset"
/>
<div class="courses-page__list-header">
<SimpleTitleIconBlock
:title="activeTab === 'templates' ? 'لیست دوره‌های الگو' : 'لیست دوره‌های ارائه شده'"
class="courses-page__list-title"
>
<template #header-icon>
<SvgIcon name="list-bullets" :size="16" color="#bcbcbc" />
</template>
</SimpleTitleIconBlock>
<BaseButton
:text="activeTab === 'templates' ? 'افزودن دوره الگو' : 'افزودن دوره ارائه شده'"
custom-class="courses-page__add-btn"
@click="onAdd"
>
<template #appendIcon>
<SvgIcon name="plus" :size="18" color="#fff" />
</template>
</BaseButton>
</div>
<TabsBlock :tabs="tabs" v-model="activeTab" @change-tab="onTabChange">
<template #templates>
<SkeletonLoaderBlock v-if="templatesPending" :rows="6" :cols-per-row="1" />
@@ -85,7 +65,6 @@ import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import useModal from '@/composables/useModal'
import { useQueryClient } from '@tanstack/vue-query'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import TabsBlock from '@/components/blocks/TabsBlock.vue'
@@ -94,7 +73,6 @@ import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import CourseItem from '@/features/admin/courses/components/CourseItem.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
import CoursesFilters from '@/features/admin/courses/components/CoursesFilters.vue'
import CourseDetailsModal from '@/features/admin/courses/components/modals/CourseDetailsModal.vue'
import AddOfferedCourseModal from '@/features/admin/courses/components/modals/AddOfferedCourseModal.vue'
@@ -117,9 +95,31 @@ const router = useRouter()
const queryClient = useQueryClient()
const { openModal, isModal } = useModal()
const onAdd = () => {
if (activeTab.value === 'templates') {
router.push({ name: 'admin-add-course-template' }).catch(() => {})
} else {
openModal('AddOfferedCourseModal', { mode: 'add' })
}
}
const tabs = [
{ name: 'templates', label: 'دوره‌های الگو', icon: 'list-bullets' },
{ name: 'offered', label: 'دوره‌های ارائه شده', icon: 'list-bullets' },
{
name: 'templates',
label: 'دوره‌های الگو',
icon: 'list-bullets',
hasButton: true,
textButton: 'افزودن دوره الگوی جدید',
buttonAction: onAdd,
},
{
name: 'offered',
label: 'دوره‌های ارائه شده',
icon: 'list-bullets',
hasButton: true,
textButton: 'افزودن دوره ارائه شده جدید',
buttonAction: onAdd,
},
]
const activeTab = ref('templates')
@@ -188,14 +188,6 @@ const onFilterApply = () => {
}
const onFilterReset = onFilterApply
const onAdd = () => {
if (activeTab.value === 'templates') {
router.push({ name: 'admin-add-course-template' }).catch(() => {})
} else {
openModal('AddOfferedCourseModal', { mode: 'add' })
}
}
const onEditTemplate = (course) => {
router.push({ name: 'admin-edit-course-template', params: { id: course.id } }).catch(() => {})
}
@@ -261,22 +253,5 @@ const onChangeOfferedStatus = ({ id, isActive }) => {
&__heading {
margin-bottom: 0.75rem;
}
&__list-header {
display: flex;
align-items: center;
gap: 0.625rem;
margin-bottom: 0.375rem;
}
&__list-title {
flex: 1;
margin-bottom: 0;
}
&__add-btn {
min-width: fit-content;
padding: 0 1.25rem;
}
}
</style>
@@ -0,0 +1,236 @@
<template>
<div class="cert">
<div class="cert__course">
<div class="cert__thumb">
<img v-if="certificate.thumbnailUrl" :src="certificate.thumbnailUrl" :alt="courseTitle" />
<SvgIcon v-else name="scroll" :size="24" color="#f55d6d" />
</div>
<div class="cert__course-info">
<p class="cert__title">{{ courseTitle }}</p>
<div class="cert__teacher">
<SvgIcon name="user" :size="11" color="#bcbcbc" />
<span class="cert__teacher-label">استاد :</span>
<span class="cert__teacher-name">{{ teacherName }}</span>
</div>
</div>
</div>
<div class="cert__dates">
<div class="cert__date">
<span class="cert__date-label">تاریخ شروع :</span>
<span class="cert__date-value">{{ certificate.startDate || '—' }}</span>
</div>
<span class="cert__date-divider" />
<div class="cert__date">
<span class="cert__date-label">تاریخ پایان :</span>
<span class="cert__date-value">{{ certificate.endDate || '—' }}</span>
</div>
</div>
<div class="cert__actions">
<BaseButton
ref="downloadBtnRef"
text="دانلود گواهینامه"
custom-class="cert__download-btn"
@click="toggleMenu"
>
<template #appendIcon>
<SvgIcon name="caret-down" :size="14" color="#fff" />
</template>
</BaseButton>
</div>
<DropdownMenu
v-model="menuOpen"
:reference="menuTrigger"
:items="downloadItems"
placement="bottom-end"
min-width="14rem"
/>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import DropdownMenu from '@/components/DropdownMenu.vue'
const props = defineProps({
certificate: { type: Object, required: true },
})
const emit = defineEmits(['download'])
const courseTitle = computed(() => props.certificate.courseTitle || '—')
const teacherName = computed(() => props.certificate.teacherName || '—')
const downloadBtnRef = ref(null)
const menuTrigger = ref(null)
const menuOpen = ref(false)
const toggleMenu = (event) => {
menuTrigger.value = event.currentTarget
menuOpen.value = !menuOpen.value
}
const onDownload = (format) => {
emit('download', { certificate: props.certificate, format })
}
const downloadItems = computed(() => [
{
key: 'pdf',
label: 'دانلود به صورت PDF',
icon: 'upload',
onClick: () => onDownload('pdf'),
},
{
key: 'jpg',
label: 'دانلود به صورت JPG',
icon: 'upload',
onClick: () => onDownload('jpg'),
},
])
</script>
<style lang="scss" scoped>
.cert {
position: relative;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.875rem 1rem;
background: rgba(255, 255, 255, 58%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.75rem;
margin-bottom: 0.625rem;
@media (min-width: 1024px) {
flex-direction: row;
align-items: center;
gap: 1.5rem;
padding: 0.875rem 2rem;
}
&__course {
display: flex;
align-items: center;
gap: 0.75rem;
@media (min-width: 1024px) {
flex: 1;
order: 3;
min-width: 12rem;
}
}
&__thumb {
width: 3rem;
height: 3.25rem;
border-radius: 0.625rem;
background: #f4f4f4;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&__course-info {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: end;
gap: 0.25rem;
min-width: 0;
}
&__title {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.95rem;
color: #4b4b4b;
margin: 0;
}
&__teacher {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-family: var(--font-family-fa);
font-size: 0.65rem;
color: #4b4b4b;
}
&__teacher-label {
font-weight: 400;
}
&__teacher-name {
font-weight: 500;
}
&__dates {
display: inline-flex;
align-items: center;
gap: 0.75rem;
padding: 0.3rem 0.875rem;
border-radius: 0.75rem;
background: rgba(107, 107, 107, 4%);
font-family: var(--font-family-fa);
white-space: nowrap;
@media (min-width: 1024px) {
order: 2;
}
}
&__date {
display: inline-flex;
align-items: center;
gap: 0.375rem;
}
&__date-label {
font-weight: 400;
font-size: 0.65rem;
color: #535353;
}
&__date-value {
font-family: var(--font-family-en);
font-weight: 400;
font-size: 0.75rem;
color: #5d5d5d;
}
&__date-divider {
width: 1px;
height: 0.55rem;
background: #dadada;
}
&__actions {
display: flex;
justify-content: center;
@media (min-width: 1024px) {
order: 1;
justify-content: flex-start;
}
}
&__download-btn {
min-width: 9rem;
height: 2rem;
padding: 0 1.25rem;
}
}
</style>
@@ -0,0 +1,94 @@
<template>
<div class="student-certificates">
<BoxedIconTitleBlock
class="student-certificates__heading"
title="گواهینامه های شما"
desc="در این قسمت شما میتوانید گواهینامه هر دوره ای را که گذرانده اید را مشاهده کنید."
>
<template #icon>
<SvgIcon name="scroll" :size="24" color="var(--color-primary)" />
</template>
</BoxedIconTitleBlock>
<SimpleTitleIconBlock title="گواهینامه های شما" class="student-certificates__section-title">
<template #header-icon>
<SvgIcon name="list-bullets" :size="16" color="#bcbcbc" />
</template>
</SimpleTitleIconBlock>
<SkeletonLoaderBlock v-if="isLoading" :rows="3" :cols-per-row="1" />
<div v-else-if="certificates.length > 0">
<CertificateItem
v-for="certificate in certificates"
:key="certificate.id"
:certificate="certificate"
@download="onDownload"
/>
</div>
<NoItems v-else title="موردی نیست" desc="هنوز گواهینامه‌ای ندارید." />
<PaginationBlock :pagination="meta" @update:page="setPage" />
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { toast } from 'vue3-toastify'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import { usePagination } from '@/composables/usePagination'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
import CertificateItem from '@/features/student/certificates/components/CertificateItem.vue'
import {
useDownloadStudentCertificateMutation,
useStudentCertificatesQuery,
} from '@/services/query/student-certificates'
const filters = ref({})
const { pagination, setPage } = usePagination({ page: 1, perPage: 10 })
const { data, isLoading } = useStudentCertificatesQuery(filters, pagination, {
keepPreviousData: true,
})
const certificates = computed(() => data.value?.data ?? [])
const meta = computed(() => ({
page: pagination.value.page,
perPage: pagination.value.perPage,
...data.value?.meta,
}))
const downloadMutation = useDownloadStudentCertificateMutation()
const onDownload = async ({ certificate, format }) => {
try {
const response = await downloadMutation.mutateAsync({ id: certificate.id, format })
const url = response?.data?.url
if (url && url !== '#') {
window.open(url, '_blank')
} else {
toast.info('فایل گواهینامه در دسترس نیست.')
}
} catch {
toast.error('دانلود گواهینامه با خطا مواجه شد.')
}
}
</script>
<style lang="scss" scoped>
.student-certificates {
display: flex;
flex-direction: column;
gap: 0.5rem;
&__heading {
margin-bottom: 0.75rem;
}
&__section-title {
margin-block: 0.25rem;
}
}
</style>
@@ -0,0 +1,107 @@
<template>
<div class="cns" @click="emit('show-details', consultant)">
<div class="cns__heading">
<p class="cns__title">{{ consultant.title }}</p>
<p class="cns__subtitle">شماره درخواست : {{ consultant.requestNumber }}</p>
</div>
<div class="cns__meta">
<Badge
variant="neutral"
size="sm"
icon="calendar"
:label="`زمان :`"
:value="`${consultant.timeLabel} | ${consultant.dateLabel}`"
/>
<Badge :variant="tone" size="sm" dot :value="consultant.statusLabel" />
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import Badge from '@/global-components/Badge.vue'
const TONE_MAP = {
approved: 'success',
rejected: 'danger',
pending: 'neutral',
closed: 'info',
}
const props = defineProps({
consultant: { type: Object, required: true },
})
const emit = defineEmits(['show-details'])
const tone = computed(() => TONE_MAP[props.consultant.status] || 'neutral')
</script>
<style lang="scss" scoped>
.cns {
position: relative;
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem 1.25rem;
background: rgba(255, 255, 255, 58%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.75rem;
margin-bottom: 0.625rem;
cursor: pointer;
transition: background 0.15s ease;
&:hover {
background: rgba(255, 255, 255, 75%);
}
@media (min-width: 1024px) {
flex-direction: row;
align-items: center;
gap: 1.5rem;
padding: 0.875rem 2rem;
}
&__heading {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: end;
gap: 0.125rem;
@media (min-width: 1024px) {
min-width: 7rem;
}
}
&__title {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.95rem;
color: #4b4b4b;
margin: 0;
}
&__subtitle {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.7rem;
color: #4b4b4b;
margin: 0;
}
&__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
@media (min-width: 1024px) {
flex: 1;
justify-content: center;
}
}
}
</style>
@@ -0,0 +1,141 @@
<template>
<div class="rcf">
<form class="rcf__form" @submit.prevent="onSubmit">
<div class="rcf__row">
<div class="rcf__cell">
<SelectField
v-model="form.title"
name="title"
label="عنوان مشاوره درخواستی"
placeholder="انتخاب کنید"
:options="titleOptions"
option-label="label"
option-value="value"
:error="errors.title"
/>
</div>
</div>
<div class="rcf__row">
<div class="rcf__cell rcf__cell--full">
<TextareaField
v-model="form.description"
name="description"
label="توضیحات خود را وارد نمایید"
placeholder="لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است."
:row="5"
:error="errors.description"
/>
</div>
</div>
<div class="rcf__divider" />
<div class="rcf__actions">
<BaseButton
type="submit"
text="درخواست مشاوره"
custom-class="rcf__submit"
:loading="submitting"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="16" color="#fff" />
</template>
</BaseButton>
</div>
</form>
</div>
</template>
<script setup>
import * as yup from 'yup'
import { computed, ref } from 'vue'
import useYup from '@/composables/useYup'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import SelectField from '@/components/form/SelectField.vue'
import TextareaField from '@/components/form/TextareaField.vue'
import { useStudentConsultantTitlesQuery } from '@/services/query/student-consultants'
defineProps({
submitting: { type: Boolean, default: false },
})
const emit = defineEmits(['submit'])
const form = ref({
title: '',
description: '',
})
const schema = yup.object({
title: yup.string().required('عنوان مشاوره را انتخاب کنید'),
description: yup
.string()
.trim()
.min(5, 'توضیحات حداقل ۵ کاراکتر باشد')
.required('توضیحات را وارد کنید'),
})
const { validate, errors } = useYup(schema)
const { data: titles } = useStudentConsultantTitlesQuery()
const titleOptions = computed(() => titles.value ?? [])
const onSubmit = async () => {
const { isValid, payload } = await validate(form.value)
if (!isValid) return
emit('submit', { ...payload })
}
</script>
<style lang="scss" scoped>
.rcf {
display: flex;
flex-direction: column;
gap: 1rem;
border-radius: 0.75rem;
&__form {
display: flex;
flex-direction: column;
gap: 1rem;
}
&__row {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
&__cell {
display: flex;
flex-direction: column;
&--full {
grid-column: 1 / -1;
}
}
&__divider {
height: 0.75px;
background: #ddd;
margin-top: 0.25rem;
}
&__actions {
display: flex;
justify-content: center;
@media (min-width: 768px) {
justify-content: flex-end;
}
}
&__submit {
min-width: 14rem;
height: 2.5rem;
padding: 0 2.5rem;
}
}
</style>
@@ -0,0 +1,110 @@
<template>
<div class="student-consultants">
<BoxedIconTitleBlock
class="student-consultants__heading"
title="مشاوره"
desc="درخواست مشاوره جدید ثبت کنید یا تاریخچه مشاوره‌های خود را مشاهده نمایید."
>
<template #icon>
<SvgIcon name="chat" :size="24" color="var(--color-primary)" />
</template>
</BoxedIconTitleBlock>
<TabsBlock v-model="activeTab" :tabs="tabs">
<template #create>
<RequestConsultantForm :submitting="createMutation.isPending.value" @submit="onSubmit" />
</template>
<template #history>
<SkeletonLoaderBlock v-if="historyLoading" :rows="3" :cols-per-row="1" />
<div v-else-if="consultants.length > 0">
<ConsultantItem
v-for="consultant in consultants"
:key="consultant.id"
:consultant="consultant"
@show-details="onShowDetails"
/>
</div>
<NoItems v-else title="موردی نیست" desc="هنوز درخواست مشاوره‌ای ثبت نکرده‌اید." />
<PaginationBlock :pagination="historyMeta" @update:page="setHistoryPage" />
</template>
</TabsBlock>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { toast } from 'vue3-toastify'
import { useQueryClient } from '@tanstack/vue-query'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import TabsBlock from '@/components/blocks/TabsBlock.vue'
import { usePagination } from '@/composables/usePagination'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import ConsultantItem from '@/features/student/consultants/components/ConsultantItem.vue'
import RequestConsultantForm from '@/features/student/consultants/components/RequestConsultantForm.vue'
import {
studentConsultantsKeys,
useCreateStudentConsultantMutation,
useStudentConsultantsQuery,
} from '@/services/query/student-consultants'
const queryClient = useQueryClient()
const tabs = [
{ name: 'create', label: 'درخواست مشاوره', icon: 'chat' },
{ name: 'history', label: 'تاریخچه مشاوره ها', icon: 'list-bullets' },
]
const activeTab = ref('create')
const historyFilters = ref({})
const { pagination: historyPagination, setPage: setHistoryPage } = usePagination({
page: 1,
perPage: 10,
})
const { data: historyData, isLoading: historyLoading } = useStudentConsultantsQuery(
historyFilters,
historyPagination,
{
enabled: () => activeTab.value === 'history',
keepPreviousData: true,
}
)
const consultants = computed(() => historyData.value?.data ?? [])
const historyMeta = computed(() => ({
page: historyPagination.value.page,
perPage: historyPagination.value.perPage,
...historyData.value?.meta,
}))
const createMutation = useCreateStudentConsultantMutation()
const onSubmit = async (payload) => {
try {
await createMutation.mutateAsync(payload)
toast.success('درخواست مشاوره با موفقیت ثبت شد.')
await queryClient.invalidateQueries({ queryKey: studentConsultantsKeys.all })
activeTab.value = 'history'
} catch {
toast.error('ثبت درخواست با خطا مواجه شد.')
}
}
const onShowDetails = () => {}
</script>
<style lang="scss" scoped>
.student-consultants {
display: flex;
flex-direction: column;
gap: 0.5rem;
&__heading {
margin-bottom: 0.75rem;
}
}
</style>
@@ -53,7 +53,7 @@ import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import { useStudentCoursesListQuery } from '@/services/query/student-courses'
import StudentCourseItem from '@/features/student/components/StudentCourseItem.vue'
import StudentCourseItem from '@/features/student/courses/components/StudentCourseItem.vue'
const router = useRouter()
@@ -0,0 +1,147 @@
<template>
<div class="student-exam-attempt">
<p class="student-exam-attempt__title">{{ attempt.title || 'آزمون' }}</p>
<div class="student-exam-attempt__meta">
<div class="student-exam-attempt__stat">
<SvgIcon name="scroll" :size="11" color="#5d5d5d" />
<span class="student-exam-attempt__stat-label">نمره :</span>
<span class="student-exam-attempt__stat-value">{{ scoreText }}</span>
</div>
<div class="student-exam-attempt__stat">
<SvgIcon name="users-three" :size="11" color="#5d5d5d" />
<span class="student-exam-attempt__stat-label">زمان آزمون :</span>
<span class="student-exam-attempt__stat-value">{{ dateText }}</span>
</div>
<div class="student-exam-attempt__status" :class="`student-exam-attempt__status--${tone}`">
<SvgIcon name="pencil" :size="11" :color="toneColor" />
<span class="student-exam-attempt__status-label">وضعیت :</span>
<span class="student-exam-attempt__status-value">{{ statusLabel }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { formatJalaaliDate } from '@/utils/date-utils'
const TONE_MAP = { passed: 'success', failed: 'danger' }
const TONE_COLOR = { success: '#007074', danger: '#740002' }
const props = defineProps({
attempt: { type: Object, required: true },
})
const scoreText = computed(() => (props.attempt.score == null ? '—' : `${props.attempt.score}`))
const dateText = computed(() => {
if (props.attempt.faTime && props.attempt.faDate)
return `${props.attempt.faTime} ${props.attempt.faDate}`
if (props.attempt.faDate) return props.attempt.faDate
return formatJalaaliDate(props.attempt.date) || '—'
})
const tone = computed(() => TONE_MAP[props.attempt.status] || 'success')
const toneColor = computed(() => TONE_COLOR[tone.value] || '#5d5d5d')
const statusLabel = computed(
() => props.attempt.statusLabel || (tone.value === 'success' ? 'قبول شده' : 'قبول نشده')
)
</script>
<style lang="scss" scoped>
.student-exam-attempt {
display: flex;
flex-direction: column-reverse;
gap: 0.625rem;
padding: 0.625rem 1.25rem;
background: rgba(255, 255, 255, 58%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.75rem;
margin-bottom: 0.625rem;
@media (min-width: 1024px) {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
&__title {
font-family: var(--font-family-fa);
font-weight: 500;
font-size: 0.95rem;
color: #4b4b4b;
margin: 0;
text-align: end;
flex: 0 0 auto;
order: 4;
@media (min-width: 1024px) {
order: 0;
}
}
&__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
flex: 1;
justify-content: center;
}
&__stat {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.875rem;
border-radius: 0.75rem;
background: rgba(107, 107, 107, 4%);
font-family: var(--font-family-fa);
white-space: nowrap;
}
&__stat-label {
font-weight: 400;
font-size: 0.65rem;
color: #5d5d5d;
}
&__stat-value {
font-family: var(--font-family-en);
font-weight: 500;
font-size: 0.75rem;
color: #5d5d5d;
}
&__status {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.875rem;
border-radius: 0.75rem;
font-family: var(--font-family-fa);
white-space: nowrap;
&--success {
background: rgba(0, 112, 116, 4%);
color: #007074;
}
&--danger {
background: rgba(116, 0, 2, 4%);
color: #740002;
}
}
&__status-label {
font-weight: 400;
font-size: 0.65rem;
}
&__status-value {
font-weight: 500;
font-size: 0.75rem;
}
}
</style>
@@ -0,0 +1,143 @@
<template>
<div class="student-exam-item">
<p class="student-exam-item__title">{{ exam.title || '—' }}</p>
<div class="student-exam-item__stats">
<div class="student-exam-item__stat student-exam-item__stat--duration">
<span class="student-exam-item__stat-value">{{ durationText }}</span>
<span class="student-exam-item__stat-label">مدت آزمون :</span>
<SvgIcon name="calendar" :size="11" color="#5d5d5d" />
</div>
<div class="student-exam-item__stat">
<span class="student-exam-item__stat-label">نمره قبولی :</span>
<span class="student-exam-item__stat-value">{{ passingScoreText }}</span>
<SvgIcon name="users-three" :size="11" color="#5d5d5d" />
</div>
<div class="student-exam-item__stat">
<span class="student-exam-item__stat-label">تعداد سوال :</span>
<span class="student-exam-item__stat-value">{{ questionsCountText }}</span>
<SvgIcon name="file" :size="11" color="#5d5d5d" />
</div>
</div>
<div class="student-exam-item__actions">
<BaseButton
text="شروع آزمون"
custom-class="student-exam-item__btn"
@click="emit('start', exam)"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="14" color="#fff" />
</template>
</BaseButton>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
const props = defineProps({
exam: { type: Object, required: true },
})
const emit = defineEmits(['start'])
const durationText = computed(() =>
props.exam.durationMinutes == null ? '—' : `${props.exam.durationMinutes} دقیقه`
)
const passingScoreText = computed(() =>
props.exam.passingScore == null ? '—' : `${props.exam.passingScore}`
)
const questionsCountText = computed(() =>
props.exam.questionsCount == null ? '—' : `${props.exam.questionsCount}`
)
</script>
<style lang="scss" scoped>
.student-exam-item {
display: flex;
flex-direction: column-reverse;
gap: 0.625rem;
padding: 0.625rem 1rem;
background: rgba(255, 255, 255, 58%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.75rem;
margin-bottom: 0.625rem;
@media (min-width: 1024px) {
flex-direction: row;
align-items: center;
gap: 1rem;
}
&__title {
flex: 1 1 18%;
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.95rem;
color: #4b4b4b;
margin: 0;
text-align: end;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
order: 3;
@media (min-width: 1024px) {
order: 0;
}
}
&__stats {
flex: 1 1 50%;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
&__stat {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.875rem;
border-radius: 0.75rem;
background: rgba(107, 107, 107, 4%);
font-family: var(--font-family-fa);
white-space: nowrap;
}
&__stat--duration {
border-radius: 1.375rem;
}
&__stat-label {
font-weight: 300;
font-size: 0.7rem;
color: #535353;
}
&__stat-value {
font-weight: 500;
font-size: 0.75rem;
color: #5d5d5d;
}
&__actions {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: flex-end;
}
&__btn {
min-width: 6rem;
padding: 0 1rem;
height: 2rem;
}
}
</style>
@@ -0,0 +1,66 @@
<template>
<div class="seq seq--full">
<p class="seq__title">{{ orderText }}{{ question.title }}</p>
<div class="seq__answers">
<CheckboxField
v-for="answer in question.answers"
:key="answer.id"
:model-value="selectedId === answer.id"
:label="answer.title"
@change="onSelect(answer.id)"
/>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import CheckboxField from '@/components/form/CheckboxField.vue'
const props = defineProps({
question: { type: Object, required: true },
order: { type: Number, default: 0 },
modelValue: { type: [String, Number, null], default: null },
})
const emit = defineEmits(['update:modelValue'])
const selectedId = computed(() => props.modelValue)
const orderText = computed(() => (props.order ? `${props.order} - ` : ''))
const onSelect = (id) => {
emit('update:modelValue', id === selectedId.value ? null : id)
}
</script>
<style lang="scss" scoped>
.seq {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.875rem;
padding: 1rem 0;
&--full {
grid-column: 1 / -1;
}
&__title {
font-family: var(--font-family-fa);
font-weight: 300;
font-size: 0.875rem;
line-height: 1.55;
color: #a7a7a7;
margin: 0;
}
&__answers {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.75rem 1.5rem;
}
}
</style>
@@ -0,0 +1,216 @@
<template>
<div class="ses">
<div class="ses__info">
<p class="ses__title">{{ exam.title || '—' }}</p>
<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>
<div class="ses__stat">
<SvgIcon name="users-three" :size="12" color="#5d5d5d" />
<span class="ses__stat-label">نمره قبولی :</span>
<span class="ses__stat-value">{{ passingScoreText }}</span>
</div>
<div class="ses__stat ses__stat--pill">
<SvgIcon name="calendar" :size="11" color="#535353" />
<span class="ses__stat-label">مدت آزمون :</span>
<span class="ses__stat-value">{{ durationText }}</span>
</div>
<div class="ses__status" :class="`ses__status--${statusTone}`">
<span class="ses__status-label">وضعیت :</span>
<span class="ses__status-value">{{ statusLabel }}</span>
</div>
</div>
</div>
<div class="ses__action">
<BaseButton
:text="startButtonText"
:loading="starting"
custom-class="ses__start-btn"
@click="emit('start')"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
</template>
</BaseButton>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
const TONE_MAP = {
passed: 'success',
failed: 'danger',
in_progress: 'success',
not_started: 'neutral',
}
const props = defineProps({
exam: { type: Object, required: true },
starting: { type: Boolean, default: false },
})
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}`
)
const passingScoreText = computed(() =>
props.exam.passingScore == null ? '—' : `${props.exam.passingScore}`
)
const durationText = computed(() =>
props.exam.durationMinutes == null ? '—' : `${props.exam.durationMinutes} دقیقه`
)
const statusLabel = computed(() => props.exam.statusLabel || '—')
const statusTone = computed(() => TONE_MAP[props.exam.status] || 'neutral')
const startButtonText = computed(() =>
(props.exam.attempts || []).length > 0 ? 'شروع آزمون مجدد' : 'شروع آزمون'
)
</script>
<style lang="scss" scoped>
.ses {
position: relative;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 1rem;
padding: 1.25rem;
background: rgba(255, 255, 255, 40%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.75rem;
margin-bottom: 0.75rem;
@media (min-width: 1024px) {
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 1.25rem 2rem;
min-height: 9.9rem;
}
&__info {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1rem;
text-align: end;
}
&__title {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 1.5rem;
color: #4b4b4b;
margin: 0;
line-height: 1.246;
}
&__stats {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
}
&__stat {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.875rem;
border-radius: 0.75rem;
background: rgba(107, 107, 107, 4%);
font-family: var(--font-family-fa);
white-space: nowrap;
&--pill {
border-radius: 1.375rem;
}
}
&__stat-label {
font-weight: 400;
font-size: 0.65rem;
color: #535353;
}
&__stat-value {
font-family: var(--font-family-en);
font-weight: 500;
font-size: 0.75rem;
color: #5d5d5d;
}
&__status {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.875rem;
border-radius: 0.75rem;
font-family: var(--font-family-fa);
white-space: nowrap;
&--success {
background: rgba(0, 112, 116, 4%);
color: #007074;
}
&--danger {
background: rgba(116, 0, 2, 4%);
color: #740002;
}
&--neutral {
background: rgba(107, 107, 107, 4%);
color: #535353;
}
}
&__status-label {
font-weight: 400;
font-size: 0.65rem;
}
&__status-value {
font-weight: 500;
font-size: 0.75rem;
}
&__action {
display: flex;
justify-content: center;
@media (min-width: 1024px) {
justify-content: flex-start;
}
}
&__start-btn {
min-width: 12rem;
padding: 0 2rem;
height: 2rem;
}
}
</style>
@@ -0,0 +1,117 @@
<template>
<div class="se">
<BoxedIconTitleBlock
class="se__heading"
:title="exam?.title || 'آزمون'"
:desc="exam?.desc || 'جزئیات آزمون را مشاهده و سپس شروع آزمون را بزنید'"
>
<template #icon>
<SvgIcon name="pencil" :size="24" color="var(--color-primary)" />
</template>
</BoxedIconTitleBlock>
<SkeletonLoaderBlock v-if="isLoading && !exam" :rows="2" :cols-per-row="1" />
<StudentExamSummary
v-else-if="exam"
:exam="exam"
:starting="startMutation.isPending.value"
@start="onStart"
/>
<SimpleTitleIconBlock title="تعداد تلاش ها برای این آزمون" class="se__list-title">
<template #header-icon>
<SvgIcon name="list-bullets" :size="16" color="#bcbcbc" />
</template>
</SimpleTitleIconBlock>
<SkeletonLoaderBlock v-if="isLoading && attempts.length === 0" :rows="2" :cols-per-row="1" />
<div v-else-if="attempts.length > 0">
<StudentExamAttemptItem v-for="attempt in attempts" :key="attempt.id" :attempt="attempt" />
</div>
<div v-else class="se__empty">
<SvgIcon name="scroll" :size="128" color="#c3c3c3" />
<p class="se__empty-title">دانشجوی گرامی</p>
<p class="se__empty-desc">شما تاکنون هیچ آزمونی را به شروع نکرده اید.</p>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { toast } from 'vue3-toastify'
import { useRoute, useRouter } from 'vue-router'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
import StudentExamSummary from '@/features/student/exams/components/StudentExamSummary.vue'
import StudentExamAttemptItem from '@/features/student/exams/components/StudentExamAttemptItem.vue'
import {
useStartStudentExamAttemptMutation,
useStudentExamQuery,
} from '@/services/query/student-exams'
const route = useRoute()
const router = useRouter()
const examId = computed(() => route.params.id)
const { data: exam, isLoading } = useStudentExamQuery(examId, {
enabled: () => !!examId.value,
})
const attempts = computed(() => exam.value?.attempts ?? [])
const startMutation = useStartStudentExamAttemptMutation()
const onStart = async () => {
try {
await startMutation.mutateAsync({ id: examId.value })
router.push({ name: 'student-take-exam', params: { id: examId.value } })
} catch {
toast.error('شروع آزمون با خطا مواجه شد.')
}
}
</script>
<style lang="scss" scoped>
.se {
display: flex;
flex-direction: column;
gap: 0.75rem;
&__heading {
margin-bottom: 0.75rem;
}
&__list-title {
margin-block: 0.25rem;
}
&__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 2.5rem 1rem;
border-radius: 0.75rem;
}
&__empty-title {
font-family: var(--font-family-fa);
font-weight: 500;
font-size: 1rem;
color: #8e8e8e;
margin: 0.5rem 0 0;
}
&__empty-desc {
font-family: var(--font-family-fa);
font-weight: 400;
color: #4b4b4b;
font-size: 0.875rem;
margin: 0;
}
}
</style>
@@ -0,0 +1,258 @@
<template>
<div class="ste">
<div class="ste__header">
<BoxedIconTitleBlock
class="exam-form__heading"
:title="exam?.title"
desc="لطفا سوالات به دقت خوانده و جواب درست را علامت بزنید"
>
<template #icon>
<SvgIcon name="pencil" :size="24" color="var(--color-primary)" />
</template>
</BoxedIconTitleBlock>
<div class="ste__timer">
<span class="ste__timer-label">زمان باقی مانده :</span>
<span class="ste__timer-value">{{ timerText }}</span>
</div>
</div>
<SkeletonLoaderBlock v-if="isLoading && !exam" :rows="3" :cols-per-row="2" />
<div v-else class="ste__body">
<div class="ste__questions">
<StudentExamQuestion
v-for="(question, index) in questions"
:key="question.id"
:question="question"
:order="index + 1"
:model-value="answers[question.id] ?? null"
@update:model-value="(value) => onAnswer(question.id, value)"
/>
</div>
<div class="ste__actions">
<BaseButton
text="اتمام آزمون"
:loading="submitMutation.isPending.value"
custom-class="ste__submit"
@click="onSubmit"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
</template>
</BaseButton>
</div>
</div>
</div>
</template>
<script setup>
import { toast } from 'vue3-toastify'
import { useRoute, useRouter } from 'vue-router'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import StudentExamQuestion from '@/features/student/exams/components/StudentExamQuestion.vue'
import {
useStudentExamQuery,
useSubmitStudentExamAttemptMutation,
} from '@/services/query/student-exams'
const route = useRoute()
const router = useRouter()
const examId = computed(() => route.params.id)
const { data: exam, isLoading } = useStudentExamQuery(examId, {
enabled: () => !!examId.value,
})
const questions = computed(() => exam.value?.questions ?? [])
const answers = reactive({})
const onAnswer = (questionId, value) => {
if (value === null || value === undefined) delete answers[questionId]
else answers[questionId] = value
}
const remaining = ref(0)
let intervalId = null
const startTimer = (minutes) => {
if (intervalId) clearInterval(intervalId)
remaining.value = Math.max(0, Math.floor(minutes * 60))
intervalId = setInterval(() => {
if (remaining.value <= 0) {
clearInterval(intervalId)
intervalId = null
onTimeout()
return
}
remaining.value -= 1
}, 1000)
}
watch(
() => exam.value?.durationMinutes,
(minutes) => {
if (minutes != null) startTimer(minutes)
},
{ immediate: true }
)
onBeforeUnmount(() => {
if (intervalId) clearInterval(intervalId)
})
const pad = (n) => String(n).padStart(2, '0')
const timerText = computed(() => {
const total = remaining.value
const h = Math.floor(total / 3600)
const m = Math.floor((total % 3600) / 60)
const s = total % 60
return h > 0 ? `${pad(h)}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`
})
const submitMutation = useSubmitStudentExamAttemptMutation()
const submit = async () => {
try {
await submitMutation.mutateAsync({
id: examId.value,
payload: { answers: { ...answers } },
})
toast.success('پاسخ‌های شما با موفقیت ثبت شد.')
router.push({ name: 'student-exam', params: { id: examId.value } })
} catch {
toast.error('ثبت پاسخ‌ها با خطا مواجه شد.')
}
}
const onSubmit = () => {
if (Object.keys(answers).length === 0) {
toast.warning('حداقل به یک سوال پاسخ دهید.')
return
}
submit()
}
const onTimeout = () => {
toast.info('زمان آزمون به پایان رسید.')
submit()
}
onMounted(() => {
if (!examId.value) router.push({ name: 'student-dashboard' })
})
</script>
<style lang="scss" scoped>
.ste {
display: flex;
flex-direction: column;
gap: 1rem;
&__header {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.25rem 1.5rem;
border-radius: 0.75rem;
@media (min-width: 768px) {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
}
&__heading {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.4rem;
text-align: end;
}
&__title {
font-family: var(--font-family-fa);
font-weight: 500;
font-size: 1.125rem;
color: #4b4b4b;
margin: 0;
}
&__subtitle {
font-family: var(--font-family-fa);
font-weight: 300;
font-size: 0.8125rem;
color: #8c8c8c;
margin: 0;
}
&__timer {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.45rem 0.875rem;
border-radius: 0.75rem;
background: rgba(245, 93, 109, 8%);
align-self: flex-end;
font-family: var(--font-family-fa);
white-space: nowrap;
@media (min-width: 768px) {
align-self: center;
}
}
&__timer-label {
font-weight: 400;
font-size: 0.75rem;
color: #f55d6d;
}
&__timer-value {
font-family: var(--font-family-en);
font-weight: 500;
font-size: 0.875rem;
color: #f55d6d;
direction: ltr;
}
&__body {
display: flex;
flex-direction: column;
gap: 1.25rem;
padding: 1.25rem 1.5rem;
border-radius: 0.75rem;
}
&__questions {
display: grid;
grid-template-columns: 1fr;
gap: 0.5rem 2rem;
@media (min-width: 768px) {
grid-template-columns: 1fr 1fr;
}
}
&__actions {
display: flex;
justify-content: flex-end;
padding-top: 1rem;
border-top: 1px solid var(--color-thd-gray);
}
&__submit {
min-width: 14rem;
padding: 0 2rem;
height: 2.25rem;
}
}
</style>
@@ -0,0 +1,190 @@
<template>
<div class="student-lesson-item">
<div class="student-lesson-item__head">
<span class="student-lesson-item__decor" aria-hidden="true">
<SvgIcon name="users-three" :size="20" color="rgba(0, 112, 116, 0.31)" />
</span>
<p class="student-lesson-item__title">{{ lesson.title || '—' }}</p>
</div>
<div class="student-lesson-item__stats">
<div class="student-lesson-item__stat">
<SvgIcon name="users-three" :size="12" color="#5d5d5d" />
<span class="student-lesson-item__stat-label">تعداد دانشجویان :</span>
<span class="student-lesson-item__stat-value">{{ studentsText }}</span>
</div>
<div class="student-lesson-item__stat">
<SvgIcon name="file" :size="12" color="#5d5d5d" />
<span class="student-lesson-item__stat-label">تعداد آزمون :</span>
<span class="student-lesson-item__stat-value">{{ quizzesText }}</span>
</div>
<div class="student-lesson-item__stat">
<SvgIcon name="list-bullets" :size="12" color="#5d5d5d" />
<span class="student-lesson-item__stat-label">تعداد جلسات :</span>
<span class="student-lesson-item__stat-value">{{ sessionsText }}</span>
</div>
</div>
<div class="student-lesson-item__hours">
<SvgIcon name="calendar" :size="11" color="#007074" />
<span class="student-lesson-item__hours-label">زمان یادگیری :</span>
<span class="student-lesson-item__hours-value">{{ hoursText }}</span>
</div>
<div class="student-lesson-item__actions">
<BaseButton
text="جزئیات درس"
custom-class="student-lesson-item__details-btn"
@click="emit('show-details', lesson)"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="14" color="#fff" />
</template>
</BaseButton>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
const props = defineProps({
lesson: { type: Object, required: true },
})
const emit = defineEmits(['show-details'])
const hoursText = computed(() =>
props.lesson.learningHours == null ? '—' : `${props.lesson.learningHours} ساعت`
)
const sessionsText = computed(() =>
props.lesson.sessionsCount == null ? '—' : `${props.lesson.sessionsCount} جلسه`
)
const quizzesText = computed(() =>
props.lesson.quizzesCount == null ? '—' : `${props.lesson.quizzesCount}`
)
const studentsText = computed(() =>
props.lesson.studentsCount == null ? '—' : `${props.lesson.studentsCount} دانشجو`
)
</script>
<style lang="scss" scoped>
.student-lesson-item {
display: flex;
flex-direction: column-reverse;
gap: 0.625rem;
padding: 0.875rem 1rem;
background: rgba(255, 255, 255, 58%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.75rem;
margin-bottom: 0.625rem;
@media (min-width: 1024px) {
flex-direction: row;
align-items: center;
gap: 1rem;
}
&__head {
display: flex;
align-items: center;
gap: 0.625rem;
flex: 1 1 22%;
min-width: 0;
justify-content: flex-end;
}
&__decor {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.75rem;
height: 2.75rem;
border-radius: 0.75rem;
background: rgba(0, 112, 116, 4%);
flex-shrink: 0;
}
&__title {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.95rem;
color: #4b4b4b;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
&__stats {
flex: 1 1 38%;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
&__stat {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.875rem;
border-radius: 0.75rem;
background: rgba(107, 107, 107, 4%);
font-family: var(--font-family-fa);
white-space: nowrap;
}
&__stat-label {
font-weight: 300;
font-size: 0.7rem;
color: #5d5d5d;
}
&__stat-value {
font-weight: 500;
font-size: 0.75rem;
color: #5d5d5d;
}
&__hours {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.875rem;
border-radius: 0.75rem;
background: rgba(0, 112, 116, 4%);
font-family: var(--font-family-fa);
white-space: nowrap;
}
&__hours-label {
font-weight: 300;
font-size: 0.7rem;
color: #007074;
}
&__hours-value {
font-weight: 500;
font-size: 0.75rem;
color: #007074;
}
&__actions {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: flex-end;
}
&__details-btn {
min-width: 6rem;
padding: 0 1rem;
height: 2rem;
}
}
</style>
@@ -0,0 +1,226 @@
<template>
<div class="student-lesson-details">
<BoxedIconTitleBlock
class="student-lesson-details__heading"
title="جزئیات دوره‌ آموزشی"
desc="اطلاعات کامل این دوره را مشاهده کنید."
>
<template #icon>
<SvgIcon name="book" :size="24" color="var(--color-primary)" />
</template>
</BoxedIconTitleBlock>
<SkeletonLoaderBlock v-if="isLoading && !lesson" :rows="2" :cols-per-row="1" />
<div v-else-if="lesson" class="student-lesson-details__summary">
<span class="student-lesson-details__decor" aria-hidden="true">
<SvgIcon name="users-three" :size="40" color="rgba(0, 112, 116, 0.31)" />
</span>
<div class="student-lesson-details__summary-content">
<p class="student-lesson-details__summary-title">{{ lesson.title || '—' }}</p>
<div class="student-lesson-details__summary-stats">
<div class="student-lesson-details__summary-stat">
<SvgIcon name="users-three" :size="12" color="#5d5d5d" />
<span class="student-lesson-details__summary-stat-label">تعداد دانشجویان :</span>
<span class="student-lesson-details__summary-stat-value">
{{ studentsText }}
</span>
</div>
<div class="student-lesson-details__summary-stat">
<SvgIcon name="file" :size="12" color="#5d5d5d" />
<span class="student-lesson-details__summary-stat-label">تعداد آزمون :</span>
<span class="student-lesson-details__summary-stat-value">
{{ quizzesText }}
</span>
</div>
<div class="student-lesson-details__summary-stat">
<SvgIcon name="list-bullets" :size="12" color="#5d5d5d" />
<span class="student-lesson-details__summary-stat-label">تعداد جلسات :</span>
<span class="student-lesson-details__summary-stat-value">
{{ sessionsText }}
</span>
</div>
<div class="student-lesson-details__summary-stat">
<SvgIcon name="calendar" :size="12" color="#535353" />
<span class="student-lesson-details__summary-stat-label">تاریخ پایان :</span>
<span class="student-lesson-details__summary-stat-value">{{ endDateText }}</span>
</div>
</div>
</div>
</div>
<TabsBlock :tabs="tabs" v-model="activeTab">
<template #sessions>
<SkeletonLoaderBlock
v-if="isLoading && sessions.length === 0"
:rows="3"
:cols-per-row="1"
/>
<div v-else-if="sessions.length > 0">
<StudentSessionItem
v-for="session in sessions"
:key="session.id"
:session="session"
@start="onStartSession"
/>
</div>
<NoItems v-else title="جلسه‌ای نیست" desc="هنوز جلسه‌ای برای این درس ثبت نشده است." />
</template>
<template #exams>
<SkeletonLoaderBlock v-if="isLoading && exams.length === 0" :rows="3" :cols-per-row="1" />
<div v-else-if="exams.length > 0">
<StudentExamItem v-for="exam in exams" :key="exam.id" :exam="exam" @start="onStartExam" />
</div>
<NoItems v-else title="آزمونی نیست" desc="هنوز آزمونی برای این درس ثبت نشده است." />
</template>
</TabsBlock>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import { formatJalaaliDate } from '@/utils/date-utils'
import TabsBlock from '@/components/blocks/TabsBlock.vue'
import { useStudentLessonQuery } from '@/services/query/student-lessons'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import StudentExamItem from '@/features/student/exams/components/StudentExamItem.vue'
import StudentSessionItem from '@/features/student/sessions/components/StudentSessionItem.vue'
const route = useRoute()
const router = useRouter()
const lessonId = computed(() => route.params.id)
const { data: lesson, isLoading } = useStudentLessonQuery(lessonId, {
enabled: () => !!lessonId.value,
})
const sessions = computed(() => lesson.value?.sessions ?? [])
const exams = computed(() => lesson.value?.exams ?? [])
const sessionsText = computed(() =>
lesson.value?.sessionsCount == null ? '—' : `${lesson.value.sessionsCount} جلسه`
)
const quizzesText = computed(() =>
lesson.value?.quizzesCount == null ? '—' : `${lesson.value.quizzesCount}`
)
const studentsText = computed(() =>
lesson.value?.studentsCount == null ? '—' : `${lesson.value.studentsCount} دانشجو`
)
const endDateText = computed(
() => lesson.value?.faEndDate || formatJalaaliDate(lesson.value?.endDate) || '—'
)
const tabs = [
{ name: 'sessions', label: 'همه جلسه ها', icon: 'list-bullets' },
{ name: 'exams', label: 'آزمون هــــــــا', icon: 'file' },
]
const activeTab = ref('sessions')
const onStartSession = (session) => {
router.push({ name: 'student-session-details', params: { id: session.id } }).catch(() => {})
}
const onStartExam = (exam) => {
router.push({ name: 'student-exam', params: { id: exam.id } }).catch(() => {})
}
</script>
<style lang="scss" scoped>
.student-lesson-details {
display: flex;
flex-direction: column;
gap: 0.5rem;
&__heading {
margin-bottom: 0.75rem;
}
&__summary {
position: relative;
display: flex;
flex-wrap: nowrap;
align-items: center;
justify-content: flex-start;
gap: 0.75rem;
padding: 1.25rem 1rem;
background: rgba(255, 255, 255, 40%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.75rem;
margin-bottom: 0.75rem;
&-content {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 0.5rem;
width: 100%;
}
}
&__decor {
display: none;
width: 5.5rem;
height: 5.5rem;
border-radius: 0.75rem;
background: rgba(0, 112, 116, 4%);
align-items: center;
justify-content: center;
flex-shrink: 0;
@media (min-width: 1024px) {
display: inline-flex;
}
}
&__summary-title {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 1.5rem;
color: #4b4b4b;
margin: 0;
text-align: center;
flex: 0 0 100%;
@media (min-width: 1024px) {
flex: 0 0 auto;
text-align: end;
}
}
&__summary-stats {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
&__summary-stat {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.875rem;
border-radius: 0.75rem;
background: rgba(107, 107, 107, 4%);
font-family: var(--font-family-fa);
white-space: nowrap;
}
&__summary-stat-label {
font-weight: 300;
font-size: 0.7rem;
color: #5d5d5d;
}
&__summary-stat-value {
font-weight: 500;
font-size: 0.75rem;
color: #5d5d5d;
}
}
</style>
@@ -0,0 +1,174 @@
<template>
<div class="mdis" @click="emit('show-details', dispatch)">
<div class="mdis__heading">
<span class="mdis__code">{{ dispatch.code }}</span>
<div class="mdis__title-wrap">
<p class="mdis__title">{{ dispatch.title }}</p>
<div class="mdis__location">
<SvgIcon name="paper-plane-right" :size="11" color="#989898" />
<span>{{ dispatch.location }}</span>
</div>
</div>
</div>
<div class="mdis__meta">
<div class="mdis__pill">
<span class="mdis__pill-label">تاریخ اعزام :</span>
<span class="mdis__pill-value">{{ dispatch.dispatchDate }}</span>
</div>
</div>
<div class="mdis__actions" @click.stop>
<CircleButton bg-color="rgba(0, 112, 116, 0.08)" size="2rem">
<template #icon>
<SvgIcon name="pencil" :size="14" color="#007074" />
</template>
</CircleButton>
<BaseButton
text="جزئیات درخواست"
custom-class="mdis__btn"
@click="emit('show-details', dispatch)"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="16" color="#fff" />
</template>
</BaseButton>
</div>
</div>
</template>
<script setup>
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import CircleButton from '@/components/CircleButton.vue'
defineProps({
dispatch: { type: Object, required: true },
})
const emit = defineEmits(['show-details'])
</script>
<style lang="scss" scoped>
.mdis {
position: relative;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem 1.25rem;
background: rgba(255, 255, 255, 58%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.75rem;
margin-bottom: 0.625rem;
cursor: pointer;
transition: background 0.15s ease;
&:hover {
background: rgba(255, 255, 255, 75%);
}
@media (min-width: 1024px) {
flex-direction: row;
align-items: center;
gap: 1.5rem;
padding: 1rem 2rem;
}
&__heading {
display: flex;
align-items: center;
gap: 0.625rem;
@media (min-width: 1024px) {
min-width: 8rem;
}
}
&__title-wrap {
display: flex;
flex-direction: column;
align-items: flex-end;
text-align: end;
gap: 0.35rem;
}
&__title {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 1rem;
color: #4b4b4b;
margin: 0;
}
&__location {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.7rem;
color: #989898;
}
&__code {
font-family: var(--font-family-en);
font-weight: 800;
font-size: 2.75rem;
line-height: 1;
color: #f0f0f0;
}
&__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
@media (min-width: 1024px) {
flex: 1;
justify-content: center;
}
}
&__pill {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.3rem 0.875rem;
border-radius: 0.75rem;
background: rgba(107, 107, 107, 4%);
font-family: var(--font-family-fa);
white-space: nowrap;
}
&__pill-label {
font-weight: 400;
font-size: 0.65rem;
color: #535353;
}
&__pill-value {
font-family: var(--font-family-en);
font-weight: 400;
font-size: 0.75rem;
color: #5d5d5d;
}
&__actions {
display: flex;
justify-content: center;
gap: 6px;
@media (min-width: 1024px) {
justify-content: flex-start;
}
}
&__btn {
min-width: 8rem;
height: 2rem;
padding: 0 1.25rem;
}
}
</style>
@@ -0,0 +1,177 @@
<template>
<div class="mnar" @click="emit('show-details', narrative)">
<div class="mnar__heading">
<span class="mnar__code">{{ narrative.code }}</span>
<div class="mnar__title-wrap">
<p class="mnar__title">{{ narrative.title }}</p>
<p class="mnar__subtitle">تاریخ ثبت: {{ narrative.submittedAt }}</p>
</div>
</div>
<div class="mnar__meta">
<div class="mnar__pill" :class="`mnar__pill--${narrative.kindTone}`">
<span class="mnar__pill-label">روایت :</span>
<span class="mnar__pill-value">{{ narrative.kindLabel }}</span>
</div>
</div>
<div class="mnar__actions">
<CircleButton bg-color="rgba(0, 112, 116, 0.08)" size="2rem">
<template #icon>
<SvgIcon name="check" :size="14" color="#007074" />
</template>
</CircleButton>
<CircleButton bg-color="rgba(243, 102, 117, 0.06)" size="2rem">
<template #icon>
<SvgIcon name="close" :size="14" color="var(--color-error)" />
</template>
</CircleButton>
<CircleButton bg-color="rgba(107, 107, 107, 0.04)" size="2rem">
<template #icon>
<SvgIcon name="pencil" :size="14" color="#535353" />
</template>
</CircleButton>
</div>
</div>
</template>
<script setup>
import SvgIcon from '@/components/icons/SvgIcon.vue'
import CircleButton from '@/components/CircleButton.vue'
defineProps({
narrative: { type: Object, required: true },
})
const emit = defineEmits(['show-details'])
</script>
<style lang="scss" scoped>
.mnar {
position: relative;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem 1.25rem;
background: rgba(255, 255, 255, 58%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.75rem;
margin-bottom: 0.625rem;
@media (min-width: 1024px) {
flex-direction: row;
align-items: center;
gap: 1.5rem;
padding: 1rem 2rem;
}
&__heading {
display: flex;
align-items: center;
gap: 0.625rem;
@media (min-width: 1024px) {
min-width: 9rem;
}
}
&__title-wrap {
display: flex;
flex-direction: column;
align-items: flex-end;
text-align: end;
gap: 0.2rem;
}
&__title {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 1rem;
color: #4b4b4b;
margin: 0;
}
&__subtitle {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.55rem;
color: #4b4b4b;
margin: 0;
}
&__code {
font-family: var(--font-family-en);
font-weight: 800;
font-size: 2.75rem;
line-height: 1;
color: #f0f0f0;
}
&__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
@media (min-width: 1024px) {
flex: 1;
justify-content: center;
}
}
&__pill {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.3rem 0.875rem;
border-radius: 0.75rem;
font-family: var(--font-family-fa);
white-space: nowrap;
}
&__pill-label {
font-weight: 400;
font-size: 0.65rem;
}
&__pill-value {
font-weight: 400;
font-size: 0.75rem;
}
&__pill--pink {
background: rgba(243, 102, 117, 7%);
.mnar__pill-label,
.mnar__pill-value {
color: #f55d6d;
}
}
&__pill--gold {
background: rgba(116, 102, 0, 4%);
.mnar__pill-label,
.mnar__pill-value {
color: #b6842d;
}
}
&__actions {
display: flex;
justify-content: center;
gap: 6px;
@media (min-width: 1024px) {
justify-content: flex-start;
}
}
&__btn {
min-width: 7rem;
height: 2rem;
padding: 0 1.25rem;
}
}
</style>
@@ -0,0 +1,257 @@
<template>
<div class="mreq" @click="emit('show-details', request)">
<div class="mreq__heading">
<span class="mreq__code">{{ request.code }}</span>
<div class="mreq__title-wrap">
<p class="mreq__title">{{ request.title }}</p>
<p class="mreq__subtitle">شماره درخواست : {{ request.requestNumber }}</p>
</div>
</div>
<div class="mreq__meta">
<div class="mreq__pill mreq__pill--date">
<span class="mreq__pill-label">تاریخ درخواست :</span>
<span class="mreq__pill-value">{{ request.requestDate }}</span>
</div>
<div class="mreq__pill mreq__pill--name">
<span class="mreq__pill-label">نام درخواست کننده :</span>
<span class="mreq__pill-value">{{ request.requesterName }}</span>
</div>
<div class="mreq__pill mreq__pill--status" :class="`mreq__pill--${tone}`">
<span class="mreq__dot" />
<span class="mreq__pill-value mreq__pill-value--status">{{ request.statusLabel }}</span>
</div>
</div>
<div class="mreq__actions" @click.stop>
<CircleButton bg-color="rgba(0, 112, 116, 0.08)" size="2rem">
<template #icon>
<SvgIcon name="check" :size="14" color="#007074" />
</template>
</CircleButton>
<CircleButton bg-color="rgba(243, 102, 117, 0.06)" size="2rem">
<template #icon>
<SvgIcon name="close" :size="14" color="var(--color-error)" />
</template>
</CircleButton>
<BaseButton
text="جزئیات درخواست"
custom-class="mreq__btn"
@click="emit('show-details', request)"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="16" color="#fff" />
</template>
</BaseButton>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import CircleButton from '@/components/CircleButton.vue'
const TONE_MAP = {
rejected: 'danger',
approved: 'success',
pending: 'warning',
cancelled: 'neutral',
}
const props = defineProps({
request: { type: Object, required: true },
})
const emit = defineEmits(['show-details'])
const tone = computed(() => TONE_MAP[props.request.status] || 'neutral')
</script>
<style lang="scss" scoped>
.mreq {
position: relative;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem 1.25rem;
background: rgba(255, 255, 255, 58%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.75rem;
margin-bottom: 0.625rem;
cursor: pointer;
transition: background 0.15s ease;
&:hover {
background: rgba(255, 255, 255, 75%);
}
@media (min-width: 1024px) {
flex-direction: row;
align-items: center;
gap: 1.5rem;
padding: 1rem 2rem;
}
&__heading {
display: flex;
align-items: center;
gap: 0.625rem;
@media (min-width: 1024px) {
min-width: 9rem;
}
}
&__title-wrap {
display: flex;
flex-direction: column;
align-items: flex-end;
text-align: end;
gap: 0.125rem;
}
&__title {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.95rem;
color: #4b4b4b;
margin: 0;
}
&__subtitle {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.7rem;
color: #4b4b4b;
margin: 0;
}
&__code {
font-family: var(--font-family-en);
font-weight: 800;
font-size: 2.75rem;
line-height: 1;
color: #f0f0f0;
}
&__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.5rem;
@media (min-width: 1024px) {
flex: 1;
flex-wrap: nowrap;
}
}
&__pill {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.3rem 0.875rem;
border-radius: 0.75rem;
background: rgba(107, 107, 107, 4%);
font-family: var(--font-family-fa);
white-space: nowrap;
}
&__pill-label {
font-weight: 400;
font-size: 0.65rem;
color: #535353;
}
&__pill-value {
font-family: var(--font-family-en);
font-weight: 400;
font-size: 0.75rem;
color: #5d5d5d;
}
&__pill-value--status {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.65rem;
}
&__dot {
width: 0.32rem;
height: 0.32rem;
border-radius: 9999px;
flex-shrink: 0;
}
&__pill--status {
padding: 0.3rem 0.75rem;
}
&__pill--danger {
background: rgba(243, 102, 117, 6%);
.mreq__pill-value {
color: #cc2831;
}
.mreq__dot {
background: #cc2831;
}
}
&__pill--success {
background: rgba(0, 112, 116, 6%);
.mreq__pill-value {
color: #007074;
}
.mreq__dot {
background: #007074;
}
}
&__pill--warning {
background: rgba(182, 132, 45, 7%);
.mreq__pill-value {
color: #b6842d;
}
.mreq__dot {
background: #b6842d;
}
}
&__pill--neutral {
background: rgba(107, 107, 107, 6%);
.mreq__pill-value {
color: #535353;
}
.mreq__dot {
background: #989898;
}
}
&__actions {
display: flex;
justify-content: center;
gap: 6px;
@media (min-width: 1024px) {
justify-content: flex-start;
}
}
&__btn {
min-width: 8rem;
height: 2rem;
padding: 0 1.25rem;
}
}
</style>
@@ -0,0 +1,257 @@
<template>
<div class="muc">
<div class="muc__profile">
<div class="muc__avatar">
<img v-if="avatar" :src="avatar" alt="" />
<SvgIcon v-else name="user" :size="40" color="#c2c2c2" />
</div>
<div class="muc__profile-info">
<div class="muc__level">
<span class="muc__level-label">سطح کاربر :</span>
<span class="muc__level-value">{{ level }}</span>
</div>
<p class="muc__name">{{ fullName }}</p>
<div class="muc__location">
<SvgIcon name="map-pin-simple-area" :size="12" />
<span>{{ city }}</span>
</div>
</div>
</div>
<div class="muc__stats">
<div class="muc__stat">
<span class="muc__stat-label">مدت عضویت</span>
<div class="muc__stat-value">
<span class="muc__stat-num">{{ membershipDays }}</span>
<span class="muc__stat-unit">روز</span>
</div>
</div>
<span class="muc__divider" />
<div class="muc__stat">
<span class="muc__stat-label">امتیاز کاربر</span>
<div class="muc__stat-value">
<span class="muc__stat-num">{{ rating }}</span>
</div>
</div>
<span class="muc__divider" />
<div class="muc__stat">
<span class="muc__stat-label">تعداد درخواستها</span>
<div class="muc__stat-value">
<span class="muc__stat-num">{{ requestsCount }}</span>
<span class="muc__stat-unit">درخواست</span>
</div>
</div>
</div>
<div class="muc__actions">
<CircleButton bg-color="rgba(0, 112, 116, 0.08)" size="2.5rem">
<template #icon>
<SvgIcon name="pencil" :size="20" color="#007074" />
</template>
</CircleButton>
<BaseButton text="تکمیل اطلاعات" custom-class="muc__btn" @click="emit('complete-info')">
<template #appendIcon>
<SvgIcon name="arrow-left" :size="16" color="#fff" />
</template>
</BaseButton>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import CircleButton from '@/components/CircleButton.vue'
const props = defineProps({
profile: { type: Object, default: () => ({}) },
})
const emit = defineEmits(['complete-info', 'settings'])
const fullName = computed(() => props.profile.fullName || '—')
const city = computed(() => props.profile.city || '—')
const level = computed(() => props.profile.level || '—')
const avatar = computed(() => props.profile.avatar || '')
const membershipDays = computed(() => props.profile.membershipDays ?? '—')
const rating = computed(() => props.profile.rating ?? '—')
const requestsCount = computed(() => props.profile.requestsCount ?? '—')
</script>
<style lang="scss" scoped>
.muc {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.25rem;
background: #fff;
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.75rem;
margin-bottom: 0.75rem;
@media (min-width: 1024px) {
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 1.25rem 2rem;
}
&__actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
&__btn {
min-width: 9rem;
height: 2.5rem;
padding: 0 1.25rem;
}
&__icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 9999px;
background: rgba(0, 112, 116, 4%);
cursor: pointer;
transition: background 0.15s ease;
&:hover {
background: rgba(0, 112, 116, 8%);
}
}
&__stats {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
@media (min-width: 1024px) {
gap: 1.25rem;
}
}
&__stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.125rem;
min-width: 3rem;
}
&__stat-label {
font-family: var(--font-family-fa);
font-weight: 500;
font-size: 0.7rem;
color: #a7a7a7;
white-space: nowrap;
}
&__stat-value {
display: inline-flex;
align-items: baseline;
gap: 0.25rem;
}
&__stat-num {
font-family: var(--font-family-en);
font-weight: 400;
font-size: 1.2rem;
color: #818181;
line-height: 1.4;
}
&__stat-unit {
font-family: var(--font-family-fa);
font-weight: 300;
font-size: 0.6rem;
color: #929292;
}
&__divider {
width: 1px;
height: 1.5rem;
background: #eee;
}
&__profile {
display: flex;
align-items: center;
gap: 0.875rem;
}
&__profile-info {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.375rem;
text-align: center;
}
&__name {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 1.15rem;
color: #4b4b4b;
margin: 0;
}
&__location {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.7rem;
color: #989898;
}
&__level {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.875rem;
border-radius: 0.75rem;
background: rgba(0, 112, 116, 4%);
font-family: var(--font-family-fa);
color: #007074;
}
&__level-label {
font-weight: 400;
font-size: 0.65rem;
}
&__level-value {
font-weight: 500;
font-size: 0.75rem;
}
&__avatar {
width: 5.625rem;
height: 5.625rem;
border-radius: 9999px;
background: #fff;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}
</style>
@@ -0,0 +1,72 @@
<template>
<BasicModal
title="جزئیات اعزام"
title-en="Dispatch Details"
width="95%"
max-width="56rem"
min-width="auto"
>
<div class="dispatch-details">
<div class="dispatch-details__row dispatch-details__row--summary">
<LineInfoBlock
title="نام مأموریت/اعزام"
:desc="dispatch.missionName || dispatch.title || '—'"
/>
<LineInfoBlock title="کد اعزام" :numeric-desc="dispatch.code || ''" />
<LineInfoBlock title="تاریخ اعزام" :numeric-desc="dispatch.dispatchDate || ''" />
<LineInfoBlock title="وضعیت فعلی" :desc="dispatch.statusLabel || '—'" />
<LineInfoBlock title="نام درخواست کننده" :desc="dispatch.requesterName || '—'" />
</div>
<div class="dispatch-details__row">
<LineInfoBlock title="آدرس" :desc="dispatch.address || '—'" />
</div>
<div class="dispatch-details__row dispatch-details__row--body">
<LineInfoBlock title="نتیجه یا دستاورد مأموریت" :desc="dispatch.result || '—'" />
</div>
</div>
</BasicModal>
</template>
<script setup>
import { computed } from 'vue'
import useModal from '@/composables/useModal'
import BasicModal from '@/components/BasicModal.vue'
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
defineOptions({ name: 'DispatchDetailsModal' })
const { getModal } = useModal()
const dispatch = computed(() => getModal('DispatchDetailsModal')?.data ?? {})
</script>
<style lang="scss" scoped>
.dispatch-details {
display: flex;
flex-direction: column;
gap: 0.5rem;
font-family: var(--font-family-fa);
&__row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
&--summary {
flex-direction: row;
justify-content: flex-start;
:deep(.line-info) {
flex: 1 1 auto;
min-width: 8rem;
}
}
&--body :deep(.line-info__desc) {
line-height: 1.7;
}
}
}
</style>
@@ -0,0 +1,123 @@
<template>
<BasicModal
title="جزئیات روایت شما"
title-en="Story Details"
width="95%"
max-width="56rem"
min-width="auto"
>
<div class="narrative-details">
<div class="narrative-details__head">
<div class="narrative-details__title-block">
<LineInfoBlock title="عنوان روایت" :desc="narrative.title || '—'" />
</div>
<div class="narrative-details__stats">
<div class="narrative-details__stat">
<span class="narrative-details__stat-value">{{ narrative.comments ?? 0 }}</span>
<SvgIcon name="chat-centered-dots" :size="20" />
</div>
<span class="narrative-details__stat-divider" />
<div class="narrative-details__stat">
<span class="narrative-details__stat-value">{{ narrative.likes ?? 0 }}</span>
<SvgIcon name="heart" :size="20" />
</div>
</div>
</div>
<div class="narrative-details__body">
<LineInfoBlock title="نتیجه یا دستاورد مأموریت" :desc="narrative.body || '—'" />
</div>
</div>
</BasicModal>
</template>
<script setup>
import { computed } from 'vue'
import useModal from '@/composables/useModal'
import BasicModal from '@/components/BasicModal.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
defineOptions({ name: 'NarrativeDetailsModal' })
const { getModal } = useModal()
const narrative = computed(() => getModal('NarrativeDetailsModal')?.data ?? {})
</script>
<style lang="scss" scoped>
.narrative-details {
display: flex;
flex-direction: column;
gap: 1rem;
font-family: var(--font-family-fa);
&__head {
display: flex;
flex-direction: column;
gap: 0.75rem;
@media (min-width: 768px) {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
}
&__title-block {
flex: 1;
min-width: 0;
}
&__stats {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.5rem;
background: #fff;
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.75rem;
align-self: flex-end;
min-height: 2.25rem;
}
&__stat {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0 0.25rem;
}
&__stat-divider {
width: 1.5px;
height: 1.5rem;
background: #efefef;
border-radius: 9999px;
}
&__stat-value {
font-family: var(--font-family-en);
font-weight: 300;
font-size: 0.95rem;
color: #b3b3b3;
}
&__body :deep(.line-info__desc) {
white-space: pre-line;
line-height: 1.7;
color: #4e4e4e;
}
&__footer {
display: flex;
justify-content: flex-end;
padding-top: 0.5rem;
}
&__close-btn {
min-width: 10rem;
height: 2.25rem;
}
}
</style>
@@ -0,0 +1,71 @@
<template>
<BasicModal
title="جزئیات درخواست"
title-en="Request Details"
width="95%"
max-width="56rem"
min-width="auto"
>
<div class="request-details">
<div class="request-details__row request-details__row--summary">
<LineInfoBlock
title="نام و نام خانوادگی درخواست کننده"
:desc="request.requesterName || '—'"
/>
<LineInfoBlock title="تاریخ ثبت درخواست" :numeric-desc="request.requestDate || ''" />
<LineInfoBlock title="شماره تماس" :numeric-desc="request.phone || ''" />
<LineInfoBlock title="کد پستی" :numeric-desc="request.postalCode || ''" />
</div>
<div class="request-details__row">
<LineInfoBlock title="آدرس" :desc="request.address || '—'" />
</div>
<div class="request-details__row request-details__row--body">
<LineInfoBlock title="توضیحات" :desc="request.description || '—'" />
</div>
</div>
</BasicModal>
</template>
<script setup>
import { computed } from 'vue'
import useModal from '@/composables/useModal'
import BasicModal from '@/components/BasicModal.vue'
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
defineOptions({ name: 'RequestDetailsModal' })
const { getModal } = useModal()
const request = computed(() => getModal('RequestDetailsModal')?.data ?? {})
</script>
<style lang="scss" scoped>
.request-details {
display: flex;
flex-direction: column;
gap: 0.5rem;
font-family: var(--font-family-fa);
&__row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
&--summary {
flex-direction: row;
justify-content: flex-start;
:deep(.line-info) {
flex: 1 1 auto;
min-width: 8rem;
}
}
&--body :deep(.line-info__desc) {
line-height: 1.7;
}
}
}
</style>
@@ -0,0 +1,203 @@
<template>
<div class="student-missionary">
<BoxedIconTitleBlock
class="student-missionary__heading"
title="سامانه اعزام"
desc="در این قسمت شما میتوانید درخواست های اعزام، تاریخچه اعزام و... را مشاهده کنید."
>
<template #icon>
<SvgIcon name="paper-plane-right" :size="24" color="var(--color-primary)" />
</template>
</BoxedIconTitleBlock>
<SkeletonLoaderBlock v-if="profileLoading && !profile" :rows="1" :cols-per-row="1" />
<MissionaryUserCard
v-else
:profile="profile || {}"
@complete-info="onCompleteInfo"
@settings="onProfileSettings"
/>
<TabsBlock v-model="activeTab" :tabs="tabs">
<template #my-requests>
<SkeletonLoaderBlock v-if="requestsLoading" :rows="3" :cols-per-row="1" />
<div v-else-if="requests.length > 0">
<MissionaryRequestItem
v-for="request in requests"
:key="request.id"
:request="request"
@show-details="onRequestDetails"
/>
</div>
<NoItems v-else title="موردی نیست" desc="هنوز درخواستی ثبت نکرده‌اید." />
<PaginationBlock :pagination="requestsMeta" @update:page="setRequestsPage" />
</template>
<template #history>
<SkeletonLoaderBlock v-if="dispatchesLoading" :rows="3" :cols-per-row="1" />
<div v-else-if="dispatches.length > 0">
<MissionaryDispatchItem
v-for="dispatch in dispatches"
:key="dispatch.id"
:dispatch="dispatch"
@show-details="onDispatchDetails"
/>
</div>
<NoItems v-else title="موردی نیست" desc="تاریخچه اعزامی موجود نیست." />
<PaginationBlock :pagination="dispatchesMeta" @update:page="setDispatchesPage" />
</template>
<template #narratives>
<SkeletonLoaderBlock v-if="narrativesLoading" :rows="3" :cols-per-row="1" />
<div v-else-if="narratives.length > 0">
<MissionaryNarrativeItem
v-for="narrative in narratives"
:key="narrative.id"
:narrative="narrative"
@show-details="onNarrativeDetails"
/>
</div>
<NoItems v-else title="موردی نیست" desc="هنوز روایتی ثبت نکرده‌اید." />
<PaginationBlock :pagination="narrativesMeta" @update:page="setNarrativesPage" />
</template>
</TabsBlock>
<RequestDetailsModal v-if="isModal('RequestDetailsModal')" />
<DispatchDetailsModal v-if="isModal('DispatchDetailsModal')" />
<NarrativeDetailsModal v-if="isModal('NarrativeDetailsModal')" />
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import useModal from '@/composables/useModal'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import TabsBlock from '@/components/blocks/TabsBlock.vue'
import { usePagination } from '@/composables/usePagination'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import MissionaryUserCard from '@/features/student/missionary/components/MissionaryUserCard.vue'
import MissionaryRequestItem from '@/features/student/missionary/components/MissionaryRequestItem.vue'
import MissionaryDispatchItem from '@/features/student/missionary/components/MissionaryDispatchItem.vue'
import RequestDetailsModal from '@/features/student/missionary/components/modals/RequestDetailsModal.vue'
import MissionaryNarrativeItem from '@/features/student/missionary/components/MissionaryNarrativeItem.vue'
import DispatchDetailsModal from '@/features/student/missionary/components/modals/DispatchDetailsModal.vue'
import NarrativeDetailsModal from '@/features/student/missionary/components/modals/NarrativeDetailsModal.vue'
import {
useMissionaryProfileQuery,
useMissionaryDispatchesQuery,
useMissionaryNarrativesQuery,
useMissionaryRequestsQuery,
} from '@/services/query/student-missionary'
const router = useRouter()
const { openModal, isModal } = useModal()
const tabs = [
{ name: 'my-requests', label: 'درخواست های من', icon: 'list-bullets' },
{ name: 'history', label: 'تاریخچه اعزام ها', icon: 'paper-plane-right' },
{ name: 'narratives', label: 'روایت های من', icon: 'pencil' },
]
const activeTab = ref('my-requests')
const { data: profile, isLoading: profileLoading } = useMissionaryProfileQuery()
const requestsFilters = ref({})
const dispatchesFilters = ref({})
const narrativesFilters = ref({})
const { pagination: requestsPagination, setPage: setRequestsPage } = usePagination({
page: 1,
perPage: 10,
})
const { pagination: dispatchesPagination, setPage: setDispatchesPage } = usePagination({
page: 1,
perPage: 10,
})
const { pagination: narrativesPagination, setPage: setNarrativesPage } = usePagination({
page: 1,
perPage: 10,
})
const { data: requestsData, isLoading: requestsLoading } = useMissionaryRequestsQuery(
requestsFilters,
requestsPagination,
{
enabled: () => activeTab.value === 'my-requests',
keepPreviousData: true,
}
)
const { data: dispatchesData, isLoading: dispatchesLoading } = useMissionaryDispatchesQuery(
dispatchesFilters,
dispatchesPagination,
{
enabled: () => activeTab.value === 'history',
keepPreviousData: true,
}
)
const { data: narrativesData, isLoading: narrativesLoading } = useMissionaryNarrativesQuery(
narrativesFilters,
narrativesPagination,
{
enabled: () => activeTab.value === 'narratives',
keepPreviousData: true,
}
)
const requests = computed(() => requestsData.value?.data ?? [])
const dispatches = computed(() => dispatchesData.value?.data ?? [])
const narratives = computed(() => narrativesData.value?.data ?? [])
const requestsMeta = computed(() => ({
page: requestsPagination.value.page,
perPage: requestsPagination.value.perPage,
...requestsData.value?.meta,
}))
const dispatchesMeta = computed(() => ({
page: dispatchesPagination.value.page,
perPage: dispatchesPagination.value.perPage,
...dispatchesData.value?.meta,
}))
const narrativesMeta = computed(() => ({
page: narrativesPagination.value.page,
perPage: narrativesPagination.value.perPage,
...narrativesData.value?.meta,
}))
const onCompleteInfo = () => {
router.push({ name: 'student-profile' }).catch(() => {})
}
const onProfileSettings = () => {
router.push({ name: 'student-profile' }).catch(() => {})
}
const onRequestDetails = (request) => {
openModal('RequestDetailsModal', request)
}
const onDispatchDetails = (dispatch) => {
openModal('DispatchDetailsModal', dispatch)
}
const onNarrativeDetails = (narrative) => {
openModal('NarrativeDetailsModal', narrative)
}
</script>
<style lang="scss" scoped>
.student-missionary {
display: flex;
flex-direction: column;
gap: 0.5rem;
&__heading {
margin-bottom: 0.75rem;
}
}
</style>
@@ -217,8 +217,8 @@ import ImageCropper from '@/components/form/ImageCropper.vue'
import { objectToFormData } from '@/utils/object-to-formdata'
import TextareaField from '@/components/form/TextareaField.vue'
import PasswordField from '@/components/form/PasswordField.vue'
import { studentProfileSchema } from '@/features/student/schema'
import DatePickerField from '@/components/form/DatePickerField.vue'
import { studentProfileSchema } from '@/features/student/profile/schema'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import { useGetCitiesOfProvinceQuery, useGetProvincesQuery } from '@/services/query/common'
import {
+63 -3
View File
@@ -8,19 +8,79 @@ export default [
{
path: '/profile',
name: 'student-profile',
component: () => import('@/features/student/pages/EditProfilePage.vue'),
component: () => import('@/features/student/profile/pages/EditProfilePage.vue'),
meta: { layout: 'student', role: 'student', title: 'ویرایش پروفایل' },
},
{
path: '/my-courses',
name: 'student-courses',
component: () => import('@/features/student/pages/StudentCoursesPage.vue'),
component: () => import('@/features/student/courses/pages/StudentCoursesPage.vue'),
meta: { layout: 'student', role: 'student', title: 'دوره‌های من' },
},
{
path: '/my-courses/:id',
name: 'student-course-details',
component: () => import('@/features/student/pages/StudentCourseDetailsPage.vue'),
component: () => import('@/features/student/courses/pages/StudentCourseDetailsPage.vue'),
meta: { layout: 'student', role: 'student', title: 'جزئیات دوره' },
},
{
path: '/my-terms',
name: 'student-terms',
component: () => import('@/features/student/terms/pages/StudentTermsPage.vue'),
meta: { layout: 'student', role: 'student', title: 'دوره‌های آموزشی' },
},
{
path: '/my-terms/:id',
name: 'student-term-details',
component: () => import('@/features/student/terms/pages/StudentTermDetailsPage.vue'),
meta: { layout: 'student', role: 'student', title: 'جزئیات دوره' },
},
{
path: '/my-lessons/:id',
name: 'student-lesson-details',
component: () => import('@/features/student/lessons/pages/StudentLessonDetailsPage.vue'),
meta: { layout: 'student', role: 'student', title: 'جزئیات درس' },
},
{
path: '/my-sessions/:id',
name: 'student-session-details',
component: () => import('@/features/student/sessions/pages/StudentSessionDetailsPage.vue'),
meta: { layout: 'student', role: 'student', title: 'جزئیات جلسه' },
},
{
path: '/my-exams/:id',
name: 'student-exam',
component: () => import('@/features/student/exams/pages/StudentExamPage.vue'),
meta: { layout: 'student', role: 'student', title: 'آزمون' },
},
{
path: '/my-exams/:id/take',
name: 'student-take-exam',
component: () => import('@/features/student/exams/pages/StudentTakeExamPage.vue'),
meta: { layout: 'student', role: 'student', title: 'شروع آزمون' },
},
{
path: '/missionary',
name: 'student-missionary',
component: () => import('@/features/student/missionary/pages/StudentMissionaryPage.vue'),
meta: { layout: 'student', role: 'student', title: 'سامانه اعزام' },
},
{
path: '/user/services',
name: 'student-services',
component: () => import('@/features/student/services/pages/StudentServicesPage.vue'),
meta: { layout: 'student', role: 'student', title: 'خدمات' },
},
{
path: '/user/consultants',
name: 'student-consultants',
component: () => import('@/features/student/consultants/pages/StudentConsultantsPage.vue'),
meta: { layout: 'student', role: 'student', title: 'مشاوره' },
},
{
path: '/user/certificates',
name: 'student-certificates',
component: () => import('@/features/student/certificates/pages/StudentCertificatesPage.vue'),
meta: { layout: 'student', role: 'student', title: 'گواهینامه ها' },
},
]
@@ -0,0 +1,251 @@
<template>
<div class="csf">
<form class="csf__form" @submit.prevent="onSubmit">
<div class="csf__row">
<div class="csf__cell">
<SelectField
v-model="form.typeKey"
name="typeKey"
label="نوع خدمت"
placeholder="انتخاب کنید"
:options="typeOptions"
option-label="label"
option-value="value"
:error="errors.typeKey"
@change="onTypeChange"
/>
</div>
<div class="csf__cell">
<SelectField
v-model="form.title"
name="title"
label="عنوان خدمت"
placeholder="انتخاب کنید"
:options="titleOptions"
option-label="label"
option-value="value"
:disabled="!form.typeKey"
:error="errors.title"
/>
</div>
</div>
<div class="csf__row">
<div class="csf__cell csf__cell--full">
<TextareaField
v-model="form.description"
name="description"
label="توضیحات خود را وارد نمایید"
placeholder="لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است."
:row="5"
:error="errors.description"
/>
</div>
</div>
<div class="csf__upload">
<FileUploader
v-model="files"
accept="image/*,application/pdf"
:max-files="3"
@error="onFileError"
/>
</div>
<div class="csf__divider" />
<div class="csf__actions">
<BaseButton
type="submit"
text="ثبت درخواست"
custom-class="csf__submit"
:loading="submitting"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="16" color="#fff" />
</template>
</BaseButton>
</div>
</form>
</div>
</template>
<script setup>
import * as yup from 'yup'
import useYup from '@/composables/useYup'
import { computed, ref, watch } from 'vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import SelectField from '@/components/form/SelectField.vue'
import FileUploader from '@/components/form/FileUploader.vue'
import TextareaField from '@/components/form/TextareaField.vue'
import {
useStudentServiceTitlesQuery,
useStudentServiceTypesQuery,
} from '@/services/query/student-services'
defineProps({
submitting: { type: Boolean, default: false },
})
const emit = defineEmits(['submit', 'file-error'])
const form = ref({
typeKey: '',
title: '',
description: '',
})
const files = ref([])
const schema = yup.object({
typeKey: yup.string().required('نوع خدمت را انتخاب کنید'),
title: yup.string().required('عنوان خدمت را انتخاب کنید'),
description: yup
.string()
.trim()
.min(5, 'توضیحات حداقل ۵ کاراکتر باشد')
.required('توضیحات را وارد کنید'),
})
const { validate, errors } = useYup(schema)
const { data: types } = useStudentServiceTypesQuery()
const typeOptions = computed(() => types.value ?? [])
const typeKeyRef = computed(() => form.value.typeKey)
const { data: titles } = useStudentServiceTitlesQuery(typeKeyRef, {
enabled: () => !!form.value.typeKey,
})
const titleOptions = computed(() => titles.value ?? [])
const onTypeChange = () => {
form.value.title = ''
}
watch(
() => form.value.typeKey,
() => {
form.value.title = ''
}
)
const onFileError = (msg) => emit('file-error', msg)
const onSubmit = async () => {
const { isValid, payload } = await validate(form.value)
if (!isValid) return
emit('submit', { ...payload, files: files.value })
}
</script>
<style lang="scss" scoped>
.csf {
display: flex;
flex-direction: column;
gap: 1rem;
border-radius: 0.75rem;
&__intro {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 0 1rem;
border-bottom: 0.75px solid #ddd;
}
&__intro-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 0.625rem;
background: rgba(255, 255, 255, 44%);
box-shadow: 0 3px 7.35px rgba(245, 93, 109, 100%);
flex-shrink: 0;
}
&__intro-text {
display: flex;
flex-direction: column;
align-items: flex-end;
text-align: end;
gap: 0.125rem;
flex: 1;
@media (min-width: 768px) {
align-items: center;
text-align: center;
}
}
&__intro-title {
font-family: var(--font-family-fa);
font-weight: 500;
font-size: 1.05rem;
color: #4c4c4c;
margin: 0;
}
&__intro-desc {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.75rem;
color: #adadad;
margin: 0;
}
&__form {
display: flex;
flex-direction: column;
gap: 1rem;
}
&__row {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
@media (min-width: 768px) {
grid-template-columns: 1fr 1fr;
}
}
&__cell {
display: flex;
flex-direction: column;
&--full {
grid-column: 1 / -1;
}
}
&__upload {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
&__divider {
height: 0.75px;
background: #ddd;
margin-top: 0.25rem;
}
&__actions {
display: flex;
justify-content: center;
@media (min-width: 768px) {
justify-content: flex-end;
}
}
&__submit {
min-width: 14rem;
height: 2.5rem;
padding: 0 2.5rem;
}
}
</style>
@@ -0,0 +1,106 @@
<template>
<div class="svc" @click="emit('show-details', service)">
<div class="svc__heading">
<p class="svc__title">{{ service.title }}</p>
<p class="svc__subtitle">شماره درخواست : {{ service.requestNumber }}</p>
</div>
<div class="svc__meta">
<Badge
variant="neutral"
size="sm"
icon="chat"
:label="`نوع خدمت :`"
:value="service.typeLabel"
/>
<Badge :variant="tone" size="sm" dot :value="service.statusLabel" />
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import Badge from '@/global-components/Badge.vue'
const TONE_MAP = {
approved: 'success',
rejected: 'danger',
pending: 'neutral',
}
const props = defineProps({
service: { type: Object, required: true },
})
const emit = defineEmits(['show-details'])
const tone = computed(() => TONE_MAP[props.service.status] || 'neutral')
</script>
<style lang="scss" scoped>
.svc {
position: relative;
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem 1.25rem;
background: rgba(255, 255, 255, 58%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.75rem;
margin-bottom: 0.625rem;
cursor: pointer;
transition: background 0.15s ease;
&:hover {
background: rgba(255, 255, 255, 75%);
}
@media (min-width: 1024px) {
flex-direction: row;
align-items: center;
gap: 1.5rem;
padding: 0.875rem 2rem;
}
&__heading {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: end;
gap: 0.125rem;
@media (min-width: 1024px) {
min-width: 7rem;
}
}
&__title {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.95rem;
color: #4b4b4b;
margin: 0;
}
&__subtitle {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.7rem;
color: #4b4b4b;
margin: 0;
}
&__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
@media (min-width: 1024px) {
flex: 1;
justify-content: center;
}
}
}
</style>
@@ -0,0 +1,116 @@
<template>
<div class="student-services">
<BoxedIconTitleBlock
class="student-services__heading"
title="خدمات"
desc="درخواست خدمت ثبت کنید یا تاریخچه درخواست‌های خود را مشاهده نمایید."
>
<template #icon>
<SvgIcon name="paper-plane" :size="24" color="var(--color-primary)" />
</template>
</BoxedIconTitleBlock>
<TabsBlock v-model="activeTab" :tabs="tabs">
<template #create>
<CreateServiceForm
:submitting="createMutation.isPending.value"
@submit="onSubmit"
@file-error="onFileError"
/>
</template>
<template #history>
<SkeletonLoaderBlock v-if="historyLoading" :rows="3" :cols-per-row="1" />
<div v-else-if="services.length > 0">
<ServiceItem
v-for="service in services"
:key="service.id"
:service="service"
@show-details="onShowDetails"
/>
</div>
<NoItems v-else title="موردی نیست" desc="هنوز درخواستی ثبت نکرده‌اید." />
<PaginationBlock :pagination="historyMeta" @update:page="setHistoryPage" />
</template>
</TabsBlock>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { toast } from 'vue3-toastify'
import { useQueryClient } from '@tanstack/vue-query'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import TabsBlock from '@/components/blocks/TabsBlock.vue'
import { usePagination } from '@/composables/usePagination'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import ServiceItem from '@/features/student/services/components/ServiceItem.vue'
import CreateServiceForm from '@/features/student/services/components/CreateServiceForm.vue'
import {
studentServicesKeys,
useCreateStudentServiceMutation,
useStudentServicesQuery,
} from '@/services/query/student-services'
const queryClient = useQueryClient()
const tabs = [
{ name: 'create', label: 'درخواست خدمت', icon: 'paper-plane' },
{ name: 'history', label: 'تاریخچه درخواست ها', icon: 'list-bullets' },
]
const activeTab = ref('create')
const historyFilters = ref({})
const { pagination: historyPagination, setPage: setHistoryPage } = usePagination({
page: 1,
perPage: 10,
})
const { data: historyData, isLoading: historyLoading } = useStudentServicesQuery(
historyFilters,
historyPagination,
{
enabled: () => activeTab.value === 'history',
keepPreviousData: true,
}
)
const services = computed(() => historyData.value?.data ?? [])
const historyMeta = computed(() => ({
page: historyPagination.value.page,
perPage: historyPagination.value.perPage,
...historyData.value?.meta,
}))
const createMutation = useCreateStudentServiceMutation()
const onSubmit = async (payload) => {
try {
await createMutation.mutateAsync(payload)
toast.success('درخواست خدمت با موفقیت ثبت شد.')
await queryClient.invalidateQueries({ queryKey: studentServicesKeys.all })
activeTab.value = 'history'
} catch {
toast.error('ثبت درخواست با خطا مواجه شد.')
}
}
const onFileError = (msg) => toast.warning(msg)
const onShowDetails = () => {}
</script>
<style lang="scss" scoped>
.student-services {
display: flex;
flex-direction: column;
gap: 0.5rem;
&__heading {
margin-bottom: 0.75rem;
}
}
</style>
@@ -0,0 +1,162 @@
<template>
<div class="student-session-item">
<div>
<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>
</div>
</div>
<div class="student-session-item__badges">
<span
v-if="session.isSeen"
class="student-session-item__badge student-session-item__badge--seen"
>
دیده شده
</span>
<span v-if="session.needsAssignment" class="student-session-item__badge">
<SvgIcon name="paper-plane" :size="11" color="#5d5d5d" />
ارسال تکلیف
</span>
<span v-if="session.hasQuiz" class="student-session-item__badge">
<SvgIcon name="file" :size="11" color="#5d5d5d" />
دارای آزمون
</span>
<span v-if="session.isOnline" class="student-session-item__badge">
<SvgIcon name="users-three" :size="11" color="#5d5d5d" />
جلسه آنلاین
</span>
</div>
<div class="student-session-item__actions">
<BaseButton
text="شروع یادگیری"
custom-class="student-session-item__btn"
@click="emit('start', session)"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="14" color="#fff" />
</template>
</BaseButton>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
const props = defineProps({
session: { type: Object, required: true },
})
const emit = defineEmits(['start'])
const durationText = computed(() =>
props.session.durationHours == null ? '—' : `${props.session.durationHours} ساعت`
)
</script>
<style lang="scss" scoped>
.student-session-item {
display: flex;
flex-direction: column-reverse;
gap: 0.625rem;
padding: 0.875rem 1rem;
background: rgba(255, 255, 255, 58%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.75rem;
margin-bottom: 0.625rem;
@media (min-width: 1024px) {
flex-direction: row;
align-items: center;
gap: 1rem;
}
&__title {
flex: 1 1 22%;
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.95rem;
color: #4b4b4b;
margin: 0;
text-align: end;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
order: 4;
@media (min-width: 1024px) {
order: 0;
}
}
&__badges {
flex: 1 1 38%;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
&__badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.875rem;
border-radius: 0.75rem;
background: rgba(107, 107, 107, 4%);
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.7rem;
color: #5d5d5d;
white-space: nowrap;
&--seen {
background: rgba(0, 255, 68, 4%);
color: #009a12;
}
}
&__hours {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.875rem;
border-radius: 0.75rem;
background: rgba(0, 112, 116, 4%);
font-family: var(--font-family-fa);
white-space: nowrap;
}
&__hours-label {
font-weight: 300;
font-size: 0.65rem;
color: #007074;
}
&__hours-value {
font-weight: 500;
font-size: 0.75rem;
color: #007074;
}
&__actions {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: flex-end;
}
&__btn {
min-width: 6rem;
padding: 0 1rem;
height: 2rem;
}
}
</style>
@@ -0,0 +1,306 @@
<template>
<div class="ssd">
<BoxedIconTitleBlock
class="ssd__heading"
:title="session?.title || 'جزئیات جلسه'"
:desc="session?.desc || 'اطلاعات کامل این جلسه را مشاهده کنید.'"
>
<template #icon>
<SvgIcon name="book" :size="24" color="var(--color-primary)" />
</template>
</BoxedIconTitleBlock>
<TabsBlock :tabs="tabs" v-model="activeTab" @change-tab="onTabChange">
<template #homework>
<SkeletonLoaderBlock v-if="isLoading && !session" :rows="3" :cols-per-row="1" />
<div v-else class="ssd__panel">
<div class="ssd__prompt">
<SvgIcon name="warning" :size="22" color="#b8b8b8" class="ssd__prompt-icon" />
<p class="ssd__prompt-text">
{{ session?.homeworkPrompt || 'متنی برای این جلسه ثبت نشده است.' }}
</p>
</div>
<div class="ssd__media">
<div class="ssd__content">
<div v-if="contentKind === 'video'" class="ssd__video">
<VideoPlayerBlock
:src="session?.videoUrl || ''"
:poster="session?.poster || ''"
:video-id="sessionId"
/>
</div>
<div v-else-if="contentKind === 'audio'" class="ssd__audio">
<SvgIcon name="paper-plane-right" :size="40" color="rgba(0, 112, 116, 0.31)" />
<audio :src="session.audioUrl" controls class="ssd__audio-player" />
</div>
<div v-else-if="contentKind === 'text'" class="ssd__text">
<SvgIcon name="warning" :size="22" color="#b8b8b8" class="ssd__text-icon" />
<p class="ssd__text-body">{{ session.text }}</p>
</div>
<div v-else-if="contentKind === 'link'" class="ssd__link">
<div class="ssd__link__box">
<span>https://link</span>
<SvgIcon name="link" :size="22" class="ssd__link-icon" />
</div>
<BaseButton text="ورود به جلسه " custom-class="ssd__link-btn">
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
</template>
</BaseButton>
</div>
<div v-else class="ssd__empty">
<SvgIcon name="warning" :size="32" color="#bcbcbc" />
<p>محتوایی برای این جلسه ثبت نشده است.</p>
</div>
</div>
<div class="ssd__uploads" v-if="contentKind !== 'link'">
<VoiceRecorder
class="ssd__uploads-recorder"
v-model="homeworkAudio"
:error="errors.audio"
/>
<ImageUploader
class="ssd__uploads-uploader"
v-model="homeworkImage"
:error="errors.image"
/>
</div>
</div>
<div class="ssd__hint">
<SvgIcon name="warning" :size="22" color="#b8b8b8" />
<p class="ssd__hint-text">
{{ session?.homeworkHint || '' }}
</p>
</div>
<div class="ssd__footer">
<BaseButton
text="ارسال تکلیف"
:loading="submitMutation.isPending.value"
:disabled="!canSubmit"
custom-class="ssd__submit-btn"
@click="onSubmit"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
</template>
</BaseButton>
</div>
</div>
</template>
<template #exam>
<NoItems title="در حال انتقال" desc="در حال انتقال به صفحه آزمون..." />
</template>
</TabsBlock>
</div>
</template>
<script setup>
import { toast } from 'vue3-toastify'
import { computed, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import BaseButton from '@/components/BaseButton.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import TabsBlock from '@/components/blocks/TabsBlock.vue'
import { objectToFormData } from '@/utils/object-to-formdata'
import VoiceRecorder from '@/components/form/VoiceRecorder.vue'
import ImageUploader from '@/components/form/ImageUploader.vue'
import VideoPlayerBlock from '@/components/blocks/VideoPlayerBlock.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import {
useStudentSessionQuery,
useSubmitStudentSessionHomeworkMutation,
} from '@/services/query/student-sessions'
const route = useRoute()
const router = useRouter()
const sessionId = computed(() => route.params.id)
const { data: session, isLoading } = useStudentSessionQuery(sessionId, {
enabled: () => !!sessionId.value,
})
const contentKind = computed(() => {
const s = session.value
if (!s) return ''
if (s.videoUrl) return 'link'
if (s.audioUrl) return 'audio'
if (s.text) return 'text'
if (s.link) return 'link'
return ''
})
const tabs = [
{ name: 'homework', label: 'ارسال تکلیف', icon: 'list-bullets' },
{ name: 'exam', label: 'آزمـــــون', icon: 'file' },
]
const activeTab = ref('homework')
const homeworkAudio = ref(null)
const homeworkImage = ref(null)
const errors = reactive({ audio: '', image: '' })
const canSubmit = computed(() => !!(homeworkAudio.value || homeworkImage.value))
const submitMutation = useSubmitStudentSessionHomeworkMutation()
const onSubmit = async () => {
if (!canSubmit.value) {
errors.audio = 'حداقل یک فایل صوتی یا تصویر ارسال کنید.'
return
}
errors.audio = ''
errors.image = ''
try {
const payload = { subType: 'homework' }
if (homeworkAudio.value) payload.audio = homeworkAudio.value
if (homeworkImage.value) payload.image = homeworkImage.value
const fd = objectToFormData(payload)
await submitMutation.mutateAsync({ id: sessionId.value, payload: fd })
toast.success('تکلیف با موفقیت ارسال شد.')
homeworkAudio.value = null
homeworkImage.value = null
} catch {
toast.error('ارسال تکلیف با خطا مواجه شد.')
}
}
const onTabChange = (name) => {
if (name !== 'exam') return
const examId = session.value?.examId
if (examId) {
router.push({ name: 'student-exam', params: { id: examId } }).catch(() => {})
} else {
toast.info('آزمونی برای این جلسه ثبت نشده است.')
activeTab.value = 'homework'
}
}
</script>
<style lang="scss" scoped>
.ssd {
display: flex;
flex-direction: column;
gap: 0.5rem;
&__heading {
margin-bottom: 0.75rem;
}
&__media {
display: flex;
align-items: center;
gap: 16px;
}
&__panel {
display: flex;
flex-direction: column;
gap: 0.875rem;
}
&__prompt {
display: flex;
align-items: flex-start;
gap: 0.625rem;
padding: 0.75rem 1rem;
background: rgba(255, 255, 255, 58%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 1rem;
}
&__prompt-icon {
flex-shrink: 0;
margin-top: 0.125rem;
}
&__prompt-text {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.875rem;
line-height: 1.7;
color: #5d5d5d;
margin: 0;
text-align: justify;
}
&__content {
width: 100%;
}
&__uploads {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.875rem;
height: -webkit-fill-available;
&-uploader {
height: stretch;
}
:global(.voice-recorder) {
height: stretch;
background-color: #eee !important;
}
}
&__link {
display: flex;
align-items: center;
gap: 1rem;
&__box {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.625rem;
padding: 0.625rem 1rem;
border: 1px solid #ddd;
border-radius: 50px;
width: 100%;
}
&__icon {
flex-shrink: 0;
}
}
&__hint {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.625rem 1rem;
background: #eee;
border-radius: 1rem;
}
&__hint-text {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.875rem;
line-height: 1.7;
color: #5d5d5d;
margin: 0;
text-align: justify;
flex: 1;
}
&__footer {
display: flex;
justify-content: center;
margin-top: 0.5rem;
}
&__submit-btn {
min-width: 18rem;
padding: 0 2rem;
}
}
</style>
@@ -0,0 +1,280 @@
<template>
<div class="student-term-item">
<div class="student-term-item__box">
<div class="student-term-item__accent" />
<div class="student-term-item__info">
<p class="student-term-item__title">{{ term.title || '—' }}</p>
<div class="student-term-item__teacher">
<SvgIcon name="user" :size="10" color="#bcbcbc" />
<span class="student-term-item__teacher-label">استـــــــاد:</span>
<span class="student-term-item__teacher-name">{{ teacherName }}</span>
</div>
</div>
</div>
<div class="student-term-item__meta">
<div class="student-term-item__dates">
<div class="student-term-item__date">
<span class="student-term-item__date-label">تاریخ پایان :</span>
<span class="student-term-item__date-value">{{ endDate }}</span>
</div>
<span class="student-term-item__date-divider" />
<div class="student-term-item__date">
<span class="student-term-item__date-label">تاریخ شروع :</span>
<span class="student-term-item__date-value">{{ startDate }}</span>
</div>
</div>
<div v-if="hasGpa" class="student-term-item__status student-term-item__status--gpa">
<span class="student-term-item__status-label">معدل دوره :</span>
<span class="student-term-item__status-value student-term-item__status-value--en">
{{ gpaText }}
</span>
</div>
<div
v-else
class="student-term-item__status"
:class="`student-term-item__status--${term.status || 'watching'}`"
>
<span class="student-term-item__status-label">وضعیت دوره :</span>
<span class="student-term-item__status-value">{{ statusLabel }}</span>
</div>
</div>
<div v-if="showDetails" class="student-term-item__actions">
<BaseButton
text="جزئیات دوره"
custom-class="student-term-item__details-btn"
@click="emit('show-details', term)"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="14" color="#fff" />
</template>
</BaseButton>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { STUDENT_COURSE_STATUS } from '@/enums'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { formatJalaaliDate } from '@/utils/date-utils'
const props = defineProps({
term: { type: Object, required: true },
showDetails: { type: Boolean, default: true },
})
const emit = defineEmits(['show-details'])
const teacherName = computed(() => {
const t = props.term.teacher
if (t) return `${t.firstName || ''} ${t.lastName || ''}`.trim() || t.fullName || '—'
return props.term.teacherName || '—'
})
const startDate = computed(
() => props.term.faStartDate || formatJalaaliDate(props.term.startDate) || '—'
)
const endDate = computed(() => props.term.faEndDate || formatJalaaliDate(props.term.endDate) || '—')
const statusLabel = computed(
() => props.term.statusLabel || STUDENT_COURSE_STATUS[props.term.status] || '—'
)
const hasGpa = computed(
() => props.term.gpa !== undefined && props.term.gpa !== null && props.term.gpa !== ''
)
const gpaText = computed(() => {
const v = Number(props.term.gpa)
return Number.isFinite(v) ? v.toFixed(3).replace(/0+$/, '').replace(/\.$/, '') : props.term.gpa
})
</script>
<style lang="scss" scoped>
.student-term-item {
display: flex;
flex-direction: column-reverse;
gap: 0.625rem;
padding: 0.875rem;
background: rgba(255, 255, 255, 58%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.75rem;
margin-bottom: 0.625rem;
@media (min-width: 1024px) {
flex-direction: row;
align-items: center;
gap: 1rem;
}
&__box {
display: flex;
align-items: center;
}
&__info {
flex: 1 1 25%;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
text-align: end;
margin-right: 4px;
}
&__title {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.95rem;
color: #4b4b4b;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
&__teacher {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-family: var(--font-family-fa);
font-size: 0.65rem;
color: #4b4b4b;
}
&__teacher-label {
color: #4b4b4b;
}
&__teacher-name {
color: #4b4b4b;
}
&__meta {
flex: 1 1 40%;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
&__status {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 1rem;
border-radius: 0.75rem;
font-family: var(--font-family-fa);
font-weight: 500;
font-size: 0.75rem;
white-space: nowrap;
&--watching {
background: rgba(0, 112, 116, 4%);
color: #007074;
}
&--waitForExam {
background: rgba(116, 102, 0, 4%);
color: #746a00;
}
&--completed {
background: rgba(0, 154, 18, 6%);
color: #009a12;
}
&--failed {
background: rgba(204, 40, 49, 6%);
color: #cc2831;
}
&--gpa {
background: rgba(0, 112, 116, 6%);
color: #007074;
}
}
&__status-value--en {
font-family: var(--font-family-en);
}
&__status-label {
font-weight: 300;
font-size: 0.7rem;
color: inherit;
opacity: 0.85;
}
&__status-value {
font-weight: 500;
}
&__dates {
display: inline-flex;
align-items: center;
gap: 0.75rem;
padding: 0.375rem 1rem;
border-radius: 0.75rem;
background: rgba(107, 107, 107, 4%);
white-space: nowrap;
}
&__date {
display: inline-flex;
align-items: baseline;
gap: 0.375rem;
}
&__date-label {
font-family: var(--font-family-fa);
font-weight: 300;
font-size: 0.7rem;
color: #535353;
}
&__date-value {
font-family: var(--font-family-en);
font-size: 0.75rem;
color: #5d5d5d;
}
&__date-divider {
width: 1px;
height: 0.625rem;
background: #dadada;
}
&__actions {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.625rem;
}
&__accent {
width: 3.25rem;
height: 3.35rem;
border-radius: 0.75rem;
background: #f4f4f4;
display: none;
@media (min-width: 1024px) {
display: block;
}
}
&__details-btn {
min-width: 6rem;
padding: 0 1rem;
height: 2rem;
}
}
</style>
@@ -0,0 +1,227 @@
<template>
<div class="student-term-summary">
<div class="student-term-summary__cover">
<img v-if="term.image" :src="term.image" :alt="term.title" />
</div>
<div class="student-term-summary__info">
<div
class="student-term-summary__status"
:class="`student-term-summary__status--${term.status || 'watching'}`"
>
<span class="student-term-summary__status-label">وضعیت دوره :</span>
<span class="student-term-summary__status-value">{{ statusLabel }}</span>
</div>
<p class="student-term-summary__title">{{ term.title || '—' }}</p>
<div class="student-term-summary__teacher">
<span class="student-term-summary__teacher-label">استـــــــاد:</span>
<span class="student-term-summary__teacher-name">{{ teacherName }}</span>
<SvgIcon name="user" :size="13" color="#bcbcbc" />
</div>
</div>
<div class="student-term-summary__dates">
<div class="student-term-summary__date">
<span class="student-term-summary__date-label">تاریخ پایان :</span>
<span class="student-term-summary__date-value">{{ endDate }}</span>
</div>
<span class="student-term-summary__date-divider" />
<div class="student-term-summary__date">
<span class="student-term-summary__date-label">تاریخ شروع :</span>
<span class="student-term-summary__date-value">{{ startDate }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { STUDENT_COURSE_STATUS } from '@/enums'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { formatJalaaliDate } from '@/utils/date-utils'
const props = defineProps({
term: { type: Object, required: true },
})
const teacherName = computed(() => {
const t = props.term.teacher
if (t) return `${t.firstName || ''} ${t.lastName || ''}`.trim() || t.fullName || '—'
return props.term.teacherName || '—'
})
const startDate = computed(
() => props.term.faStartDate || formatJalaaliDate(props.term.startDate) || '—'
)
const endDate = computed(() => props.term.faEndDate || formatJalaaliDate(props.term.endDate) || '—')
const statusLabel = computed(
() => props.term.statusLabel || STUDENT_COURSE_STATUS[props.term.status] || '—'
)
</script>
<style lang="scss" scoped>
.student-term-summary {
position: relative;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 1rem;
padding: 0.875rem 1.25rem;
background: rgba(255, 255, 255, 40%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.75rem;
margin-bottom: 0.75rem;
@media (min-width: 1024px) {
flex-direction: row;
align-items: center;
min-height: 9.9rem;
padding: 0.875rem 2.25rem;
}
&__cover {
width: 100%;
height: 8rem;
border-radius: 0.75rem;
background: #f4f4f4;
overflow: hidden;
order: 1;
@media (min-width: 1024px) {
width: 19rem;
height: 8rem;
flex-shrink: 0;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&__info {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 0.5rem;
text-align: end;
order: 2;
}
&__status {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 1rem;
border-radius: 0.75rem;
font-family: var(--font-family-fa);
white-space: nowrap;
&--watching {
background: rgba(0, 112, 116, 4%);
color: #007074;
}
&--waitForExam {
background: rgba(116, 102, 0, 4%);
color: #746a00;
}
&--completed {
background: rgba(0, 154, 18, 6%);
color: #009a12;
}
&--failed {
background: rgba(204, 40, 49, 6%);
color: #cc2831;
}
}
&__status-label {
font-weight: 400;
font-size: 0.65rem;
color: inherit;
}
&__status-value {
font-weight: 500;
font-size: 0.75rem;
}
&__title {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 1.5rem;
color: #4b4b4b;
margin: 0;
line-height: 1.234;
}
&__teacher {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-family: var(--font-family-fa);
font-size: 0.75rem;
color: #4b4b4b;
}
&__teacher-label {
font-weight: 400;
}
&__teacher-name {
font-weight: 400;
}
&__dates {
display: inline-flex;
align-items: center;
gap: 0.75rem;
padding: 0.375rem 1.125rem;
border-radius: 1.375rem;
background: rgba(107, 107, 107, 4%);
white-space: nowrap;
align-self: center;
order: 3;
@media (min-width: 1024px) {
align-self: center;
}
}
&__date {
display: inline-flex;
align-items: baseline;
gap: 0.375rem;
}
&__date-label {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.6rem;
color: #535353;
}
&__date-value {
font-family: var(--font-family-en);
font-weight: 400;
font-size: 0.75rem;
color: #5d5d5d;
}
&__date-divider {
width: 1px;
height: 0.625rem;
background: #dadada;
}
}
</style>
@@ -0,0 +1,77 @@
<template>
<div class="student-term-details">
<BoxedIconTitleBlock
class="student-term-details__heading"
title="جزئیات دوره‌ آموزشی"
desc="اطلاعات کامل این دوره را مشاهده کنید."
>
<template #icon>
<SvgIcon name="book" :size="24" color="var(--color-primary)" />
</template>
</BoxedIconTitleBlock>
<SkeletonLoaderBlock v-if="isLoading && !term" :rows="2" :cols-per-row="1" />
<StudentTermSummary v-else-if="term" :term="term" />
<SimpleTitleIconBlock title="همه درس ها" class="student-term-details__list-title">
<template #header-icon>
<SvgIcon name="list-bullets" :size="16" color="#bcbcbc" />
</template>
</SimpleTitleIconBlock>
<SkeletonLoaderBlock v-if="isLoading && lessons.length === 0" :rows="3" :cols-per-row="1" />
<div v-else-if="lessons.length > 0">
<StudentLessonItem
v-for="lesson in lessons"
:key="lesson.id"
:lesson="lesson"
@show-details="onShowLesson"
/>
</div>
<NoItems v-else title="درسی نیست" desc="هنوز درسی برای این دوره ثبت نشده است." />
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import { useStudentTermQuery } from '@/services/query/student-terms'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
import StudentTermSummary from '@/features/student/terms/components/StudentTermSummary.vue'
import StudentLessonItem from '@/features/student/lessons/components/StudentLessonItem.vue'
const route = useRoute()
const router = useRouter()
const termId = computed(() => route.params.id)
const { data: term, isLoading } = useStudentTermQuery(termId, {
enabled: () => !!termId.value,
})
const lessons = computed(() => term.value?.courses ?? term.value?.lessons ?? [])
const onShowLesson = (lesson) => {
router.push({ name: 'student-lesson-details', params: { id: lesson.id } }).catch(() => {})
}
</script>
<style lang="scss" scoped>
.student-term-details {
display: flex;
flex-direction: column;
gap: 0.5rem;
&__heading {
margin-bottom: 0.75rem;
}
&__list-title {
margin-top: 0.5rem;
}
}
</style>
@@ -0,0 +1,128 @@
<template>
<div class="student-terms">
<BoxedIconTitleBlock
class="student-terms__heading"
title="دوره‌های آموزشی"
desc="دوره‌ها و آموزش‌های گذرانده‌شده خود را مشاهده کنید."
>
<template #icon>
<SvgIcon name="book" :size="24" color="var(--color-primary)" />
</template>
</BoxedIconTitleBlock>
<TabsBlock :tabs="tabs" v-model="activeTab">
<template #current>
<SkeletonLoaderBlock v-if="currentPending" :rows="4" :cols-per-row="1" />
<div v-else-if="currentTerms.length > 0">
<StudentTermItem
v-for="term in currentTerms"
:key="term.id"
:term="term"
@show-details="onShowDetails"
/>
</div>
<NoItems v-else title="دوره‌ای نیست" desc="در حال حاضر دوره‌ای در حال گذراندن ندارید." />
<PaginationBlock :pagination="currentMeta" @update:page="setCurrentPage" />
</template>
<template #ended>
<SkeletonLoaderBlock v-if="endedPending" :rows="4" :cols-per-row="1" />
<div v-else-if="endedTerms.length > 0">
<StudentTermItem
v-for="term in endedTerms"
:key="term.id"
:term="term"
:showDetails="false"
@show-details="onShowDetails"
/>
</div>
<NoItems v-else title="موردی نیست" desc="هنوز دوره‌ای را تمام نکرده‌اید." />
<PaginationBlock :pagination="endedMeta" @update:page="setEndedPage" />
</template>
</TabsBlock>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import TabsBlock from '@/components/blocks/TabsBlock.vue'
import { usePagination } from '@/composables/usePagination'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import { useStudentTermsListQuery } from '@/services/query/student-terms'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import StudentTermItem from '@/features/student/terms/components/StudentTermItem.vue'
const router = useRouter()
const tabs = [
{ name: 'current', label: 'دوره های در حال گذراندن', icon: 'list-bullets' },
{ name: 'ended', label: 'دوره های تکمیل شده', icon: 'list-bullets' },
]
const activeTab = ref('current')
const currentFilters = ref({ status: 'current' })
const endedFilters = ref({ status: 'ended' })
const { pagination: currentPagination, setPage: setCurrentPage } = usePagination({
page: 1,
perPage: 10,
})
const { pagination: endedPagination, setPage: setEndedPage } = usePagination({
page: 1,
perPage: 10,
})
const { data: currentData, isLoading: currentPending } = useStudentTermsListQuery(
currentFilters,
currentPagination,
{
enabled: () => activeTab.value === 'current',
keepPreviousData: true,
}
)
const { data: endedData, isLoading: endedPending } = useStudentTermsListQuery(
endedFilters,
endedPagination,
{
enabled: () => activeTab.value === 'ended',
keepPreviousData: true,
}
)
const currentTerms = computed(() => currentData.value?.data ?? [])
const endedTerms = computed(() => endedData.value?.data ?? [])
const currentMeta = computed(() => ({
page: currentPagination.value.page,
perPage: currentPagination.value.perPage,
...currentData.value?.meta,
}))
const endedMeta = computed(() => ({
page: endedPagination.value.page,
perPage: endedPagination.value.perPage,
...endedData.value?.meta,
}))
const onShowDetails = (term) => {
router.push({ name: 'student-term-details', params: { id: term.id } }).catch(() => {})
}
</script>
<style lang="scss" scoped>
.student-terms {
display: flex;
flex-direction: column;
gap: 0.5rem;
&__heading {
margin-bottom: 0.75rem;
}
}
</style>
+137
View File
@@ -0,0 +1,137 @@
<template>
<span
class="badge"
:class="[`badge--${variant}`, `badge--${size}`, { 'badge--clickable': clickable }]"
:role="clickable ? 'button' : undefined"
@click="onClick"
>
<span v-if="dot" class="badge__dot" />
<SvgIcon v-if="icon" :name="icon" :size="iconSize" class="badge__icon" :color="iconColor" />
<slot name="prepend" />
<span v-if="label" class="badge__label">{{ label }}</span>
<span v-if="$slots.default || value" class="badge__value">
<slot>{{ value }}</slot>
</span>
<slot name="append" />
</span>
</template>
<script setup>
import { computed } from 'vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
const props = defineProps({
/** @type {import('vue').PropType<'neutral' | 'success' | 'danger' | 'warning' | 'primary' | 'info'>} */
variant: { type: String, default: 'neutral' },
/** @type {import('vue').PropType<'sm' | 'md' | 'lg'>} */
size: { type: String, default: 'md' },
label: { type: String, default: '' },
value: { type: [String, Number], default: '' },
/** @type {import('vue').PropType<import('@/components/icons/icon-names').IconName | ''>} */
icon: { type: String, default: '' },
dot: { type: Boolean, default: false },
clickable: { type: Boolean, default: false },
})
const emit = defineEmits(['click'])
const ICON_SIZE_MAP = { sm: 11, md: 14, lg: 16 }
const iconSize = computed(() => ICON_SIZE_MAP[props.size] ?? 14)
const iconColor = computed(() => 'currentColor')
const onClick = (event) => {
if (!props.clickable) return
emit('click', event)
}
</script>
<style lang="scss" scoped>
.badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
border-radius: 0.75rem;
font-family: var(--font-family-fa);
white-space: nowrap;
line-height: 1.5;
transition: filter 0.15s ease;
&--clickable {
cursor: pointer;
&:hover {
filter: brightness(0.96);
}
}
&__dot {
width: 0.4em;
height: 0.4em;
border-radius: 9999px;
background: currentcolor;
flex-shrink: 0;
}
&__icon {
flex-shrink: 0;
}
&__label {
font-weight: 300;
color: inherit;
opacity: 0.85;
}
&__value {
font-weight: 500;
}
&--sm {
padding: 0.2rem 0.625rem;
font-size: 0.65rem;
gap: 0.3rem;
border-radius: 0.625rem;
}
&--md {
padding: 0.3rem 0.875rem;
font-size: 0.75rem;
}
&--lg {
padding: 0.4rem 1rem;
font-size: 0.875rem;
border-radius: 0.875rem;
}
&--neutral {
background: rgba(107, 107, 107, 5%);
color: #535353;
}
&--success {
background: rgba(0, 153, 76, 10%);
color: #00994c;
}
&--danger {
background: rgba(243, 102, 117, 8%);
color: #cc2831;
}
&--warning {
background: rgba(204, 154, 40, 10%);
color: #cc6f00;
}
&--primary {
background: rgba(0, 112, 116, 8%);
color: #007074;
}
&--info {
background: rgba(104, 104, 104, 10%);
color: #686868;
}
}
</style>
+120
View File
@@ -0,0 +1,120 @@
<template>
<div class="my-tinymce">
<Editor v-model="contentValue" :init="myInit" />
</div>
</template>
<script setup>
import { onMounted, toRefs, ref, reactive, defineProps, defineEmits, watch } from 'vue'
import axios from 'axios'
// Introducing tinymce editor
import Editor from '@tinymce/tinymce-vue'
import tinymce from 'tinymce/tinymce' // tinymce defaults to hidden. If it is not introduced, the editor will not be displayed.
// Import configuration file
import '@/plugins/tinymce/importTinymce'
import { initTiny } from '@/plugins/tinymce/tinymce'
const props = defineProps({
modelValue: {
type: String,
default: '',
},
// placeholder
placeholder: {
type: String,
default: 'Please enter content',
},
// Default style
style: {
type: Object,
default: () => {
return { width: '100%', heigth: '400' }
},
},
// image upload server address
imgUploadUrl: {
type: String,
default: '/api/v1/media',
},
batchId: {
type: String,
},
})
const emit = defineEmits(['update:modelValue'])
// Parameter custom initialization
const customer = (init) => {
// Allow the outside world to pass in height and placeholder
init.height = props.style.heigth
init.placeholder = props.placeholder
// Paste pictures and automatically process base64
init.urlconverter_callback = (url, node, onSave, name) => {
if (node === 'img' && url.startsWith('blob:')) {
tinymce.activeEditor && tinymce.activeEditor.uploadImages()
}
return url
}
// upload picture
init.images_upload_handler = (blobInfo, success, failure) => {
imgUploadFn(blobInfo, success, failure)
}
return init
}
// const myInit = ref(customer(initTiny(props.batchId)));
const state = reactive({
myInit: ref(customer(initTiny(props.batchId))),
contentValue: props.modelValue, // binding text
timeout: null,
})
onMounted(() => {
tinymce.init({})
})
// Listen for text changes and pass them to the outside world
watch(
() => state.contentValue,
(n) => {
debounce(() => {
emit('update:modelValue', state.contentValue)
})
}
)
// Listen to the default value. The first time a v-model is passed in from the outside world, it is assigned to contentValue.
watch(
() => props.modelValue,
(n) => {
if (n && n !== state.contentValue) {
state.contentValue = n
}
}
)
const debounce = (fn, wait = 400) => {
if (state.timeout !== null) {
clearTimeout(state.timeout)
}
state.timeout = setTimeout(fn, wait)
}
const imgUploadFn = async (blobInfo, success, failure) => {
// Can limit image size
// if (blobInfo.blob().size / 1024 / 1024 > 2) {
// failure('Upload failed, please control the image size within 2M')
// } else {}
const formData = new FormData()
formData.append('file', blobInfo.blob(), blobInfo.filename())
formData.append('batch_id', props.batchId)
const response = await axios.post("https://api.madomotor.ir".props.imgUploadUrl, formData)
if (response && response.status == 200) {
return success(response.data.data.url)
}
return failure(`HTTP Error: ${response.status}`)
}
const { myInit, contentValue, timeout } = toRefs(state)
</script>
<style></style>
+36
View File
@@ -65,6 +65,42 @@ const menuItems = computed(() => [
to: { name: 'student-courses' },
active: ['student-courses', 'student-course-details'].includes(route.name),
},
{
title: 'دوره‌های آموزشی',
icon: 'book',
to: { name: 'student-terms' },
active: [
'student-terms',
'student-term-details',
'student-lesson-details',
'student-session-details',
'student-exam',
].includes(route.name),
},
{
title: 'سامانه اعزام',
icon: 'paper-plane-right',
to: { name: 'student-missionary' },
active: route.name === 'student-missionary',
},
{
title: 'خدمات',
icon: 'paper-plane',
to: { name: 'student-services' },
active: route.name === 'student-services',
},
{
title: 'مشاوره',
icon: 'chat',
to: { name: 'student-consultants' },
active: route.name === 'student-consultants',
},
{
title: 'گواهینامه ها',
icon: 'scroll',
to: { name: 'student-certificates' },
active: route.name === 'student-certificates',
},
{
title: 'ویرایش پروفایل',
icon: 'user',
+1 -5
View File
@@ -1,5 +1,5 @@
<template>
<aside class="student-sidebar" :style="bgStyle">
<aside class="student-sidebar">
<div class="student-sidebar__logo">
<img :src="logo" alt="banu" />
</div>
@@ -22,7 +22,6 @@
</template>
<script setup>
import { computed } from 'vue'
import { gallery } from '@/utils/gallery'
import SvgIcon from '@/components/icons/SvgIcon.vue'
@@ -31,9 +30,6 @@ defineProps({
})
const logo = gallery.logoWhite
const bgStyle = computed(() => ({
backgroundImage: `url(${gallery.loginPattern})`,
}))
</script>
<style lang="scss" scoped>
+35
View File
@@ -0,0 +1,35 @@
// //tinymce
// import tinymce from "tinymce/tinymce";
import "tinymce/tinymce";
import "tinymce/themes/silver";
import "tinymce/icons/default";
import "tinymce/skins/ui/oxide/skin.css";
// Vue.use(tinymce);
// skin css /lightgray/skin.min.css
import "tinymce/skins/ui/oxide/content.min.css";
import "tinymce/skins/ui/oxide/skin.min.css";
// //plugins
import "tinymce/plugins/textcolor";
import "tinymce/plugins/colorpicker";
import "tinymce/plugins/advlist";
import "tinymce/plugins/autolink";
import "tinymce/plugins/lists";
import "tinymce/plugins/paste";
import "tinymce/plugins/link";
import "tinymce/plugins/preview";
import "tinymce/plugins/anchor";
import "tinymce/plugins/charmap";
import "tinymce/plugins/print";
import "tinymce/plugins/image";
import "tinymce/plugins/searchreplace";
import "tinymce/plugins/directionality";
import "tinymce/plugins/wordcount";
// import "tinymce/plugins/help";
import "tinymce/plugins/table";
import "tinymce/plugins/media";
import "tinymce/plugins/insertdatetime";
import "tinymce/plugins/fullscreen";
import "tinymce/plugins/code";
import "tinymce/plugins/visualblocks";
import "@/plugins/tinymce/langs/fa_IR";
import "@/styles/plugins/tinymce.css";
+392
View File
@@ -0,0 +1,392 @@
import tinymce from "tinymce/tinymce";
tinymce.addI18n('fa_IR',{
"Redo": "\u0628\u0627\u0632\u0627\u0646\u062c\u0627\u0645",
"Undo": "\u0648\u0627\u06af\u0631\u062f",
"Cut": "\u0628\u0631\u0634",
"Copy": "\u06a9\u067e\u06cc",
"Paste": "\u0686\u0633\u0628\u0627\u0646\u062f\u0646",
"Select all": "\u0627\u0646\u062a\u062e\u0627\u0628 \u0647\u0645\u0647",
"New document": "\u0633\u0646\u062f \u062c\u062f\u06cc\u062f",
"Ok": "\u062a\u0623\u06cc\u06cc\u062f",
"Cancel": "\u0644\u063a\u0648",
"Visual aids": "\u06a9\u0645\u06a9\u200c\u0647\u0627\u06cc \u0628\u0635\u0631\u06cc",
"Bold": "\u067e\u0631\u0631\u0646\u06af",
"Italic": "\u06a9\u062c",
"Underline": "\u0632\u06cc\u0631 \u062e\u0637 \u062f\u0627\u0631",
"Strikethrough": "\u062e\u0637 \u0632\u062f\u0646",
"Superscript": "\u0628\u0627\u0644\u0627\u0646\u06af\u0627\u0634\u062a",
"Subscript": "\u0632\u06cc\u0631\u0646\u06af\u0627\u0634\u062a",
"Clear formatting": "\u067e\u0627\u06a9 \u06a9\u0631\u062f\u0646 \u0642\u0627\u0644\u0628\u200c\u0628\u0646\u062f\u06cc",
"Align left": "\u062a\u0631\u0627\u0632\u0628\u0646\u062f\u06cc \u0627\u0632 \u0686\u067e",
"Align center": "\u062a\u0631\u0627\u0632\u0628\u0646\u062f\u06cc \u0627\u0632 \u0648\u0633\u0637",
"Align right": "\u062a\u0631\u0627\u0632\u0628\u0646\u062f\u06cc \u0627\u0632 \u0631\u0627\u0633\u062a",
"Justify": "\u062a\u0631\u0627\u0632\u0628\u0646\u062f\u06cc \u062f\u0648\u0637\u0631\u0641\u0647",
"Bullet list": "\u0641\u0647\u0631\u0633\u062a \u0646\u0634\u0627\u0646\u0647\u200c\u062f\u0627\u0631",
"Numbered list": "\u0641\u0647\u0631\u0633\u062a \u0634\u0645\u0627\u0631\u0647\u200c\u062f\u0627\u0631",
"Decrease indent": "\u06a9\u0627\u0647\u0634 \u062a\u0648\u0631\u0641\u062a\u06af\u06cc",
"Increase indent": "\u0627\u0641\u0632\u0627\u06cc\u0634 \u062a\u0648\u0631\u0641\u062a\u06af\u06cc",
"Close": "\u0628\u0633\u062a\u0646",
"Formats": "\u0642\u0627\u0644\u0628\u200c\u0628\u0646\u062f\u06cc\u200c\u0647\u0627",
"Your browser doesn't support direct access to the clipboard. Please use the Ctrl+X\/C\/V keyboard shortcuts instead.": "\u0645\u0631\u0648\u0631\u06af\u0631 \u0634\u0645\u0627 \u0627\u0632 \u062f\u0633\u062a\u0631\u0633\u06cc \u0645\u0633\u062a\u0642\u06cc\u0645 \u0628\u0647 \u06a9\u0644\u06cc\u067e\u200c\u0628\u0648\u0631\u062f \u067e\u0634\u062a\u06cc\u0628\u0627\u0646\u06cc \u0646\u0645\u06cc\u200c\u06a9\u0646\u062f\u060c \u0644\u0637\u0641\u0627\u064b \u0627\u0632 \u0645\u06cc\u0627\u0646\u0628\u0631\u0647\u0627\u06cc Ctrl+X\/C\/V \u0635\u0641\u062d\u0647 \u06a9\u0644\u06cc\u062f \u0627\u0633\u062a\u0641\u0627\u062f\u0647 \u06a9\u0646\u06cc\u062f.",
"Headers": "\u0633\u0631\u0628\u0631\u06af\u200c\u0647\u0627",
"Header 1": "\u0633\u0631\u0628\u0631\u06af 1",
"Header 2": "\u0633\u0631\u0628\u0631\u06af 2",
"Header 3": "\u0633\u0631\u0628\u0631\u06af 3",
"Header 4": "\u0633\u0631\u0628\u0631\u06af 4",
"Header 5": "\u0633\u0631\u0628\u0631\u06af 5",
"Header 6": "\u0633\u0631\u0628\u0631\u06af 6",
"Headings": "\u0633\u0631\u0641\u0635\u0644\u200c\u0647\u0627",
"Heading 1": "\u0633\u0631\u0641\u0635\u0644\u200c 1",
"Heading 2": "\u0633\u0631\u0641\u0635\u0644 2",
"Heading 3": "\u0633\u0631\u0641\u0635\u0644 3",
"Heading 4": "\u0633\u0631\u0641\u0635\u0644 4",
"Heading 5": "\u0633\u0631\u0641\u0635\u0644 5",
"Heading 6": "\u0633\u0631\u0641\u0635\u0644 6",
"Preformatted": "\u0627\u0632 \u067e\u06cc\u0634 \u0642\u0627\u0644\u0628\u200c\u0628\u0646\u062f\u06cc\u200c\u0634\u062f\u0647",
"Div": "\u0628\u062e\u0634",
"Pre": "\u067e\u06cc\u0634",
"Code": "\u06a9\u062f",
"Paragraph": "\u067e\u0627\u0631\u0627\u06af\u0631\u0627\u0641",
"Blockquote": "\u0646\u0642\u0644 \u0642\u0648\u0644 \u0628\u0644\u0648\u06a9\u06cc",
"Inline": "\u0647\u0645\u200c\u0631\u0627\u0633\u062a\u0627",
"Blocks": "\u0628\u0644\u0648\u06a9\u200c\u0647\u0627",
"Paste is now in plain text mode. Contents will now be pasted as plain text until you toggle this option off.": "\u0627\u0645\u06a9\u0627\u0646 \u0686\u0633\u0628\u0627\u0646\u062f\u0646\u060c \u062f\u0631 \u062d\u0627\u0644\u062a \u0645\u062a\u0646 \u062e\u0627\u0644\u0635 \u062a\u0646\u0638\u06cc\u0645 \u06af\u0634\u062a\u0647. \u062a\u0627 \u0632\u0645\u0627\u0646 \u062a\u063a\u06cc\u06cc\u0631 \u0627\u06cc\u0646 \u062d\u0627\u0644\u062a\u060c \u0645\u062d\u062a\u0648\u0627\u06cc \u0645\u0648\u0631\u062f \u0686\u0633\u0628\u0627\u0646\u062f\u0646\u060c \u0628\u0647 \u0635\u0648\u0631\u062a \u0645\u062a\u0646 \u062e\u0627\u0644\u0635 \u062e\u0648\u0627\u0647\u062f \u0686\u0633\u0628\u06cc\u062f.",
"Fonts": "\u0641\u0648\u0646\u062a\u200c\u200c\u0647\u0627",
"Font Sizes": "\u0627\u0646\u062f\u0627\u0632\u0647\u0654 \u0641\u0648\u0646\u062a",
"Class": "\u0637\u0628\u0642\u0647",
"Browse for an image": "\u06af\u0634\u062a\u0646 \u0628\u0631\u0627\u06cc \u0639\u06a9\u0633 \u0645\u0648\u0631\u062f \u0646\u0638\u0631",
"OR": "\u06cc\u0627",
"Drop an image here": "\u062a\u0635\u0648\u06cc\u0631 \u0645\u0648\u0631\u062f \u0646\u0638\u0631 \u0631\u0627 \u0627\u06cc\u0646\u062c\u0627 \u0631\u0647\u0627 \u06a9\u0646\u06cc\u062f",
"Upload": "\u0622\u067e\u0644\u0648\u062f",
"Block": "\u0628\u0644\u0648\u06a9",
"Align": "\u062a\u0631\u0627\u0632\u0628\u0646\u062f\u06cc",
"Default": "\u067e\u06cc\u0634 \u0641\u0631\u0636",
"Circle": "\u062f\u0627\u06cc\u0631\u0647",
"Disc": "\u062f\u0627\u06cc\u0631\u0647\u0621 \u062a\u0648\u067e\u0631",
"Square": "\u0686\u0647\u0627\u0631 \u06af\u0648\u0634",
"Lower Alpha": "\u062d\u0631\u0648\u0641 \u06a9\u0648\u0686\u06a9",
"Lower Greek": "\u062d\u0631\u0648\u0641 \u06a9\u0648\u0686\u06a9 \u06cc\u0648\u0646\u0627\u0646\u06cc",
"Lower Roman": "\u0627\u0631\u0642\u0627\u0645 \u06a9\u0648\u0686\u06a9 \u0631\u0648\u0645\u06cc",
"Upper Alpha": "\u062d\u0631\u0648\u0641 \u0628\u0632\u0631\u06af",
"Upper Roman": "\u0627\u0631\u0642\u0627\u0645 \u0628\u0632\u0631\u06af \u0631\u0648\u0645\u06cc",
"Anchor...": "\u0642\u0644\u0627\u0628...",
"Name": "\u0646\u0627\u0645",
"Id": "\u0634\u0646\u0627\u0633\u0647",
"Id should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores.": "\u0634\u0646\u0627\u0633\u0647 \u0645\u06cc \u0628\u0627\u06cc\u0633\u062a \u0628\u0627 \u06cc\u06a9 \u062d\u0631\u0641 \u0627\u0644\u0641\u0628\u0627 \u0622\u063a\u0627\u0632 \u0648 \u0628\u0627 \u062f\u0646\u0628\u0627\u0644\u0647 \u0627\u06cc \u0627\u0632 \u062d\u0631\u0648\u0641\u060c \u0627\u0639\u062f\u0627\u062f\u060c \u0639\u0644\u0627\u0645\u062a \u0645\u0650\u0646\u0647\u0627\u060c \u0646\u0642\u0637\u0647\u060c \u062f\u0648 \u0646\u0642\u0637\u0647 \u06cc\u0627 \u062e\u0637 \u062a\u06cc\u0631\u0647 \u0627\u062f\u0627\u0645\u0647 \u06cc\u0627\u0628\u062f.",
"You have unsaved changes are you sure you want to navigate away?": "\u062a\u063a\u06cc\u06cc\u0631\u0627\u062a \u0634\u0645\u0627 \u0630\u062e\u06cc\u0631\u0647 \u0646\u0634\u062f\u0647 \u0627\u0646\u062f\u060c \u0622\u06cc\u0627 \u062c\u0647\u062a \u062e\u0631\u0648\u062c \u0627\u0637\u0645\u06cc\u0646\u0627\u0646 \u062f\u0627\u0631\u06cc\u062f\u061f",
"Restore last draft": "\u0628\u0627\u0632\u06cc\u0627\u0628\u06cc \u0622\u062e\u0631\u06cc\u0646 \u067e\u06cc\u0634 \u0646\u0648\u06cc\u0633",
"Special characters...": "\u0646\u0648\u06cc\u0633\u0647\u200c\u0647\u0627\u06cc \u0648\u06cc\u0698\u0647...",
"Source code": "\u0645\u062a\u0646 \u06a9\u062f \u0645\u0646\u0628\u0639",
"Insert\/Edit code sample": "\u062f\u0631\u062c\/\u0648\u06cc\u0631\u0627\u06cc\u0634 \u0646\u0645\u0648\u0646\u0647\u0621 \u06a9\u062f",
"Language": "\u0632\u0628\u0627\u0646",
"Code sample...": "\u0646\u0645\u0648\u0646\u0647 \u06a9\u062f...",
"Color Picker": "\u0627\u0646\u062a\u062e\u0627\u0628\u200c\u06a9\u0646\u0646\u062f\u0647 \u0631\u0646\u06af",
"R": "\u0642\u0631\u0645\u0632",
"G": "\u0633\u0628\u0632",
"B": "\u0622\u0628\u06cc",
"Left to right": "\u0686\u067e \u0628\u0647 \u0631\u0627\u0633\u062a",
"Right to left": "\u0631\u0627\u0633\u062a \u0628\u0647 \u0686\u067e",
"Emoticons...": "\u0635\u0648\u0631\u062a\u06a9\u200c\u0647\u0627...",
"Metadata and Document Properties": "\u0641\u0631\u0627\u062f\u0627\u062f\u0647 \u0648 \u0645\u0634\u062e\u0635\u0627\u062a \u0633\u0646\u062f",
"Title": "\u0639\u0646\u0648\u0627\u0646",
"Keywords": "\u0648\u0627\u0698\u06af\u0627\u0646 \u06a9\u0644\u06cc\u062f\u06cc",
"Description": "\u062a\u0648\u0636\u06cc\u062d",
"Robots": "\u0631\u0648\u0628\u0627\u062a\u0647\u0627",
"Author": "\u0645\u0648\u0644\u0641",
"Encoding": "\u06a9\u062f\u06af\u0632\u0627\u0631\u06cc \u0645\u062a\u0646",
"Fullscreen": "\u062a\u0645\u0627\u0645 \u0635\u0641\u062d\u0647",
"Action": "\u0639\u0645\u0644",
"Shortcut": "\u0645\u06cc\u0627\u0646\u0628\u064f\u0631",
"Help": "\u0631\u0627\u0647\u0646\u0645\u0627",
"Address": "\u0646\u0634\u0627\u0646\u06cc",
"Focus to menubar": "\u062a\u0645\u0631\u06a9\u0632 \u0628\u0631 \u0646\u0648\u0627\u0631 \u0645\u0646\u0648",
"Focus to toolbar": "\u062a\u0645\u0631\u06a9\u0632 \u0628\u0631 \u0646\u0648\u0627\u0631 \u0627\u0628\u0632\u0627\u0631",
"Focus to element path": "\u062a\u0645\u0631\u06a9\u0632 \u0628\u0631 \u0645\u0633\u06cc\u0631 \u0627\u0650\u0644\u0650\u0645\u0627\u0646",
"Focus to contextual toolbar": "\u062a\u0645\u0631\u06a9\u0632 \u0628\u0631 \u0646\u0648\u0627\u0631 \u0627\u0628\u0632\u0627\u0631 \u0645\u062a\u0646\u06cc",
"Insert link (if link plugin activated)": "\u062f\u0631\u062c \u067e\u06cc\u0648\u0646\u062f (\u0627\u06af\u0631 \u0627\u0641\u0632\u0648\u0646\u0647\u0621 \u067e\u06cc\u0648\u0646\u062f \u0641\u0639\u0627\u0644 \u0634\u062f)",
"Save (if save plugin activated)": "\u062b\u0628\u062a\u00a0(\u0627\u06af\u0631 \u0627\u0641\u0632\u0648\u0646\u0647\u0621 \u0630\u062e\u06cc\u0631\u0647 \u0633\u0627\u0632\u06cc \u0641\u0639\u0627\u0644 \u0634\u062f)",
"Find (if searchreplace plugin activated)": "\u06cc\u0627\u0641\u062a\u0646 (\u0627\u06af\u0631 \u0627\u0641\u0632\u0648\u0646\u0647\u0621 \u062c\u0633\u062a\u062c\u0648\/\u062c\u0627\u06cc\u06af\u0632\u06cc\u0646\u06cc \u0641\u0639\u0627\u0644 \u0634\u062f)",
"Plugins installed ({0}):": "\u0627\u0641\u0632\u0648\u0646\u0647 \u0647\u0627\u06cc\u06cc \u06a9\u0647 \u0646\u0635\u0628 \u0634\u062f\u0646\u062f ({0}):",
"Premium plugins:": "\u0627\u0641\u0632\u0648\u0646\u0647 \u0647\u0627\u06cc \u0645\u062e\u0635\u0648\u0635:",
"Learn more...": "\u06cc\u0627\u062f\u06af\u06cc\u0631\u06cc \u0628\u06cc\u0634\u062a\u0631...",
"You are using {0}": "\u0634\u0645\u0627 \u062f\u0631 \u062d\u0627\u0644 \u0627\u0633\u062a\u0641\u0627\u062f\u0647 \u0627\u0632 {0} \u0645\u06cc \u0628\u0627\u0634\u06cc\u062f",
"Plugins": "\u0627\u0641\u0632\u0648\u0646\u0647 \u0647\u0627",
"Handy Shortcuts": "\u0645\u06cc\u0627\u0646\u0628\u064f\u0631\u0647\u0627\u06cc \u0633\u0648\u062f\u0645\u0646\u062f",
"Horizontal line": "\u062e\u0637 \u0627\u0641\u0642\u06cc",
"Insert\/edit image": "\u062f\u0631\u062c\/\u0648\u06cc\u0631\u0627\u06cc\u0634 \u062a\u0635\u0648\u06cc\u0631",
"Image description": "\u062a\u0648\u0635\u06cc\u0641 \u062a\u0635\u0648\u06cc\u0631",
"Source": "\u0645\u0646\u0628\u0639",
"Dimensions": "\u0627\u0628\u0639\u0627\u062f",
"Constrain proportions": "\u062d\u0641\u0638 \u062a\u0646\u0627\u0633\u0628",
"General": "\u0639\u0645\u0648\u0645\u06cc",
"Advanced": "\u067e\u06cc\u0634\u0631\u0641\u062a\u0647",
"Style": "\u0633\u0628\u06a9",
"Vertical space": "\u0641\u0636\u0627\u06cc \u0639\u0645\u0648\u062f\u06cc",
"Horizontal space": "\u0641\u0636\u0627\u06cc \u0627\u0641\u0642\u06cc",
"Border": "\u0644\u0628\u0647",
"Insert image": "\u062f\u0631\u062c \u062a\u0635\u0648\u06cc\u0631",
"Image...": "\u062a\u0635\u0648\u06cc\u0631...",
"Image list": "\u0641\u0647\u0631\u0633\u062a \u062a\u0635\u0648\u06cc\u0631\u06cc",
"Rotate counterclockwise": "\u062f\u064e\u0648\u064e\u0631\u0627\u0646 \u067e\u0627\u062f \u0633\u0627\u0639\u062a \u06af\u0631\u062f",
"Rotate clockwise": "\u062f\u064e\u0648\u064e\u0631\u0627\u0646 \u0633\u0627\u0639\u062a \u06af\u0631\u062f",
"Flip vertically": "\u0642\u0631\u06cc\u0646\u0647 \u0639\u0645\u0648\u062f\u06cc",
"Flip horizontally": "\u0642\u0631\u06cc\u0646\u0647 \u0627\u0641\u0642\u06cc",
"Edit image": "\u0648\u06cc\u0631\u0627\u0633\u062a \u062a\u0635\u0648\u06cc\u0631",
"Image options": "\u062a\u0646\u0638\u06cc\u0645\u0627\u062a \u062a\u0635\u0648\u06cc\u0631",
"Zoom in": "\u0628\u0632\u0631\u06af \u0646\u0645\u0627\u06cc\u06cc",
"Zoom out": "\u06a9\u0648\u0686\u06a9 \u0646\u0645\u0627\u06cc\u06cc",
"Crop": "\u0628\u064f\u0631\u0634 \u062f\u064f\u0648\u0631",
"Resize": "\u062a\u063a\u06cc\u06cc\u0631 \u0627\u0646\u062f\u0627\u0632\u0647",
"Orientation": "\u06af\u0650\u0631\u0627",
"Brightness": "\u0631\u0648\u0634\u0646\u0627\u06cc\u06cc",
"Sharpen": "\u0628\u0647\u0628\u0648\u062f \u0644\u0628\u0647",
"Contrast": "\u062a\u0636\u0627\u062f \u0631\u0646\u06af",
"Color levels": "\u0633\u0637\u0648\u062d \u0631\u0646\u06af",
"Gamma": "\u06af\u0627\u0645\u0627",
"Invert": "\u0628\u0631\u06af\u0634\u062a \u0631\u0646\u06af",
"Apply": "\u0627\u0650\u0639\u0645\u0627\u0644",
"Back": "\u0628\u0627\u0632\u06af\u0634\u062a",
"Insert date\/time": "\u062f\u0631\u062c \u062a\u0627\u0631\u06cc\u062e\/\u0632\u0645\u0627\u0646",
"Date\/time": "\u062a\u0627\u0631\u06cc\u062e\/\u0632\u0645\u0627\u0646",
"Insert\/Edit Link": "\u062f\u0631\u062c\/\u0648\u06cc\u0631\u0627\u06cc\u0634 \u067e\u06cc\u0648\u0646\u062f",
"Insert\/edit link": "\u062f\u0631\u062c\/\u0648\u06cc\u0631\u0627\u06cc\u0634 \u067e\u06cc\u0648\u0646\u062f",
"Text to display": "\u0645\u062a\u0646 \u0646\u0645\u0627\u06cc\u0634\u06cc",
"Url": "\u0622\u062f\u0631\u0633",
"Open link in...": "\u0628\u0627\u0632 \u06a9\u0631\u062f\u0646 \u067e\u06cc\u0648\u0646\u062f \u062f\u0631...",
"Current window": "\u067e\u0646\u062c\u0631\u0647 \u062c\u0627\u0631\u06cc",
"None": "\u0647\u06cc\u0686",
"New window": "\u067e\u0646\u062c\u0631\u0647\u0621 \u062c\u062f\u06cc\u062f",
"Remove link": "\u062d\u0630\u0641 \u067e\u06cc\u0648\u0646\u062f",
"Anchors": "\u0642\u0644\u0627\u0628 \u0647\u0627",
"Link...": "\u067e\u06cc\u0648\u0646\u062f...",
"Paste or type a link": "\u0686\u0633\u0628\u0627\u0646\u062f\u0646 \u06cc\u0627 \u062a\u0627\u06cc\u067e \u067e\u06cc\u0648\u0646\u062f",
"The URL you entered seems to be an email address. Do you want to add the required mailto: prefix?": "\u0628\u0647 \u0646\u0638\u0631 \u0645\u06cc \u0631\u0633\u062f \u0622\u062f\u0631\u0633 \u0648\u0631\u0648\u062f\u06cc \u06cc\u06a9 \u0631\u0627\u06cc\u0627\u0646\u0627\u0645\u0647 \u0628\u0627\u0634\u062f. \u0622\u06cc\u0627 \u062a\u0645\u0627\u06cc\u0644 \u0628\u0647 \u0627\u0641\u0632\u0648\u0631\u062f\u0646 \u067e\u06cc\u0634\u0648\u0646\u062f mailto: \u062f\u0627\u0631\u06cc\u062f\u061f",
"The URL you entered seems to be an external link. Do you want to add the required http:\/\/ prefix?": "\u0628\u0647 \u0646\u0638\u0631 \u0645\u06cc \u0631\u0633\u062f \u0622\u062f\u0631\u0633 \u0648\u0631\u0648\u062f\u06cc \u0627\u0631\u062c\u0627\u0639\u06cc \u0628\u0647 \u062e\u0627\u0631\u062c \u0627\u0632 \u0627\u06cc\u0646 \u0633\u0627\u06cc\u062a \u0645\u06cc \u0628\u0627\u0634\u062f. \u0622\u06cc\u0627 \u062a\u0645\u0627\u06cc\u0644 \u0628\u0647 \u0627\u0641\u0632\u0648\u0631\u062f\u0646 \u067e\u06cc\u0634\u0648\u0646\u062f http:\/\/ \u062f\u0627\u0631\u06cc\u062f\u061f",
"Link list": "\u0641\u0647\u0631\u0633\u062a \u067e\u06cc\u0648\u0646\u062f",
"Insert video": "\u062f\u0631\u062c \u0648\u06cc\u062f\u06cc\u0648",
"Insert\/edit video": "\u062f\u0631\u062c\/\u0648\u06cc\u0631\u0627\u06cc\u0634 \u0648\u06cc\u062f\u06cc\u0648",
"Insert\/edit media": "\u062f\u0631\u062c\/\u0648\u06cc\u0631\u0627\u06cc\u0634 \u0631\u0633\u0627\u0646\u0647",
"Alternative source": "\u0645\u0646\u0628\u0639 \u062c\u0627\u06cc\u06af\u0632\u06cc\u0646",
"Alternative source URL": "\u0646\u0634\u0627\u0646\u06cc \u0648\u0628 \u0645\u0646\u0628\u0639 \u062c\u0627\u06cc\u06af\u0632\u06cc\u0646",
"Media poster (Image URL)": "\u067e\u0648\u0633\u062a\u0631 \u0631\u0633\u0627\u0646\u0647 (\u0646\u0634\u0627\u0646\u06cc \u0648\u0628 \u062a\u0635\u0648\u06cc\u0631)",
"Paste your embed code below:": "\u0686\u0633\u0628\u0627\u0646\u062f\u0646 \u06a9\u062f \u062c\u0627\u0633\u0627\u0632\u06cc \u0634\u0645\u0627 \u062f\u0631 \u0632\u06cc\u0631: ",
"Embed": "\u062c\u0627\u0633\u0627\u0632\u06cc",
"Media...": "\u0631\u0633\u0627\u0646\u0647...",
"Nonbreaking space": "\u0641\u0636\u0627\u06cc \u062e\u0627\u0644\u06cc \u0628\u0631\u0634 \u0646\u0627\u067e\u0630\u06cc\u0631",
"Page break": "\u0628\u0631\u0634 \u0635\u0641\u062d\u0647",
"Paste as text": "\u0686\u0633\u0628\u0627\u0646\u062f\u0646 \u0645\u062a\u0646",
"Preview": "\u067e\u06cc\u0634 \u0646\u0645\u0627\u06cc\u0634",
"Print...": "\u0686\u0627\u067e...",
"Save": "\u0630\u062e\u06cc\u0631\u0647",
"Find": "\u062c\u0633\u062a\u062c\u0648",
"Replace with": "\u062c\u0627\u06cc\u06af\u0632\u06cc\u0646\u06cc \u0628\u0627",
"Replace": "\u062c\u0627\u06cc\u06af\u0632\u06cc\u0646\u06cc",
"Replace all": "\u062c\u0627\u06cc\u06af\u0632\u06cc\u0646 \u0647\u0645\u0647",
"Previous": "\u0642\u0628\u0644\u06cc",
"Next": "\u0628\u0639\u062f\u06cc",
"Find and replace...": "\u06cc\u0627\u0641\u062a\u0646 \u0648 \u062c\u0627\u06cc\u06af\u0632\u06cc\u0646 \u06a9\u0631\u062f\u0646...",
"Could not find the specified string.": "\u0631\u0634\u062a\u0647\u0621 \u0645\u0648\u0631\u062f \u0646\u0638\u0631 \u06cc\u0627\u0641\u062a \u0646\u06af\u0631\u062f\u06cc\u062f.",
"Match case": "\u062a\u0637\u0627\u0628\u0642 \u062d\u0631\u0648\u0641",
"Find whole words only": "\u06cc\u0627\u0641\u062a\u0646 \u062f\u0642\u06cc\u0642\u0627\u064b \u06a9\u0644 \u0648\u0627\u0698\u0647",
"Spell check": "\u0628\u0631\u0631\u0633\u06cc \u0627\u0645\u0644\u0627",
"Ignore": "\u0628\u06cc \u062e\u06cc\u0627\u0644",
"Ignore all": "\u0628\u06cc \u062e\u06cc\u0627\u0644 \u0647\u0645\u0647",
"Finish": "\u0627\u062a\u0645\u0627\u0645",
"Add to Dictionary": "\u0628\u0647 \u0648\u0627\u0698\u0647 \u0646\u0627\u0645\u0647 \u0628\u06cc \u0627\u0641\u0632\u0627",
"Insert table": "\u062f\u0631\u062c \u062c\u062f\u0648\u0644",
"Table properties": "\u062a\u0646\u0638\u06cc\u0645\u0627\u062a \u062c\u062f\u0648\u0644",
"Delete table": "\u062d\u0630\u0641 \u062c\u062f\u0648\u0644",
"Cell": "\u0633\u0644\u0648\u0644",
"Row": "\u0633\u0637\u0631",
"Column": "\u0633\u062a\u0648\u0646",
"Cell properties": "\u062a\u0646\u0638\u06cc\u0645\u0627\u062a \u0633\u0644\u0648\u0644",
"Merge cells": "\u067e\u06cc\u0648\u0646\u062f \u0633\u0644\u0648\u0644 \u0647\u0627",
"Split cell": "\u062c\u062f\u0627 \u0633\u0627\u0632\u06cc \u0633\u0644\u0648\u0644",
"Insert row before": "\u062f\u0631\u062c \u0633\u0637\u0631 \u062f\u0631 \u0628\u0627\u0644\u0627",
"Insert row after": "\u062f\u0631\u062c \u0633\u0637\u0631 \u062f\u0631 \u067e\u0627\u06cc\u06cc\u0646",
"Delete row": "\u062d\u0630\u0641 \u0633\u0637\u0631",
"Row properties": "\u062a\u0646\u0638\u06cc\u0645\u0627\u062a \u0633\u0637\u0631",
"Cut row": "\u0628\u0631\u0634 \u0633\u0637\u0631",
"Copy row": "\u0631\u0648\u0646\u0648\u06cc\u0633\u06cc \u0633\u0637\u0631",
"Paste row before": "\u0686\u0633\u0628\u0627\u0646\u062f\u0646 \u0633\u0637\u0631 \u062f\u0631 \u0628\u0627\u0644\u0627",
"Paste row after": "\u0686\u0633\u0628\u0627\u0646\u062f\u0646 \u0633\u0637\u0631 \u062f\u0631 \u067e\u0627\u06cc\u06cc\u0646",
"Insert column before": "\u062f\u0631\u062c \u0633\u062a\u0648\u0646 \u0642\u0628\u0644",
"Insert column after": "\u062f\u0631\u062c \u0633\u062a\u0648\u0646 \u0628\u0639\u062f",
"Delete column": "\u062d\u0630\u0641 \u0633\u062a\u0648\u0646",
"Cols": "\u0633\u062a\u0648\u0646 \u0647\u0627",
"Rows": "\u0633\u0637\u0631 \u0647\u0627",
"Width": "\u0639\u0631\u0636",
"Height": "\u0627\u0631\u062a\u0641\u0627\u0639",
"Cell spacing": "\u0641\u0627\u0635\u0644\u0647 \u0645\u06cc\u0627\u0646 \u0633\u0644\u0648\u0644\u06cc",
"Cell padding": "\u062d\u0627\u0634\u06cc\u0647 \u062f\u0631\u0648\u0646 \u0633\u0644\u0648\u0644\u06cc",
"Show caption": "\u0646\u0645\u0627\u06cc\u0634 \u0639\u0646\u0648\u0627\u0646",
"Left": "\u0686\u067e",
"Center": "\u0645\u06cc\u0627\u0646\u0647",
"Right": "\u0631\u0627\u0633\u062a",
"Cell type": "\u0646\u0648\u0639 \u0633\u0644\u0648\u0644",
"Scope": "\u062d\u0648\u0632\u0647",
"Alignment": "\u0647\u0645 \u062a\u0631\u0627\u0632\u06cc",
"H Align": "\u062a\u0631\u0627\u0632 \u0627\u0641\u0642\u06cc",
"V Align": "\u062a\u0631\u0627\u0632 \u0639\u0645\u0648\u062f\u06cc",
"Top": "\u0628\u0627\u0644\u0627",
"Middle": "\u0645\u06cc\u0627\u0646\u0647",
"Bottom": "\u067e\u0627\u06cc\u06cc\u0646",
"Header cell": "\u0633\u0644\u0648\u0644 \u0633\u0631 \u0633\u062a\u0648\u0646",
"Row group": "\u06af\u0631\u0648\u0647 \u0633\u0637\u0631\u06cc",
"Column group": "\u06af\u0631\u0648\u0647 \u0633\u062a\u0648\u0646\u06cc",
"Row type": "\u0646\u0648\u0639 \u0633\u0637\u0631",
"Header": "\u0633\u0631 \u0622\u0645\u062f",
"Body": "\u0628\u062f\u0646\u0647",
"Footer": "\u067e\u0627 \u0646\u0648\u0634\u062a",
"Border color": "\u0631\u0646\u06af \u0644\u0628\u0647",
"Insert template...": "\u062f\u0631\u062c \u0627\u0644\u06af\u0648...",
"Templates": "\u0627\u0644\u06af\u0648\u0647\u0627",
"Template": "\u0627\u0644\u06af\u0648",
"Text color": "\u0631\u0646\u06af \u0645\u062a\u0646",
"Background color": "\u0631\u0646\u06af \u067e\u0633 \u0632\u0645\u06cc\u0646\u0647",
"Custom...": "\u062f\u0644\u062e\u0648\u0627\u0647...",
"Custom color": "\u0631\u0646\u06af \u062f\u0644\u062e\u0648\u0627\u0647",
"No color": "\u0628\u062f\u0648\u0646 \u0631\u0646\u06af",
"Remove color": "\u062d\u0630\u0641 \u0631\u0646\u06af",
"Table of Contents": "\u0641\u0647\u0631\u0633\u062a \u0639\u0646\u0627\u0648\u06cc\u0646",
"Show blocks": "\u0646\u0645\u0627\u06cc\u0634 \u0628\u0644\u0648\u06a9 \u0647\u0627",
"Show invisible characters": "\u0646\u0645\u0627\u06cc\u0634 \u0646\u0648\u06cc\u0633\u0647 \u0647\u0627\u06cc \u0646\u0627\u067e\u06cc\u062f\u0627",
"Word count": "\u062a\u0639\u062f\u0627\u062f \u0648\u0627\u0698\u0647\u200c\u0647\u0627",
"Words: {0}": "\u0648\u0627\u0698\u0647 \u0647\u0627: {0}",
"{0} words": "{0} \u0648\u0627\u0698\u0647",
"File": "\u067e\u0631\u0648\u0646\u062f\u0647",
"Edit": "\u0648\u06cc\u0631\u0627\u06cc\u0634",
"Insert": "\u062f\u0631\u062c",
"View": "\u0646\u0645\u0627\u06cc\u0634",
"Format": "\u0642\u0627\u0644\u0628",
"Table": "\u062c\u062f\u0648\u0644",
"Tools": "\u0627\u0628\u0632\u0627\u0631\u0647\u0627",
"Powered by {0}": "\u062a\u0648\u0627\u0646 \u06af\u0631\u0641\u062a\u0647 \u0627\u0632 {0}",
"Rich Text Area. Press ALT-F9 for menu. Press ALT-F10 for toolbar. Press ALT-0 for help": "\u0646\u0627\u062d\u06cc\u0647 \u0645\u062a\u0646 \u063a\u0646\u06cc.\n\u062c\u0647\u062a \u0645\u0634\u0627\u0647\u062f\u0647 \u0645\u0646\u0648 \u0627\u0632 \u06a9\u0644\u06cc\u062f\u0647\u0627\u06cc \u062a\u0631\u06a9\u06cc\u0628\u06cc ALT + F9 \u0627\u0633\u062a\u0641\u0627\u062f\u0647 \u0646\u0645\u0627\u06cc\u06cc\u062f.\n\u062c\u0647\u062a \u0645\u0634\u0627\u0647\u062f\u0647 \u0646\u0648\u0627\u0631 \u0627\u0628\u0632\u0627\u0631 \u0627\u0632 \u06a9\u0644\u06cc\u062f\u0647\u0627\u06cc \u062a\u0631\u06a9\u06cc\u0628\u06cc ALT + F10 \u0627\u0633\u062a\u0641\u0627\u062f\u0647 \u0646\u0645\u0627\u06cc\u06cc\u062f.\n\u062c\u0647\u062a \u0645\u0634\u0627\u0647\u062f\u0647 \u0631\u0627\u0647\u0646\u0645\u0627 \u0627\u0632 \u06a9\u0644\u06cc\u062f\u0647\u0627\u06cc \u062a\u0631\u06a9\u06cc\u0628\u06cc ALT + 0 \u0627\u0633\u062a\u0641\u0627\u062f\u0647 \u0646\u0645\u0627\u06cc\u06cc\u062f.",
"Image title": "\u0639\u0646\u0648\u0627\u0646 \u062a\u0635\u0648\u06cc\u0631",
"Border width": "\u0639\u0631\u0636 \u062d\u0627\u0634\u06cc\u0647",
"Border style": "\u0633\u0628\u06a9 \u062d\u0627\u0634\u06cc\u0647",
"Error": "\u062e\u0637\u0627",
"Warn": "\u0647\u0634\u062f\u0627\u0631",
"Valid": "\u0645\u0639\u062a\u0628\u0631",
"To open the popup, press Shift+Enter": "\u062c\u0647\u062a \u0628\u0627\u0632 \u06a9\u0631\u062f\u0646 \u067e\u0646\u062c\u0631\u0647 \u0628\u0627\u0632\u0634\u0648\u060c \u06a9\u0644\u06cc\u062f\u0647\u0627\u06cc Shift + Enter \u0631\u0627 \u0641\u0634\u0627\u0631 \u062f\u0647\u06cc\u062f.",
"Rich Text Area. Press ALT-0 for help.": "\u0646\u0627\u062d\u06cc\u0647 \u0645\u062a\u0646 \u063a\u0646\u06cc. \u062c\u0647\u062a \u0645\u0634\u0627\u0647\u062f\u0647\u0654 \u0631\u0627\u0647\u0646\u0645\u0627 \u06a9\u0644\u06cc\u062f\u0647\u0627\u06cc ALT + 0 \u0631\u0627 \u0641\u0634\u0627\u0631 \u062f\u0647\u06cc\u062f.",
"System Font": "\u0641\u0648\u0646\u062a \u0633\u06cc\u0633\u062a\u0645\u06cc",
"Failed to upload image: {0}": "\u0639\u062f\u0645 \u0645\u0648\u0641\u0642\u06cc\u062a \u062f\u0631 \u0628\u0627\u0631\u06af\u0630\u0627\u0631\u06cc \u062a\u0635\u0648\u06cc\u0631: {0}",
"Failed to load plugin: {0} from url {1}": "\u0639\u062f\u0645 \u0645\u0648\u0641\u0642\u06cc\u062a \u062f\u0631 \u0628\u0627\u0631\u06af\u0630\u0627\u0631\u06cc \u0627\u0641\u0632\u0648\u0646\u0647: {0} \u0627\u0632 \u0646\u0634\u0627\u0646\u06cc \u0648\u0628 {1}",
"Failed to load plugin url: {0}": "\u0639\u062f\u0645 \u0645\u0648\u0641\u0642\u06cc\u062a \u062f\u0631 \u0628\u0627\u0631\u06af\u0630\u0627\u0631\u06cc \u0646\u0634\u0627\u0646\u06cc \u0648\u0628 \u0627\u0641\u0632\u0648\u0646\u0647: {0}",
"Failed to initialize plugin: {0}": "\u0639\u062f\u0645 \u0645\u0648\u0641\u0642\u06cc\u062a \u062f\u0631 \u0631\u0627\u0647\u200c\u0627\u0646\u062f\u0627\u0632\u06cc \u0627\u0641\u0632\u0648\u0646\u0647: {0}",
"example": "\u0645\u062b\u0627\u0644",
"Search": "\u062c\u0633\u062a\u062c\u0648",
"All": "\u0647\u0645\u0647",
"Currency": "\u0627\u0631\u0632",
"Text": "\u0645\u062a\u0646",
"Quotations": "\u0646\u0642\u0644\u200c\u0642\u0648\u0644\u200c\u0647\u0627",
"Mathematical": "\u0631\u06cc\u0627\u0636\u06cc",
"Extended Latin": "\u0644\u0627\u062a\u06cc\u0646 \u06af\u0633\u062a\u0631\u062f\u0647",
"Symbols": "\u0646\u0645\u0627\u062f\u0647\u0627",
"Arrows": "\u067e\u06cc\u06a9\u0627\u0646\u200c\u0647\u0627",
"User Defined": "\u0628\u0647 \u062e\u0648\u0627\u0633\u062a \u06a9\u0627\u0631\u0628\u0631",
"dollar sign": "\u0646\u0645\u0627\u062f \u062f\u0644\u0627\u0631",
"currency sign": "\u0646\u0645\u0627\u062f \u0627\u0631\u0632",
"euro-currency sign": "\u0646\u0645\u0627\u062f \u06cc\u0648\u0631\u0648",
"colon sign": "\u0646\u0645\u0627\u062f \u062f\u0648\u0646\u0642\u0637\u0647",
"cruzeiro sign": "\u0646\u0645\u0627\u062f \u06a9\u0631\u0648\u0632\u06cc\u0631\u0648",
"french franc sign": "\u0646\u0645\u0627\u062f \u0641\u0631\u0627\u0646\u06a9 \u0641\u0631\u0627\u0646\u0633\u0647",
"lira sign": "\u0646\u0645\u0627\u062f \u0644\u06cc\u0631\u0647",
"mill sign": "\u0646\u0645\u0627\u062f \u0645\u06cc\u0644",
"naira sign": "\u0646\u0645\u0627\u062f \u0646\u0627\u06cc\u0631\u0627",
"peseta sign": "\u0646\u0645\u0627\u062f \u067e\u0632\u062a\u0627",
"rupee sign": "\u0646\u0645\u0627\u062f \u0631\u0648\u067e\u06cc\u0647",
"won sign": "\u0646\u0645\u0627\u062f \u0648\u0648\u0646",
"new sheqel sign": "\u0646\u0645\u0627\u062f \u0634\u06a9\u0644 \u062c\u062f\u06cc\u062f",
"dong sign": "\u0646\u0645\u0627\u062f \u062f\u0627\u0646\u06af",
"kip sign": "\u0646\u0645\u0627\u062f \u06a9\u06cc\u067e",
"tugrik sign": "\u0646\u0645\u0627\u062f \u062a\u0648\u06af\u0631\u0648\u06af",
"drachma sign": "\u0646\u0645\u0627\u062f \u062f\u0631\u0627\u062e\u0645\u0627",
"german penny symbol": "\u0646\u0645\u0627\u062f \u067e\u0646\u06cc \u0622\u0644\u0645\u0627\u0646\u06cc",
"peso sign": "\u0646\u0645\u0627\u062f \u067e\u0632\u0648",
"guarani sign": "\u0646\u0645\u0627\u062f \u06af\u0648\u0627\u0631\u0627\u0646\u06cc",
"austral sign": "\u0646\u0645\u0627\u062f \u0622\u0633\u062a\u0631\u0627\u0644",
"hryvnia sign": "\u0646\u0645\u0627\u062f \u06af\u0631\u06cc\u0648\u0646\u0627",
"cedi sign": "\u0646\u0645\u0627\u062f \u0633\u062f\u06cc",
"livre tournois sign": "\u0646\u0645\u0627\u062f \u0644\u06cc\u0648\u0631\u0647 \u062a\u0648\u0631\u0646\u0648\u0627",
"spesmilo sign": "\u0646\u0645\u0627\u062f \u0627\u0633\u067e\u0633\u0645\u06cc\u0644\u0648",
"tenge sign": "\u0646\u0645\u0627\u062f \u062a\u0646\u06af\u0647",
"indian rupee sign": "\u0646\u0645\u0627\u062f \u0631\u0648\u067e\u06cc\u0647 \u0647\u0646\u062f\u06cc",
"turkish lira sign": "\u0646\u0645\u0627\u062f \u0644\u06cc\u0631\u0647 \u062a\u0631\u06a9\u06cc",
"nordic mark sign": "\u0646\u0645\u0627\u062f \u0645\u0627\u0631\u06a9 \u0646\u0631\u0648\u0698",
"manat sign": "\u0646\u0645\u0627\u062f \u0645\u0646\u0627\u062a",
"ruble sign": "\u0646\u0645\u0627\u062f \u0631\u0648\u0628\u0644",
"yen character": "\u0646\u0648\u06cc\u0633\u0647 \u06cc\u0646",
"yuan character": "\u0646\u0648\u06cc\u0633\u0647 \u06cc\u0648\u0627\u0646",
"yuan character, in hong kong and taiwan": "\u0646\u0648\u06cc\u0633\u0647 \u06cc\u0648\u0627\u0646\u060c \u062f\u0631 \u0647\u0646\u06af\u200c\u06a9\u0646\u06af \u0648 \u062a\u0627\u06cc\u0648\u0627\u0646",
"yen\/yuan character variant one": "\u0646\u0648\u06cc\u0633\u0647 \u062c\u0627\u06cc\u06af\u0632\u06cc\u0646 \u06cc\u0646\/\u06cc\u0648\u0627\u0646",
"Loading emoticons...": "\u062f\u0631 \u062d\u0627\u0644 \u0628\u0627\u0631\u06af\u0630\u0627\u0631\u06cc \u0634\u06a9\u0644\u06a9\u200c\u0647\u0627...",
"Could not load emoticons": "\u0634\u06a9\u0644\u06a9\u200c\u0647\u0627 \u0628\u0627\u0631\u06af\u0630\u0627\u0631\u06cc \u0646\u0634\u062f\u0646\u062f",
"People": "\u0627\u0641\u0631\u0627\u062f",
"Animals and Nature": "\u062d\u06cc\u0648\u0627\u0646\u0627\u062a \u0648 \u0637\u0628\u06cc\u0639\u062a",
"Food and Drink": "\u063a\u0630\u0627 \u0648 \u0646\u0648\u0634\u06cc\u062f\u0646\u06cc",
"Activity": "\u0641\u0639\u0627\u0644\u06cc\u062a",
"Travel and Places": "\u0633\u0641\u0631 \u0648 \u0627\u0645\u0627\u06a9\u0646",
"Objects": "\u0627\u0634\u06cc\u0627",
"Flags": "\u067e\u0631\u0686\u0645\u200c\u0647\u0627",
"Characters": "\u0646\u0648\u06cc\u0633\u0647\u200c\u0647\u0627",
"Characters (no spaces)": "\u0646\u0648\u06cc\u0633\u0647 \u0647\u0627 (\u0628\u062f\u0648\u0646 \u0641\u0627\u0635\u0644\u0647)",
"Error: Form submit field collision.": "\u062e\u0637\u0627: \u062a\u062f\u0627\u062e\u0644 \u062f\u0631 \u062b\u0628\u062a \u0641\u0631\u0645.",
"Error: No form element found.": "\u062e\u0637\u0627: \u0647\u06cc\u0686 \u0627\u0644\u0645\u0627\u0646 \u0641\u0631\u0645\u06cc \u06cc\u0627\u0641\u062a \u0646\u0634\u062f.",
"Update": "\u0628\u0647\u200c\u0631\u0648\u0632\u0631\u0633\u0627\u0646\u06cc",
"Color swatch": "\u0646\u0645\u0648\u0646\u0647 \u0631\u0646\u06af",
"Turquoise": "\u0641\u06cc\u0631\u0648\u0632\u0647\u200c\u0627\u06cc",
"Green": "\u0633\u0628\u0632",
"Blue": "\u0622\u0628\u06cc",
"Purple": "\u0628\u0646\u0641\u0634",
"Navy Blue": "\u0633\u0631\u0645\u0647\u200c\u0627\u06cc",
"Dark Turquoise": "\u0641\u06cc\u0631\u0648\u0632\u0647\u200c\u0627\u06cc \u062a\u06cc\u0631\u0647",
"Dark Green": "\u0633\u0628\u0632 \u062a\u06cc\u0631\u0647",
"Medium Blue": "\u0622\u0628\u06cc \u0633\u06cc\u0631",
"Medium Purple": "\u0622\u0628\u06cc \u0628\u0646\u0641\u0634",
"Midnight Blue": "\u0622\u0628\u06cc \u0646\u0641\u062a\u06cc",
"Yellow": "\u0632\u0631\u062f",
"Orange": "\u0646\u0627\u0631\u0646\u062c\u06cc",
"Red": "\u0642\u0631\u0645\u0632",
"Light Gray": "\u062e\u0627\u06a9\u0633\u062a\u0631\u06cc \u0631\u0648\u0634\u0646",
"Gray": "\u062e\u0627\u06a9\u0633\u062a\u0631\u06cc",
"Dark Yellow": "\u0632\u0631\u062f \u062a\u06cc\u0631\u0647",
"Dark Orange": "\u0646\u0627\u0631\u0646\u062c\u06cc \u062a\u06cc\u0631\u0647",
"Dark Red": "\u0642\u0631\u0645\u0632 \u062a\u06cc\u0631\u0647",
"Medium Gray": "\u062e\u0627\u06a9\u0633\u062a\u0631\u06cc \u0646\u06cc\u0645\u0647\u200c\u0631\u0648\u0634\u0646",
"Dark Gray": "\u062e\u0627\u06a9\u0633\u062a\u0631\u06cc \u062a\u06cc\u0631\u0647",
"Black": "\u0633\u06cc\u0627\u0647",
"White": "\u0633\u0641\u06cc\u062f",
"Switch to or from fullscreen mode": "\u062a\u063a\u06cc\u06cc\u0631 \u0627\u0632 \u062d\u0627\u0644\u062a \u062a\u0645\u0627\u0645\u200c\u0635\u0641\u062d\u0647 \u06cc\u0627 \u0628\u0647 \u062d\u0627\u0644\u062a \u062a\u0645\u0627\u0645\u200c\u0635\u0641\u062d\u0647",
"Open help dialog": "\u0628\u0627\u0632 \u06a9\u0631\u062f\u0646 \u06a9\u0627\u062f\u0631 \u0631\u0627\u0647\u0646\u0645\u0627",
"history": "\u062a\u0627\u0631\u06cc\u062e\u0686\u0647",
"styles": "\u0633\u0628\u06a9\u200c\u0647\u0627",
"formatting": "\u0642\u0627\u0644\u0628\u200c\u0628\u0646\u062f\u06cc",
"alignment": "\u062a\u0631\u0627\u0632\u0628\u0646\u062f\u06cc",
"indentation": "\u062a\u0648\u0631\u0641\u062a\u06af\u06cc",
"permanent pen": "\u0642\u0644\u0645 \u062f\u0627\u0626\u0645\u06cc",
"comments": "\u0646\u0638\u0631\u0627\u062a",
"Anchor": "\u0642\u0644\u0627\u0628",
"Special character": "\u0646\u0648\u06cc\u0633\u0647 \u0647\u0627\u06cc \u062e\u0627\u0635",
"Code sample": "\u0646\u0645\u0648\u0646\u0647 \u06a9\u064f\u062f",
"Color": "\u0631\u0646\u06af",
"Emoticons": "\u0635\u0648\u0631\u062a\u06a9 \u0647\u0627",
"Document properties": "\u062a\u0646\u0638\u06cc\u0645\u0627\u062a \u0633\u0646\u062f",
"Image": "\u062a\u0635\u0648\u06cc\u0631",
"Insert link": "\u062f\u0631\u062c \u067e\u06cc\u0648\u0646\u062f",
"Target": "\u0645\u0642\u0635\u062f",
"Link": "\u067e\u06cc\u0648\u0646\u062f",
"Poster": "\u067e\u0648\u0633\u062a\u0631",
"Media": "\u0631\u0633\u0627\u0646\u0647",
"Print": "\u0686\u0627\u067e",
"Prev": "\u0642\u0628\u0644\u06cc",
"Find and replace": "\u062c\u0633\u062a\u062c\u0648 \u0648 \u062c\u0627\u06cc\u06af\u0632\u06cc\u0646\u06cc",
"Whole words": "\u062a\u0645\u0627\u0645 \u0648\u0627\u0698\u06af\u0627\u0646",
"Spellcheck": "\u0628\u0631\u0631\u0633\u06cc \u0627\u0645\u0644\u0627\u0621",
"Caption": "\u0639\u0646\u0648\u0627\u0646",
"Insert template": "\u062f\u0631\u062c \u0627\u0644\u06af\u0648",
"_dir": "rtl"
});
+30
View File
@@ -0,0 +1,30 @@
import axios from 'axios'
export const initTiny = (batch_id) => ({
skin: false,
height: 500,
menubar: 'tools',
directionality: 'rtl',
language: 'fa_IR',
plugins: [
'textcolor colorpicker advlist autolink lists link image charmap print preview anchor',
'searchreplace visualblocks code fullscreen',
'insertdatetime media table paste code wordcount directionality link',
],
toolbar:
'undo redo | fontsizeselect formatselect | bold italic backcolor | \
alignleft aligncenter alignright alignjustify | \
bullist numlist outdent indent | removeformat | image | ltr rtl | link | forecolor backcolor | table tabledelete | tableprops tablerowprops tablecellprops',
// 'fontselect'
// font_formats : "IranYekan=arial,helvetica,sans-serif;Courier New=courier new,courier,monospace;AkrutiKndPadmini=Akpdmi-n",
fontsize_formats: '10pt 12pt 14pt 16pt 18pt 20pt 22pt 24pt 36pt 60pt 72pt',
images_upload_url: '/api/v1/media',
relative_urls: false,
remove_script_host: false,
document_base_url: window.location.origin,
images_upload_credentials: true,
automatic_uploads: false,
file_picker_types: 'file image media',
content_style: 'body { font-family: "IranYekan-Regular", "Montserrat-Regular" !important; }',
images_upload_handler: (blobInfo, success, failure) => {},
})
+25
View File
@@ -19,6 +19,31 @@ export const endpoints = {
updateProfile: '/profile',
getStudentCourses: '/student/courses',
showStudentCourse: '/student/courses/:id',
getStudentTerms: '/student/terms',
showStudentTerm: '/student/terms/:id',
showStudentLesson: '/student/lessons/:id',
showStudentSession: '/student/sessions/:id',
submitStudentSessionHomework: '/student/sessions/:id/homework',
showStudentExam: '/student/exams/:id',
startStudentExamAttempt: '/student/exams/:id/attempts',
submitStudentExamAttempt: '/student/exams/:id/submit',
getMissionaryProfile: '/student/missionary/profile',
getMissionaryRequests: '/student/missionary/requests',
getMissionaryDispatches: '/student/missionary/dispatches',
getMissionaryNarratives: '/student/missionary/narratives',
getStudentServices: '/student/services',
createStudentService: '/student/services',
getStudentServiceTypes: '/student/service-types',
getStudentServiceTitles: '/student/service-titles',
getStudentConsultants: '/student/consultants',
createStudentConsultant: '/student/consultants',
getStudentConsultantTitles: '/student/consultant-titles',
getStudentCertificates: '/student/certificates',
downloadStudentCertificate: '/student/certificates/:id/download',
getPendingStudents: '/admin/pending-students',
showPendingStudent: '/admin/pending-students/:id',
+8
View File
@@ -0,0 +1,8 @@
import { http } from '@/services/api/http'
import { buildUrl, endpoints } from '@/services/api/endpoints'
export const apiGetStudentCertificates = (params) =>
http.get(endpoints.getStudentCertificates, { params })
export const apiDownloadStudentCertificate = (id, format = 'pdf') =>
http.get(buildUrl(endpoints.downloadStudentCertificate, { id }), { params: { format } })
+10
View File
@@ -0,0 +1,10 @@
import { http } from '@/services/api/http'
import { endpoints } from '@/services/api/endpoints'
export const apiGetStudentConsultants = (params) =>
http.get(endpoints.getStudentConsultants, { params })
export const apiCreateStudentConsultant = (payload) =>
http.post(endpoints.createStudentConsultant, payload)
export const apiGetStudentConsultantTitles = () => http.get(endpoints.getStudentConsultantTitles)
+10
View File
@@ -0,0 +1,10 @@
import { http } from '@/services/api/http'
import { buildUrl, endpoints } from '@/services/api/endpoints'
export const apiShowStudentExam = (id) => http.get(buildUrl(endpoints.showStudentExam, { id }))
export const apiStartStudentExamAttempt = (id) =>
http.post(buildUrl(endpoints.startStudentExamAttempt, { id }))
export const apiSubmitStudentExamAttempt = (id, payload) =>
http.post(buildUrl(endpoints.submitStudentExamAttempt, { id }), payload)
+4
View File
@@ -0,0 +1,4 @@
import { http } from '@/services/api/http'
import { buildUrl, endpoints } from '@/services/api/endpoints'
export const apiShowStudentLesson = (id) => http.get(buildUrl(endpoints.showStudentLesson, { id }))
+13
View File
@@ -0,0 +1,13 @@
import { http } from '@/services/api/http'
import { endpoints } from '@/services/api/endpoints'
export const apiGetMissionaryProfile = () => http.get(endpoints.getMissionaryProfile)
export const apiGetMissionaryRequests = (params) =>
http.get(endpoints.getMissionaryRequests, { params })
export const apiGetMissionaryDispatches = (params) =>
http.get(endpoints.getMissionaryDispatches, { params })
export const apiGetMissionaryNarratives = (params) =>
http.get(endpoints.getMissionaryNarratives, { params })
+12
View File
@@ -0,0 +1,12 @@
import { http } from '@/services/api/http'
import { endpoints } from '@/services/api/endpoints'
export const apiGetStudentServices = (params) => http.get(endpoints.getStudentServices, { params })
export const apiCreateStudentService = (payload) =>
http.post(endpoints.createStudentService, payload)
export const apiGetStudentServiceTypes = () => http.get(endpoints.getStudentServiceTypes)
export const apiGetStudentServiceTitles = (params) =>
http.get(endpoints.getStudentServiceTitles, { params })
+8
View File
@@ -0,0 +1,8 @@
import { http } from '@/services/api/http'
import { buildUrl, endpoints } from '@/services/api/endpoints'
export const apiShowStudentSession = (id) =>
http.get(buildUrl(endpoints.showStudentSession, { id }))
export const apiSubmitStudentSessionHomework = (id, formData) =>
http.post(buildUrl(endpoints.submitStudentSessionHomework, { id }), formData)
+6
View File
@@ -0,0 +1,6 @@
import { http } from '@/services/api/http'
import { buildUrl, endpoints } from '@/services/api/endpoints'
export const apiGetStudentTerms = (params) => http.get(endpoints.getStudentTerms, { params })
export const apiShowStudentTerm = (id) => http.get(buildUrl(endpoints.showStudentTerm, { id }))
+1 -1
View File
@@ -7,7 +7,7 @@ export const currentProfile = {
nationalCode: '0079827498',
avatarUrl: '',
status: 'approved',
roles: ['admin'],
roles: ['user'],
address: {
address: 'خیابان آزادی، پلاک ۱۲',
province: { id: 1, name: 'تهران' },
@@ -0,0 +1,32 @@
export const studentCertificates = [
{
id: 'cert-1',
courseTitle: 'دوره مبانی اندیشه اسلامی',
teacherName: 'علیرضا حسنی',
startDate: '1404.05.12',
endDate: '1404.07.12',
thumbnailUrl: '',
pdfUrl: '/files/certificates/cert-1.pdf',
jpgUrl: '/files/certificates/cert-1.jpg',
},
{
id: 'cert-2',
courseTitle: 'تفسیر سوره بقره',
teacherName: 'محمد رضایی',
startDate: '1404.02.20',
endDate: '1404.04.20',
thumbnailUrl: '',
pdfUrl: '/files/certificates/cert-2.pdf',
jpgUrl: '/files/certificates/cert-2.jpg',
},
{
id: 'cert-3',
courseTitle: 'اخلاق در قرآن',
teacherName: 'فاطمه حسینی',
startDate: '1403.11.05',
endDate: '1404.01.15',
thumbnailUrl: '',
pdfUrl: '/files/certificates/cert-3.pdf',
jpgUrl: '/files/certificates/cert-3.jpg',
},
]
@@ -0,0 +1,50 @@
export const studentConsultantTitles = [
{ value: 'course-talk', label: 'گفتگو درباره دوره' },
{ value: 'academic', label: 'مشاوره تحصیلی' },
{ value: 'career', label: 'مشاوره شغلی' },
{ value: 'psychology', label: 'مشاوره روانشناسی' },
{ value: 'other', label: 'سایر' },
]
const STATUS_LABELS = {
pending: 'در حال بررسی',
approved: 'پاسخ داده شده',
rejected: 'رد شده',
closed: 'بسته شده',
}
export const studentConsultants = [
{
id: 'c-1',
requestNumber: 215,
title: 'درخواست مشاوره',
topicLabel: 'گفتگو درباره دوره',
status: 'pending',
statusLabel: STATUS_LABELS.pending,
description: 'سوال در مورد محتوای دوره',
timeLabel: '12:53',
dateLabel: '1404/12/21',
},
{
id: 'c-2',
requestNumber: 198,
title: 'درخواست مشاوره',
topicLabel: 'مشاوره شغلی',
status: 'approved',
statusLabel: STATUS_LABELS.approved,
description: 'راهنمایی برای انتخاب مسیر شغلی',
timeLabel: '09:12',
dateLabel: '1404/11/05',
},
{
id: 'c-3',
requestNumber: 174,
title: 'درخواست مشاوره',
topicLabel: 'مشاوره تحصیلی',
status: 'rejected',
statusLabel: STATUS_LABELS.rejected,
description: 'برنامه‌ریزی درسی',
timeLabel: '16:40',
dateLabel: '1404/10/02',
},
]
@@ -0,0 +1,94 @@
const sampleQuestions = () => [
{
id: 'q1',
title: 'کدام‌یک از گزینه‌های زیر از نام‌های دیگر قرآن کریم است؟',
answers: [
{ id: 'q1-a1', title: 'فرقان' },
{ id: 'q1-a2', title: 'انجیل' },
{ id: 'q1-a3', title: 'زبور' },
{ id: 'q1-a4', title: 'تورات' },
],
},
{
id: 'q2',
title: 'کدام سوره به «قلب قرآن» معروف است؟',
answers: [
{ id: 'q2-a1', title: 'سوره بقره' },
{ id: 'q2-a2', title: 'سوره یس' },
{ id: 'q2-a3', title: 'سوره الرحمن' },
{ id: 'q2-a4', title: 'سوره نور' },
],
},
{
id: 'q3',
title:
'در مباحث علوم قرآنی، بحث «ناسخ و منسوخ» یکی از موضوعات مهمی است که به بررسی آیاتی می‌پردازد که حکم آن‌ها به‌وسیله آیات دیگر تغییر یافته یا برطرف شده است. برخی از مفسران بر این باورند که آشنایی با مفهوم نسخ برای فهم دقیق‌تر احکام شرعی و جلوگیری از برداشت‌های نادرست از آیات ضروری است. با توجه به این توضیح، کدام گزینه تعریف درستی از «نسخ» در قرآن کریم ارائه می‌دهد؟',
fullWidth: true,
answers: [
{ id: 'q3-a1', title: 'تغییر کامل متن آیات پس از نزول' },
{ id: 'q3-a2', title: 'جایگزینی حکم یک آیه با حکم آیه‌ای دیگر' },
{ id: 'q3-a3', title: 'حذف آیه از قرآن به دستور پیامبر(ص)' },
{ id: 'q3-a4', title: 'تغییر تلاوت آیه بدون تغییر در حکم' },
],
},
{
id: 'q4',
title: 'کدام‌یک از پیامبران لقب «خلیل‌الله» دارد؟',
answers: [
{ id: 'q4-a1', title: 'حضرت موسی (ع)' },
{ id: 'q4-a2', title: 'حضرت ابراهیم (ع)' },
{ id: 'q4-a3', title: 'حضرت نوح (ع)' },
{ id: 'q4-a4', title: 'حضرت عیسی (ع)' },
],
},
]
export const studentExams = [
{
id: '1-l1-e1',
title: 'آزمون جلسه اول',
desc: 'جزئیات آزمون را مشاهده و سپس شروع آزمون را بزنید',
questionsCount: 6,
passingScore: 12,
durationMinutes: 12,
attemptsCount: 2,
status: 'passed',
statusLabel: 'قبول شده اید',
questions: sampleQuestions(),
attempts: [
{
id: 'a1',
title: 'آزمون جلسه اول',
score: 13.01,
date: '2024-06-30T18:54:00.000Z',
faDate: '۱۴۰۳/۰۴/۱۰',
faTime: '۱۸:۵۴',
status: 'passed',
statusLabel: 'قبول شده',
},
{
id: 'a2',
title: 'آزمون جلسه اول',
score: 9.98,
date: '2024-06-29T18:54:00.000Z',
faDate: '۱۴۰۳/۰۴/۱۰',
faTime: '۱۸:۵۴',
status: 'failed',
statusLabel: 'قبول نشده',
},
],
},
{
id: '1-l1-e2',
title: 'آزمون جلسه دوم',
desc: 'جزئیات آزمون را مشاهده و سپس شروع آزمون را بزنید',
questionsCount: 10,
passingScore: 14,
durationMinutes: 20,
attemptsCount: 0,
status: 'not_started',
statusLabel: 'شروع نشده',
questions: sampleQuestions(),
attempts: [],
},
]
@@ -0,0 +1,93 @@
const sampleSessions = (seed) => [
{
id: `${seed}-s1`,
title: 'جلسه یک - مبانی اندیشه اسلامی',
durationHours: 18,
isSeen: true,
hasQuiz: true,
isOnline: true,
needsAssignment: true,
},
{
id: `${seed}-s2`,
title: 'جلسه دو - مبانی اندیشه اسلامی',
durationHours: 18,
isSeen: false,
hasQuiz: true,
isOnline: true,
needsAssignment: false,
},
{
id: `${seed}-s3`,
title: 'جلسه سه - مبانی اندیشه اسلامی',
durationHours: 12,
isSeen: false,
hasQuiz: false,
isOnline: false,
needsAssignment: false,
},
]
const sampleExams = (seed) => [
{
id: `${seed}-e1`,
title: 'آزمون جلسه اول',
durationMinutes: 12,
passingScore: 12,
questionsCount: 6,
},
{
id: `${seed}-e2`,
title: 'آزمون جلسه دوم',
durationMinutes: 20,
passingScore: 14,
questionsCount: 10,
},
]
export const studentLessons = [
{
id: '1-l1',
title: 'درس مبانی اندیشه اسلامی',
sessionsCount: 3,
quizzesCount: 3,
studentsCount: 34,
endDate: '2025-09-03T00:00:00.000Z',
faEndDate: '۱۴۰۴/۰۶/۱۲',
sessions: sampleSessions('1-l1'),
exams: sampleExams('1-l1'),
},
{
id: '1-l2',
title: 'درس اصول اخلاق اسلامی',
sessionsCount: 3,
quizzesCount: 3,
studentsCount: 34,
endDate: '2025-09-03T00:00:00.000Z',
faEndDate: '۱۴۰۴/۰۶/۱۲',
sessions: sampleSessions('1-l2'),
exams: sampleExams('1-l2'),
},
{
id: '2-l1',
title: 'درس مبانی اندیشه اسلامی',
sessionsCount: 3,
quizzesCount: 3,
studentsCount: 34,
endDate: '2025-09-03T00:00:00.000Z',
faEndDate: '۱۴۰۴/۰۶/۱۲',
sessions: sampleSessions('2-l1'),
exams: sampleExams('2-l1'),
},
{
id: '2-l2',
title: 'درس اصول اخلاق اسلامی',
sessionsCount: 3,
quizzesCount: 3,
studentsCount: 34,
endDate: '2025-09-03T00:00:00.000Z',
faEndDate: '۱۴۰۴/۰۶/۱۲',
sessions: sampleSessions('2-l2'),
exams: sampleExams('2-l2'),
},
]
@@ -0,0 +1,157 @@
export const missionaryProfile = {
id: 'me',
fullName: 'آقای علیرضا احمدی',
city: 'قم، ایران',
level: 'پیشرفته',
membershipDays: 23,
rating: 4.8,
requestsCount: 451,
avatar: '',
}
const STATUS_LABELS = {
pending: 'در انتظار بررسی',
approved: 'تایید شده',
rejected: 'رد شده توسط موسسه',
cancelled: 'لغو شده',
}
const REQUEST_DESCRIPTION = `لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است. چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است و برای شرایط فعلی تکنولوژی مورد نیاز و کاربردهای متنوع با هدف بهبود ابزارهای کاربردی می باشد. کتابهای زیادی در شصت و سه درصد گذشته، حال و آینده شناخت فراوان جامعه و متخصصان را می طلبد.`
export const missionaryRequests = [
{
id: 'r-1',
code: 1,
title: 'درخواست شماره یک',
requestNumber: 387,
requesterName: 'حسین رضایی',
requestDate: '1404/05/12',
status: 'rejected',
statusLabel: STATUS_LABELS.rejected,
phone: '0912 152 1413',
postalCode: '1232146890345',
address:
'قم، سالاریه، میدان پیچک، به سمت فرعی اول، میدان الهیه، ساختمان الیزیوم، طبقه منفی 1 واحد 152',
description: REQUEST_DESCRIPTION,
},
{
id: 'r-2',
code: 2,
title: 'درخواست شماره دو',
requestNumber: 401,
requesterName: 'مریم اکبری',
requestDate: '1404/06/04',
status: 'pending',
statusLabel: STATUS_LABELS.pending,
phone: '0912 884 2210',
postalCode: '8136987710332',
address: 'اصفهان، خیابان چهارباغ بالا، کوچه گلستان، پلاک 12',
description: REQUEST_DESCRIPTION,
},
{
id: 'r-3',
code: 3,
title: 'درخواست شماره سه',
requestNumber: 412,
requesterName: 'فاطمه حسینی',
requestDate: '1404/06/18',
status: 'approved',
statusLabel: STATUS_LABELS.approved,
phone: '0935 410 0091',
postalCode: '9176548132201',
address: 'مشهد، خیابان امام رضا، کوچه 14، پلاک 5',
description: REQUEST_DESCRIPTION,
},
]
const DISPATCH_RESULT = `لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است. چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است و برای شرایط فعلی تکنولوژی مورد نیاز و کاربردهای متنوع با هدف بهبود ابزارهای کاربردی می باشد. کتابهای زیادی در شصت و سه درصد گذشته، حال و آینده شناخت فراوان جامعه و متخصصان را می طلبد.`
export const missionaryDispatches = [
{
id: 'd-1',
code: 1_547_890,
title: 'اعزام شماره یک',
missionName: 'سفر به مشهد مقدس',
location: 'زنجان، ایران',
dispatchDate: '1404/12/12',
statusLabel: 'انجام شده',
requesterName: 'حسین مولایی',
address:
'قم، سالاریه، میدان پیچک، به سمت فرعی اول، میدان الهیه، ساختمان الیزیوم، طبقه منفی 1 واحد 152',
result: DISPATCH_RESULT,
},
{
id: 'd-2',
code: 1_547_891,
title: 'اعزام شماره دو',
missionName: 'سفر به اصفهان',
location: 'اصفهان، ایران',
dispatchDate: '1404/04/09',
statusLabel: 'در حال انجام',
requesterName: 'مریم اکبری',
address: 'اصفهان، خیابان چهارباغ، کوچه گلستان، پلاک 12',
result: DISPATCH_RESULT,
},
{
id: 'd-3',
code: 1_547_892,
title: 'اعزام شماره سه',
missionName: 'سفر به مشهد مقدس',
location: 'مشهد، ایران',
dispatchDate: '1404/03/21',
statusLabel: 'انجام شده',
requesterName: 'فاطمه حسینی',
address: 'مشهد، خیابان امام رضا، کوچه 14، پلاک 5',
result: DISPATCH_RESULT,
},
]
const NARRATIVE_KIND = {
manuscript: { key: 'manuscript', label: 'دست نوشته', tone: 'pink' },
dispatched: { key: 'dispatched', label: 'اعزام شــــده', tone: 'gold' },
}
const SAMPLE_BODY = `لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است. چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است و برای شرایط فعلی تکنولوژی مورد نیاز و کاربردهای متنوع با هدف بهبود ابزارهای کاربردی می باشد. کتابهای زیادی در شصت و سه درصد گذشته، حال و آینده شناخت فراوان جامعه و متخصصان را می طلبد.
لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است. چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است و برای شرایط فعلی تکنولوژی مورد نیاز و کاربردهای متنوع با هدف بهبود ابزارهای کاربردی می باشد.
لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است.`
export const missionaryNarratives = [
{
id: 'n-1',
code: 1,
title: 'سفر به شهر زیبای مشهد مقدس، شهر علم و ادب',
submittedAt: '۱۴۰۰/۷/۱۹, ۸:۳۰',
kind: NARRATIVE_KIND.manuscript.key,
kindLabel: NARRATIVE_KIND.manuscript.label,
kindTone: NARRATIVE_KIND.manuscript.tone,
body: SAMPLE_BODY,
likes: 12,
comments: 56,
},
{
id: 'n-2',
code: 2,
title: 'سفر به اصفهان نصف جهان',
submittedAt: '۱۴۰۰/۷/۱۹, ۸:۳۰',
kind: NARRATIVE_KIND.dispatched.key,
kindLabel: NARRATIVE_KIND.dispatched.label,
kindTone: NARRATIVE_KIND.dispatched.tone,
body: SAMPLE_BODY,
likes: 24,
comments: 9,
},
{
id: 'n-3',
code: 3,
title: 'روایت شماره سه',
submittedAt: '۱۴۰۰/۸/۲, ۹:۱۵',
kind: NARRATIVE_KIND.manuscript.key,
kindLabel: NARRATIVE_KIND.manuscript.label,
kindTone: NARRATIVE_KIND.manuscript.tone,
body: SAMPLE_BODY,
likes: 3,
comments: 1,
},
]
@@ -0,0 +1,61 @@
export const studentServiceTypes = [
{ value: 'loan', label: 'وام' },
{ value: 'consultation', label: 'مشاوره' },
{ value: 'insurance', label: 'بیمه' },
{ value: 'other', label: 'سایر' },
]
export const studentServiceTitlesByType = {
loan: [
{ value: 'tuition-loan', label: 'وام شهریه' },
{ value: 'living-loan', label: 'وام معیشت' },
],
consultation: [
{ value: 'course-talk', label: 'گفتگو درباره دوره' },
{ value: 'career-talk', label: 'مشاوره شغلی' },
],
insurance: [{ value: 'student-insurance', label: 'بیمه دانشجویی' }],
other: [{ value: 'other-issue', label: 'موضوع دیگر' }],
}
const STATUS_LABELS = {
approved: 'تاییــد شـــــده',
pending: 'در انتظار بررسی',
rejected: 'رد شده',
}
export const studentServices = [
{
id: 's-1',
requestNumber: 387,
title: 'درخواست وام',
typeKey: 'loan',
typeLabel: 'وام',
status: 'approved',
statusLabel: STATUS_LABELS.approved,
description: 'لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ',
createdAt: '1404/05/12',
},
{
id: 's-2',
requestNumber: 44,
title: 'درخواست وام',
typeKey: 'loan',
typeLabel: 'وام',
status: 'pending',
statusLabel: STATUS_LABELS.pending,
description: 'درخواست شهریه ترم جدید',
createdAt: '1404/06/02',
},
{
id: 's-3',
requestNumber: 18,
title: 'گفتگو درباره دوره',
typeKey: 'consultation',
typeLabel: 'مشاوره',
status: 'rejected',
statusLabel: STATUS_LABELS.rejected,
description: 'سوال در مورد محتوای دوره',
createdAt: '1404/04/22',
},
]
@@ -0,0 +1,14 @@
export const studentSessions = [
{
id: '1-l1-s1',
title: 'جلسه یک - مبانی اندیشه اسلامی',
desc: 'اطلاعات کامل این دوره را مشاهده کنید.',
videoUrl: 'https://download.samplelib.com/mp4/sample-5s.mp4',
poster: '',
homeworkPrompt:
'لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است. چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است و برای شرایط فعلی بهبود ابزارهای کاربردی می باشد.',
homeworkHint:
'تکلیف به عنوان متن به کاربر گفته می‌شود و از آن خواسته می‌شود تا تکلیف را به صورت صوت یا تصویر ارسال نماید.',
examId: '1-l1-e1',
},
]
@@ -0,0 +1,75 @@
const sampleLessons = (seed) => [
{
id: `${seed}-l1`,
title: 'درس مبانی اندیشه اسلامی',
learningHours: 18,
sessionsCount: 3,
quizzesCount: 3,
studentsCount: 34,
},
{
id: `${seed}-l2`,
title: 'درس اصول اخلاق اسلامی',
learningHours: 18,
sessionsCount: 3,
quizzesCount: 3,
studentsCount: 34,
},
]
export const studentTerms = [
{
id: 1,
title: 'دوره مبانی اندیشه اسلامی',
teacher: { firstName: 'علیرضا', lastName: 'حسنی', fullName: 'علیرضا حسنی' },
teacherName: 'علیرضا حسنی',
status: 'watching',
statusLabel: 'در حال گذراندن',
startDate: '2025-09-23T00:00:00.000Z',
endDate: '2026-01-20T00:00:00.000Z',
faStartDate: '۱۴۰۴/۰۵/۱۲',
faEndDate: '۱۴۰۴/۰۷/۱۲',
courses: sampleLessons(1),
},
{
id: 2,
title: 'دوره اصول اخلاق اسلامی',
teacher: { firstName: 'حسین', lastName: 'نیک محمدی', fullName: 'حسین نیک محمدی' },
teacherName: 'حسین نیک محمدی',
status: 'waitForExam',
statusLabel: 'در انتظار آزمون',
startDate: '2025-09-23T00:00:00.000Z',
endDate: '2026-01-20T00:00:00.000Z',
faStartDate: '۱۴۰۴/۰۵/۱۲',
faEndDate: '۱۴۰۴/۰۷/۱۲',
courses: sampleLessons(2),
},
{
id: 3,
title: 'دوره معارف قرآنی',
teacher: { firstName: 'مریم', lastName: 'احمدی', fullName: 'مریم احمدی' },
teacherName: 'مریم احمدی',
status: 'completed',
statusLabel: 'تکمیل شده',
startDate: '2025-01-20T00:00:00.000Z',
endDate: '2025-05-20T00:00:00.000Z',
faStartDate: '۱۴۰۳/۱۱/۰۱',
faEndDate: '۱۴۰۴/۰۳/۰۱',
gpa: 19.255,
courses: sampleLessons(3),
},
{
id: 4,
title: 'دوره تاریخ تشیع',
teacher: { firstName: 'فاطمه', lastName: 'رضایی', fullName: 'فاطمه رضایی' },
teacherName: 'فاطمه رضایی',
status: 'failed',
statusLabel: 'عدم قبولی',
startDate: '2024-09-22T00:00:00.000Z',
endDate: '2024-12-22T00:00:00.000Z',
faStartDate: '۱۴۰۳/۰۷/۰۱',
faEndDate: '۱۴۰۳/۱۰/۰۱',
gpa: 8.5,
courses: sampleLessons(4),
},
]
@@ -0,0 +1,15 @@
import { paginate } from '@/services/mock/helpers'
import { register } from '@/services/mock/registry'
import { endpoints } from '@/services/api/endpoints'
import { studentCertificates } from '@/services/mock/fixtures/student-certificates'
register('GET', endpoints.getStudentCertificates, ({ query }) =>
paginate(studentCertificates, query)
)
register('GET', endpoints.downloadStudentCertificate, ({ params, query }) => {
const cert = studentCertificates.find((c) => String(c.id) === String(params.id))
const format = query?.format === 'jpg' ? 'jpg' : 'pdf'
const url = format === 'jpg' ? cert?.jpgUrl : cert?.pdfUrl
return { data: { id: params.id, format, url: url || '#' } }
})
@@ -0,0 +1,29 @@
import { paginate } from '@/services/mock/helpers'
import { register } from '@/services/mock/registry'
import { endpoints } from '@/services/api/endpoints'
import {
studentConsultantTitles,
studentConsultants,
} from '@/services/mock/fixtures/student-consultants'
register('GET', endpoints.getStudentConsultants, ({ query }) => paginate(studentConsultants, query))
register('POST', endpoints.createStudentConsultant, ({ data }) => {
const id = `c-${studentConsultants.length + 1}`
const next = {
id,
requestNumber: 500 + studentConsultants.length + 1,
title: 'درخواست مشاوره',
topicLabel:
studentConsultantTitles.find((t) => t.value === data?.title)?.label || data?.title || '—',
status: 'pending',
statusLabel: 'در حال بررسی',
description: data?.description || '',
timeLabel: '12:00',
dateLabel: '1404/06/12',
}
studentConsultants.unshift(next)
return { data: next }
})
register('GET', endpoints.getStudentConsultantTitles, () => ({ data: studentConsultantTitles }))
+27
View File
@@ -0,0 +1,27 @@
import { register } from '@/services/mock/registry'
import { endpoints } from '@/services/api/endpoints'
import { studentExams } from '@/services/mock/fixtures/student-exams'
register('GET', endpoints.showStudentExam, ({ params }) => {
const exam = studentExams.find((e) => String(e.id) === String(params.id))
if (exam) return { data: exam }
const fallback = studentExams[1]
return { data: { ...fallback, id: params.id } }
})
register('POST', endpoints.startStudentExamAttempt, ({ params }) => ({
data: { id: params.id, status: 'started', message: 'آزمون آغاز شد' },
}))
register('POST', endpoints.submitStudentExamAttempt, ({ params, data }) => {
const answers = data?.answers || {}
const total = Object.keys(answers).length
return {
data: {
id: params.id,
submitted: true,
score: total > 0 ? (12 + Math.random() * 6).toFixed(2) : 0,
message: 'پاسخ‌های شما با موفقیت ثبت شد.',
},
}
})
@@ -0,0 +1,11 @@
import { register } from '@/services/mock/registry'
import { endpoints } from '@/services/api/endpoints'
import { studentLessons } from '@/services/mock/fixtures/student-lessons'
register('GET', endpoints.showStudentLesson, ({ params }) => {
const lesson = studentLessons.find((l) => String(l.id) === String(params.id))
if (lesson) return { data: lesson }
// fallback so any term/lesson combo resolves
const fallback = studentLessons[0]
return { data: { ...fallback, id: params.id } }
})
@@ -0,0 +1,21 @@
import { paginate } from '@/services/mock/helpers'
import { register } from '@/services/mock/registry'
import { endpoints } from '@/services/api/endpoints'
import {
missionaryDispatches,
missionaryNarratives,
missionaryProfile,
missionaryRequests,
} from '@/services/mock/fixtures/student-missionary'
register('GET', endpoints.getMissionaryProfile, () => ({ data: missionaryProfile }))
register('GET', endpoints.getMissionaryRequests, ({ query }) => paginate(missionaryRequests, query))
register('GET', endpoints.getMissionaryDispatches, ({ query }) =>
paginate(missionaryDispatches, query)
)
register('GET', endpoints.getMissionaryNarratives, ({ query }) =>
paginate(missionaryNarratives, query)
)
@@ -0,0 +1,33 @@
import { paginate } from '@/services/mock/helpers'
import { register } from '@/services/mock/registry'
import { endpoints } from '@/services/api/endpoints'
import {
studentServiceTitlesByType,
studentServiceTypes,
studentServices,
} from '@/services/mock/fixtures/student-services'
register('GET', endpoints.getStudentServices, ({ query }) => paginate(studentServices, query))
register('POST', endpoints.createStudentService, ({ data }) => {
const id = `s-${studentServices.length + 1}`
const next = {
id,
requestNumber: 500 + studentServices.length + 1,
title: data?.title || '—',
typeKey: data?.typeKey || 'other',
typeLabel: studentServiceTypes.find((t) => t.value === data?.typeKey)?.label || 'سایر',
status: 'pending',
statusLabel: 'در انتظار بررسی',
description: data?.description || '',
createdAt: '1404/06/12',
}
studentServices.unshift(next)
return { data: next }
})
register('GET', endpoints.getStudentServiceTypes, () => ({ data: studentServiceTypes }))
register('GET', endpoints.getStudentServiceTitles, ({ query }) => ({
data: studentServiceTitlesByType[query.type] || [],
}))
@@ -0,0 +1,14 @@
import { register } from '@/services/mock/registry'
import { endpoints } from '@/services/api/endpoints'
import { studentSessions } from '@/services/mock/fixtures/student-sessions'
register('GET', endpoints.showStudentSession, ({ params }) => {
const session = studentSessions.find((s) => String(s.id) === String(params.id))
if (session) return { data: session }
const fallback = studentSessions[0]
return { data: { ...fallback, id: params.id } }
})
register('POST', endpoints.submitStudentSessionHomework, ({ params }) => ({
data: { id: params.id, status: 'submitted', message: 'تکلیف با موفقیت ارسال شد' },
}))
+21
View File
@@ -0,0 +1,21 @@
import { register } from '@/services/mock/registry'
import { endpoints } from '@/services/api/endpoints'
import { findOrThrow, paginate } from '@/services/mock/helpers'
import { studentTerms } from '@/services/mock/fixtures/student-terms'
const CURRENT_STATUSES = new Set(['watching', 'waitForExam'])
const ENDED_STATUSES = new Set(['completed', 'failed'])
register('GET', endpoints.getStudentTerms, ({ query }) => {
let list = [...studentTerms]
if (query.status === 'current') {
list = list.filter((t) => CURRENT_STATUSES.has(t.status))
} else if (query.status === 'ended') {
list = list.filter((t) => ENDED_STATUSES.has(t.status))
}
return paginate(list, query)
})
register('GET', endpoints.showStudentTerm, ({ params }) => ({
data: findOrThrow(studentTerms, params.id),
}))
@@ -0,0 +1,25 @@
import { cleanFilters } from '@/utils/clean-filters'
import { useMutation, useQuery } from '@tanstack/vue-query'
import {
apiDownloadStudentCertificate,
apiGetStudentCertificates,
} from '@/services/api/student-certificates'
export const studentCertificatesKeys = {
all: ['student', 'certificates'],
list: (filters, pagination) => ['student', 'certificates', 'list', filters, pagination],
}
export const useStudentCertificatesQuery = (filtersRef, paginationRef, options = {}) =>
useQuery({
queryKey: ['student', 'certificates', 'list', filtersRef, paginationRef],
queryFn: () =>
apiGetStudentCertificates({
...cleanFilters(filtersRef.value),
...paginationRef.value,
}),
...options,
})
export const useDownloadStudentCertificateMutation = () =>
useMutation({ mutationFn: ({ id, format }) => apiDownloadStudentCertificate(id, format) })
+35
View File
@@ -0,0 +1,35 @@
import { cleanFilters } from '@/utils/clean-filters'
import { useMutation, useQuery } from '@tanstack/vue-query'
import {
apiCreateStudentConsultant,
apiGetStudentConsultantTitles,
apiGetStudentConsultants,
} from '@/services/api/student-consultants'
export const studentConsultantsKeys = {
all: ['student', 'consultants'],
list: (filters, pagination) => ['student', 'consultants', 'list', filters, pagination],
titles: ['student', 'consultant-titles'],
}
export const useStudentConsultantsQuery = (filtersRef, paginationRef, options = {}) =>
useQuery({
queryKey: ['student', 'consultants', 'list', filtersRef, paginationRef],
queryFn: () =>
apiGetStudentConsultants({
...cleanFilters(filtersRef.value),
...paginationRef.value,
}),
...options,
})
export const useStudentConsultantTitlesQuery = (options = {}) =>
useQuery({
queryKey: studentConsultantsKeys.titles,
queryFn: () => apiGetStudentConsultantTitles(),
select: (response) => response?.data ?? [],
...options,
})
export const useCreateStudentConsultantMutation = () =>
useMutation({ mutationFn: (payload) => apiCreateStudentConsultant(payload) })
+26
View File
@@ -0,0 +1,26 @@
import { useMutation, useQuery } from '@tanstack/vue-query'
import {
apiShowStudentExam,
apiStartStudentExamAttempt,
apiSubmitStudentExamAttempt,
} from '@/services/api/student-exams'
export const studentExamsKeys = {
detail: (id) => ['student', 'exams', 'detail', id],
}
export const useStudentExamQuery = (idRef, options = {}) =>
useQuery({
queryKey: ['student', 'exams', 'detail', idRef],
queryFn: () => apiShowStudentExam(idRef.value),
select: (response) => response?.data ?? response,
...options,
})
export const useStartStudentExamAttemptMutation = () =>
useMutation({ mutationFn: ({ id }) => apiStartStudentExamAttempt(id) })
export const useSubmitStudentExamAttemptMutation = () =>
useMutation({
mutationFn: ({ id, payload }) => apiSubmitStudentExamAttempt(id, payload),
})
+14
View File
@@ -0,0 +1,14 @@
import { useQuery } from '@tanstack/vue-query'
import { apiShowStudentLesson } from '@/services/api/student-lessons'
export const studentLessonsKeys = {
detail: (id) => ['student', 'lessons', 'detail', id],
}
export const useStudentLessonQuery = (idRef, options = {}) =>
useQuery({
queryKey: ['student', 'lessons', 'detail', idRef],
queryFn: () => apiShowStudentLesson(idRef.value),
select: (response) => response?.data ?? response,
...options,
})
+56
View File
@@ -0,0 +1,56 @@
import { useQuery } from '@tanstack/vue-query'
import { cleanFilters } from '@/utils/clean-filters'
import {
apiGetMissionaryDispatches,
apiGetMissionaryNarratives,
apiGetMissionaryProfile,
apiGetMissionaryRequests,
} from '@/services/api/student-missionary'
export const missionaryKeys = {
profile: ['student', 'missionary', 'profile'],
requests: (filters, pagination) => ['student', 'missionary', 'requests', filters, pagination],
dispatches: (filters, pagination) => ['student', 'missionary', 'dispatches', filters, pagination],
narratives: (filters, pagination) => ['student', 'missionary', 'narratives', filters, pagination],
}
export const useMissionaryProfileQuery = (options = {}) =>
useQuery({
queryKey: missionaryKeys.profile,
queryFn: () => apiGetMissionaryProfile(),
select: (response) => response?.data ?? response,
...options,
})
export const useMissionaryRequestsQuery = (filtersRef, paginationRef, options = {}) =>
useQuery({
queryKey: ['student', 'missionary', 'requests', filtersRef, paginationRef],
queryFn: () =>
apiGetMissionaryRequests({
...cleanFilters(filtersRef.value),
...paginationRef.value,
}),
...options,
})
export const useMissionaryDispatchesQuery = (filtersRef, paginationRef, options = {}) =>
useQuery({
queryKey: ['student', 'missionary', 'dispatches', filtersRef, paginationRef],
queryFn: () =>
apiGetMissionaryDispatches({
...cleanFilters(filtersRef.value),
...paginationRef.value,
}),
...options,
})
export const useMissionaryNarrativesQuery = (filtersRef, paginationRef, options = {}) =>
useQuery({
queryKey: ['student', 'missionary', 'narratives', filtersRef, paginationRef],
queryFn: () =>
apiGetMissionaryNarratives({
...cleanFilters(filtersRef.value),
...paginationRef.value,
}),
...options,
})
+45
View File
@@ -0,0 +1,45 @@
import { cleanFilters } from '@/utils/clean-filters'
import { useMutation, useQuery } from '@tanstack/vue-query'
import {
apiCreateStudentService,
apiGetStudentServiceTitles,
apiGetStudentServiceTypes,
apiGetStudentServices,
} from '@/services/api/student-services'
export const studentServicesKeys = {
all: ['student', 'services'],
list: (filters, pagination) => ['student', 'services', 'list', filters, pagination],
types: ['student', 'service-types'],
titles: (typeKey) => ['student', 'service-titles', typeKey],
}
export const useStudentServicesQuery = (filtersRef, paginationRef, options = {}) =>
useQuery({
queryKey: ['student', 'services', 'list', filtersRef, paginationRef],
queryFn: () =>
apiGetStudentServices({
...cleanFilters(filtersRef.value),
...paginationRef.value,
}),
...options,
})
export const useStudentServiceTypesQuery = (options = {}) =>
useQuery({
queryKey: studentServicesKeys.types,
queryFn: () => apiGetStudentServiceTypes(),
select: (response) => response?.data ?? [],
...options,
})
export const useStudentServiceTitlesQuery = (typeKeyRef, options = {}) =>
useQuery({
queryKey: ['student', 'service-titles', typeKeyRef],
queryFn: () => apiGetStudentServiceTitles({ type: typeKeyRef.value }),
select: (response) => response?.data ?? [],
...options,
})
export const useCreateStudentServiceMutation = () =>
useMutation({ mutationFn: (payload) => apiCreateStudentService(payload) })
+22
View File
@@ -0,0 +1,22 @@
import { useMutation, useQuery } from '@tanstack/vue-query'
import {
apiShowStudentSession,
apiSubmitStudentSessionHomework,
} from '@/services/api/student-sessions'
export const studentSessionsKeys = {
detail: (id) => ['student', 'sessions', 'detail', id],
}
export const useStudentSessionQuery = (idRef, options = {}) =>
useQuery({
queryKey: ['student', 'sessions', 'detail', idRef],
queryFn: () => apiShowStudentSession(idRef.value),
select: (response) => response?.data ?? response,
...options,
})
export const useSubmitStudentSessionHomeworkMutation = () =>
useMutation({
mutationFn: ({ id, payload }) => apiSubmitStudentSessionHomework(id, payload),
})
+28
View File
@@ -0,0 +1,28 @@
import { useQuery } from '@tanstack/vue-query'
import { cleanFilters } from '@/utils/clean-filters'
import { apiGetStudentTerms, apiShowStudentTerm } from '@/services/api/student-terms'
export const studentTermsKeys = {
all: ['student', 'terms'],
list: (filters, pagination) => ['student', 'terms', 'list', filters, pagination],
detail: (id) => ['student', 'terms', 'detail', id],
}
export const useStudentTermsListQuery = (filtersRef, paginationRef, options = {}) =>
useQuery({
queryKey: ['student', 'terms', 'list', filtersRef, paginationRef],
queryFn: () =>
apiGetStudentTerms({
...cleanFilters(filtersRef.value),
...paginationRef.value,
}),
...options,
})
export const useStudentTermQuery = (idRef, options = {}) =>
useQuery({
queryKey: ['student', 'terms', 'detail', idRef],
queryFn: () => apiShowStudentTerm(idRef.value),
select: (response) => response?.data ?? response,
...options,
})
+7
View File
@@ -0,0 +1,7 @@
.mce-container,.mce-container *,.mce-widget,.mce-widget *,.mce-reset {
font-family:"iranyekan", "Montserrat", "tinymce"!important;
}
.mce-monospace {
font-family:"iranyekan", "Montserrat", "tinymce"!important;
}