Compare commits

...

14 Commits

Author SHA1 Message Date
sajjadtalkhabi b2f2f89181 fix: bugs
Deploy banu-front / deploy (push) Successful in 1m26s
2026-08-15 19:42:33 +03:30
Server Migration b64f528c0d fix: complete registration frontend flow
Deploy banu-front / deploy (push) Successful in 1m40s
2026-08-15 17:28:40 +03:30
Server Migration d94a96b25e fix: resolve education dashboard interface issues 2026-08-15 13:06:49 +03:30
sajjadtalkhabi af0149d722 fix: media recorder
Deploy banu-front / deploy (push) Successful in 1m45s
2026-08-15 13:01:18 +03:30
sajjadtalkhabi fec1da6ec8 fix: localize Yup validation messages
Deploy banu-front / deploy (push) Successful in 1m50s
2026-08-14 21:08:52 +03:30
Server Migration fe08fc46fb fix: bind session audio URL correctly
Deploy banu-front / deploy (push) Successful in 1m20s
2026-08-04 18:45:39 +00:00
Server Migration 3d005859ab fix: remove old-server frontend references
Deploy banu-front / deploy (push) Waiting to run
2026-08-04 18:43:12 +00:00
Server Migration f13eb9bbb4 build: use reliable npm registry
Deploy banu-front / deploy (push) Waiting to run
2026-08-04 18:38:11 +00:00
Server Migration fb0e4aa7f4 chore: bind frontend deployment to loopback
Deploy banu-front / deploy (push) Waiting to run
2026-08-04 18:32:43 +00:00
Server Migration 94ac69d96a chore: migrate deployment to madresebanoo.ir
Deploy banu-front / deploy (push) Waiting to run
2026-08-04 18:32:19 +00:00
sajjadtalkhabi 39b295037e fix: bugs
Deploy banu-front / deploy (push) Successful in 2m18s
2026-08-01 10:34:21 +03:30
sajjadtalkhabi 31d100861b fix: bugs
Deploy banu-front / deploy (push) Failing after 23s
2026-07-30 18:56:55 +03:30
sajjadtalkhabi 6510432f41 Merge branch 'master' of ssh://109.122.252.86:2222/front-end/banu-front
Deploy banu-front / deploy (push) Successful in 10m31s
2026-07-30 18:53:59 +03:30
sajjadtalkhabi 8fefb4568b fix: bugs 2026-07-30 18:53:56 +03:30
51 changed files with 1202 additions and 310 deletions
+2 -2
View File
@@ -19,7 +19,7 @@ jobs:
run: |
BUILD_DIR="$(mktemp -d -t banu-front-XXXXXX)"
echo "BUILD_DIR=$BUILD_DIR" >> "$GITHUB_ENV"
git clone http://109.122.252.86:3000/front-end/banu-front.git "$BUILD_DIR"
git clone http://127.0.0.1:3000/front-end/banu-front.git "$BUILD_DIR"
git -C "$BUILD_DIR" checkout "${{ github.sha }}"
- name: Build Docker image
@@ -37,7 +37,7 @@ jobs:
docker run -d \
--name "$CONTAINER" \
--restart unless-stopped \
-p "$PORT:80" \
-p "127.0.0.1:$PORT:80" \
"$IMAGE:latest"
- name: Prune dangling images
+1 -1
View File
@@ -8,7 +8,7 @@ COPY package.json package-lock.json ./
# RUN npm config set registry https://mirror-npm.runflare.com
# RUN npm config set strict-ssl false
RUN npm config set registry https://package-mirror.liara.ir/repository/npm/ --global
RUN npm config set registry https://registry.npmjs.org/ --global
RUN npm config set strict-ssl false
RUN npm ci
+1 -1
View File
@@ -6,7 +6,7 @@ APP_DIR="/root/banu-front"
IMAGE="banu-front"
CONTAINER="banu-front"
PORT="8080"
VITE_API_BASE_URL="http://109.122.252.86/api"
VITE_API_BASE_URL="//api.madresebanoo.ir/api"
echo "==> Pulling latest code..."
if [ -d "$APP_DIR/.git" ]; then
+241
View File
@@ -13,6 +13,7 @@
"@tanstack/vue-query-devtools": "^5.62.2",
"@tinymce/tinymce-vue": "^4.0.7",
"axios": "^1.12.2",
"html2pdf.js": "^0.14.0",
"husky": "^8.0.0",
"jalaali-js": "^1.2.8",
"lodash": "^4.18.1",
@@ -102,6 +103,15 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
@@ -1722,6 +1732,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/pako": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT"
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@ungap/structured-clone": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz",
@@ -2168,6 +2198,15 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.29",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz",
@@ -2371,6 +2410,26 @@
],
"license": "CC-BY-4.0"
},
"node_modules/canvg": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@babel/runtime": "^7.12.5",
"@types/raf": "^3.4.0",
"core-js": "^3.8.3",
"raf": "^3.4.1",
"regenerator-runtime": "^0.13.7",
"rgbcolor": "^1.0.1",
"stackblur-canvas": "^2.0.0",
"svg-pathdata": "^6.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -2656,6 +2715,18 @@
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/core-js": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/core-js-compat": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz",
@@ -2722,6 +2793,15 @@
"node": ">=12"
}
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
@@ -3032,6 +3112,15 @@
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/dompurify": {
"version": "3.4.12",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
"integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
@@ -3853,6 +3942,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-png": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
"license": "MIT",
"dependencies": {
"@types/pako": "^2.0.3",
"iobuffer": "^5.3.2",
"pako": "^2.1.0"
}
},
"node_modules/fast-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
@@ -3908,6 +4008,12 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@@ -4469,6 +4575,30 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/html2pdf.js": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/html2pdf.js/-/html2pdf.js-0.14.0.tgz",
"integrity": "sha512-yvNJgE/8yru2UeGflkPdjW8YEY+nDH5X7/2WG4uiuSCwYiCp8PZ8EKNiTAa6HxJ1NjC51fZSIEq6xld5CADKBQ==",
"license": "MIT",
"dependencies": {
"dompurify": "^3.3.1",
"html2canvas": "^1.0.0",
"jspdf": "^4.0.0"
}
},
"node_modules/htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
@@ -4607,6 +4737,12 @@
"node": ">= 0.4"
}
},
"node_modules/iobuffer": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
"license": "MIT"
},
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -5171,6 +5307,23 @@
"json5": "lib/cli.js"
}
},
"node_modules/jspdf": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz",
"integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.6",
"fast-png": "^6.2.0",
"fflate": "^0.8.1"
},
"optionalDependencies": {
"canvg": "^3.0.11",
"core-js": "^3.6.0",
"dompurify": "^3.3.1",
"html2canvas": "^1.0.0-rc.5"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -6022,6 +6175,22 @@
"node": ">=6"
}
},
"node_modules/pako": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
"integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -6106,6 +6275,13 @@
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"license": "MIT"
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"license": "MIT",
"optional": true
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -6416,6 +6592,16 @@
],
"license": "MIT"
},
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"license": "MIT",
"optional": true,
"dependencies": {
"performance-now": "^2.1.0"
}
},
"node_modules/read-pkg": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz",
@@ -6563,6 +6749,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT",
"optional": true
},
"node_modules/regexp-tree": {
"version": "0.1.27",
"resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz",
@@ -6716,6 +6909,16 @@
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/rgbcolor": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
"optional": true,
"engines": {
"node": ">= 0.8.15"
}
},
"node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
@@ -7140,6 +7343,16 @@
"node": ">=0.10.0"
}
},
"node_modules/stackblur-canvas": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.1.14"
}
},
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -7685,6 +7898,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/svg-pathdata": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/svg-tags": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz",
@@ -7779,6 +8002,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@@ -8018,6 +8250,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/validate-npm-package-license": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
+1
View File
@@ -21,6 +21,7 @@
"@tanstack/vue-query-devtools": "^5.62.2",
"@tinymce/tinymce-vue": "^4.0.7",
"axios": "^1.12.2",
"html2pdf.js": "^0.14.0",
"husky": "^8.0.0",
"jalaali-js": "^1.2.8",
"lodash": "^4.18.1",
+7 -5
View File
@@ -49,7 +49,7 @@
</template>
<script setup>
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useUIStore } from '@/store/ui'
import { gallery } from '@/utils/gallery'
@@ -65,7 +65,7 @@ defineProps({
const ui = useUIStore()
const router = useRouter()
const { clearSession } = useAuth()
const { role, clearSession } = useAuth()
const logoutMutation = useLogoutMutation()
const logo = gallery.authLogo
@@ -98,8 +98,10 @@ const onLogout = async () => {
}
}
const userMenuItems = [
{ key: 'edit-profile', label: 'ویرایش پروفایل', icon: 'pencil', onClick: goEditProfile },
const userMenuItems = computed(() => [
...(role.value === 'student'
? [{ key: 'edit-profile', label: 'ویرایش پروفایل', icon: 'pencil', onClick: goEditProfile }]
: []),
{
key: 'logout',
label: 'خروج',
@@ -107,7 +109,7 @@ const userMenuItems = [
color: 'var(--color-primary)',
onClick: onLogout,
},
]
])
const iconList = [
// { icon: 'chat', action: () => {} },
+26 -2
View File
@@ -1,5 +1,10 @@
<template>
<div class="stat-card">
<component
:is="to ? RouterLink : 'div'"
:to="to || undefined"
class="stat-card"
:class="{ 'stat-card--linked': to }"
>
<SvgIcon name="card-corner-arrow" :size="'1.5rem'" class="stat-card__corner" />
<div class="stat-card__head">
@@ -14,10 +19,11 @@
<span class="stat-card__subtitle">{{ subtitle }}</span>
</div>
</div>
</div>
</component>
</template>
<script setup>
import { RouterLink } from 'vue-router'
import SvgIcon from '@/components/icons/SvgIcon.vue'
defineProps({
@@ -27,6 +33,7 @@ defineProps({
value: { type: [String, Number], default: '' },
unit: { type: String, default: '' },
subtitle: { type: String, default: '' },
to: { type: [String, Object], default: null },
})
</script>
@@ -41,6 +48,23 @@ defineProps({
background: rgba(255, 255, 255, 58%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.9375rem;
color: inherit;
text-decoration: none;
transition: transform 0.2s ease, box-shadow 0.2s ease;
&--linked {
cursor: pointer;
&:hover {
transform: translateY(-2px);
box-shadow: 0 9px 24px rgba(0, 0, 0, 7%);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 3px;
}
}
&__corner {
position: absolute;
+4 -1
View File
@@ -371,12 +371,15 @@ onBeforeUnmount(() => unmountFloating())
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.25rem 0.75rem;
min-height: 2.5rem;
padding: 0.5rem 0.75rem;
border: none;
border-radius: 1.5rem;
background: transparent;
text-align: right;
font-family: var(--font-family-fa);
font-weight: 400;
line-height: 1.5;
cursor: pointer;
transition: background-color 0.15s;
+20 -1
View File
@@ -19,6 +19,7 @@ export const fields = {
seminaryCode: 'کد حوزوی',
seminaryServiceCenterCode: 'کد مرکز خدمات حوزه',
childrenCount: 'تعداد فرزند',
marriageStatus: 'وضعیت تأهل',
birthDate: 'تاریخ تولد',
birthday: 'تاریخ تولد',
@@ -32,12 +33,17 @@ export const fields = {
address: 'آدرس',
title: 'عنوان',
subject: 'موضوع',
category: 'نوع خدمت',
note: 'یادداشت',
description: 'توضیحات',
bio: 'بیوگرافی',
message: 'پیام',
content: 'محتوا',
rejectionReason: 'دلیل رد',
reason: 'دلیل رد',
adminNote: 'یادداشت مدیر',
reviewerNote: 'یادداشت ارزیاب',
activitySummary: 'خلاصهٔ فعالیت',
avatar: 'تصویر پروفایل',
@@ -53,12 +59,15 @@ export const fields = {
startDate: 'تاریخ شروع',
endDate: 'تاریخ پایان',
deadline: 'مهلت تحویل',
fromDate: 'از تاریخ',
toDate: 'تا تاریخ',
capacity: 'ظرفیت',
minCapacity: 'حداقل ظرفیت',
maxCapacity: 'حداکثر ظرفیت',
score: 'نمره',
minimumScore: 'حد نصاب قبولی',
order: 'ترتیب',
priority: 'اولویت',
@@ -71,22 +80,32 @@ export const fields = {
termId: 'ترم',
courseId: 'دوره',
sessionId: 'جلسه',
missionaryRequestId: 'درخواست مبلغ',
sessionType: 'نوع جلسه',
type: 'نوع جلسه',
link: 'لینک جلسه',
teacherId: 'مدرس',
studentIds: 'دانش‌آموزان',
prerequisiteCourseIds: 'دوره‌های پیش‌نیاز',
contentMediaId: 'فایل محتوای دوره',
coverMediaId: 'تصویر جلد',
imageId: 'تصویر',
educationStatus: 'وضعیت تحصیلی',
fieldOfStudy: 'رشتهٔ تحصیلی',
seminaryLevel: 'مقطع حوزه',
universityLevel: 'مقطع دانشگاهی',
universityName: 'نام حوزه علمیه / دانشگاه',
degree: 'مقطع تحصیلی',
jobTitle: 'عنوان شغل',
organizationName: 'نام سازمان',
hijabApproach: 'رویکرد حجاب',
prerequisites: 'پیش‌نیازها',
specializedTopics: 'موضوعات تخصصی',
propagationPlatforms: 'بسترهای تبلیغ',
propagationMethodDescription: 'شرح روش تبلیغ',
propagationExperienceYears: نوات تجربهٔ تبلیغ',
propagationExperienceYears: ابقه فعالیت تبلیغی',
relevantCertificates: 'گواهی‌نامه‌های مرتبط',
onlinePlatformDetails: 'جزئیات بستر آنلاین',
+1 -3
View File
@@ -1,7 +1,5 @@
import { string } from 'yup'
import { fields } from '@/constants/fields'
export const phoneNumberRule = string()
.required()
.matches(/^09\d{9}$/, ({ path }) => `${fields[path] || path} نامعتبر است`)
.matches(/^09\d{9}$/, 'شماره موبایل باید با ۰۹ شروع شود و ۱۱ رقم باشد')
+11 -3
View File
@@ -3,9 +3,13 @@ import { array, boolean, mixed, number, object, string } from 'yup'
export const courseSchema = object().shape({
title: string().required().min(3).max(255),
teacherId: mixed().required(),
capacity: number().typeError('ظرفیت باید عدد باشد').required().min(1),
capacity: number()
.typeError('ظرفیت باید به‌صورت عدد وارد شود')
.required()
.integer('ظرفیت باید عدد صحیح باشد')
.min(1, 'ظرفیت باید حداقل ۱ نفر باشد'),
prerequisiteCourseIds: array().nullable().default([]),
contentMediaId: number().nullable().notRequired(),
coverMediaId: number().typeError('شناسه تصویر دوره باید عدد باشد').nullable().notRequired(),
description: string().nullable().notRequired(),
termId: mixed().required(),
isActive: boolean().nullable().notRequired(),
@@ -14,7 +18,11 @@ export const courseSchema = object().shape({
export const offeredCourseSchema = object().shape({
termId: string().required(),
title: string().required(),
capacity: string().required(),
capacity: number()
.typeError('ظرفیت باید به‌صورت عدد وارد شود')
.required()
.integer('ظرفیت باید عدد صحیح باشد')
.min(1, 'ظرفیت باید حداقل ۱ نفر باشد'),
imageId: string().nullable().notRequired(),
isActive: boolean().nullable().notRequired(),
})
+45 -25
View File
@@ -9,7 +9,7 @@
</template>
</BoxedIconTitleBlock>
<SkeletonLoaderBlock v-if="isLoading" :rows="1" :cols-per-row="3" />
<SkeletonLoaderBlock v-if="isLoading" :rows="2" :cols-per-row="3" />
<div v-else class="dashboard__stats">
<DashboardStatCard
v-for="(stat, i) in institutionStats"
@@ -19,6 +19,7 @@
:value="stat.value"
:unit="stat.unit"
:subtitle="stat.subtitle"
:to="stat.to"
/>
</div>
</section>
@@ -95,37 +96,56 @@ const ACCESS_LABELS = {
limited: 'محدود',
}
const formatThousands = (value) => {
const n = Number(value ?? 0)
return n >= 1000
? { value: toFaDigits(Math.round(n / 1000)), unit: 'هزار' }
: { value: toFaDigits(n), unit: 'نفر' }
}
const institutionStats = computed(() => {
const stats = dashboard.value.stats ?? {}
const users = formatThousands(stats.activeUsers)
return [
{
icon: 'user',
label: 'آمار کلی کاربران',
value: users.value,
unit: users.unit,
subtitle: 'کاربر فعال',
icon: 'users-three',
label: 'مجموع کل متقاضیان',
value: toFaDigits(stats.totalApplicants ?? 0),
unit: 'نفر',
subtitle: 'احراز شده و نشده',
to: { name: 'admin-users' },
},
{
icon: 'video',
label: 'دوره های ایجاد شده',
value: toFaDigits(stats.coursesCreated ?? 0),
unit: 'دوره',
subtitle: 'ایجاد شده',
icon: 'clock',
label: 'تعداد کاربران در انتظار ارزیابی',
value: toFaDigits(stats.awaitingEvaluation ?? 0),
unit: 'نفر',
subtitle: 'در انتظار ارزیابی',
to: { name: 'admin-user-verification' },
},
{
icon: 'shopping-bag',
label: 'دوره های فروش رفته',
value: toFaDigits(stats.coursesSold ?? 0),
unit: 'عدد',
subtitle: 'دوره فروش رفته',
icon: 'book',
label: 'آمار طلبه‌های ترم ۱',
value: toFaDigits(stats.term1Students ?? 0),
unit: 'نفر',
subtitle: 'ترم اول',
to: { name: 'admin-terms' },
},
{
icon: 'book',
label: 'آمار طلبه‌های ترم ۲',
value: toFaDigits(stats.term2Students ?? 0),
unit: 'نفر',
subtitle: 'ترم دوم',
to: { name: 'admin-terms' },
},
{
icon: 'book',
label: 'آمار طلبه‌های ترم ۳',
value: toFaDigits(stats.term3Students ?? 0),
unit: 'نفر',
subtitle: 'ترم سوم',
to: { name: 'admin-terms' },
},
{
icon: 'graduation-cap',
label: 'آمار فارغ‌التحصیلان',
value: toFaDigits(stats.graduates ?? 0),
unit: 'نفر',
subtitle: 'فارغ‌التحصیل',
to: { name: 'admin-users' },
},
]
})
@@ -246,7 +266,7 @@ const calendar = useTehranCalendar()
gap: 1.5rem;
@media (min-width: 1024px) {
width: 19.625rem;
width: clamp(21rem, 26vw, 24rem);
flex-shrink: 0;
border-inline-start: 1px solid #e6e6e6;
padding-inline-start: 2rem;
@@ -12,12 +12,8 @@
<div class="term-item__dates">
<div class="term-item__date">
<span class="term-item__date-label">تاریخ شروع:</span>
<span class="term-item__date-value">{{ startDate || '—' }}</span>
</div>
<div class="term-item__date">
<span class="term-item__date-label">تاریخ پایان:</span>
<span class="term-item__date-value">{{ endDate || '—' }}</span>
<span class="term-item__date-label">تاریخ ایجاد:</span>
<span class="term-item__date-value">{{ createdDate || '—' }}</span>
</div>
</div>
@@ -80,8 +76,7 @@ const props = defineProps({
const emit = defineEmits(['edit', 'delete', 'clone', 'change-status', 'show-details'])
const startDate = computed(() => formatJalaaliDate(props.term.startsAt) || '')
const endDate = computed(() => formatJalaaliDate(props.term.endsAt) || '')
const createdDate = computed(() => formatJalaaliDate(props.term.createdAt) || '')
</script>
<style lang="scss" scoped>
@@ -163,8 +163,6 @@ const invalidate = () => {
}
const onAttach = async (template) => {
console.log(template)
if (!termId.value) return
pendingId.value = template.id
try {
@@ -21,12 +21,8 @@
<div class="term-details__overview-grid">
<LineInfoBlock title="عنوان ترم" :desc="term.title || '-'" />
<LineInfoBlock
title="تاریخ شروع"
:numeric-desc="formatJalaaliDate(term.startsAt) || '-'"
/>
<LineInfoBlock
title="تاریخ پایان"
:numeric-desc="formatJalaaliDate(term.endsAt) || '-'"
title="تاریخ ایجاد"
:numeric-desc="formatJalaaliDate(term.createdAt) || '-'"
/>
<LineInfoBlock title="تعداد طلبه‌ها" :numeric-desc="term.studentsCount ?? 0" />
<LineInfoBlock title="تعداد دوره‌ها" :numeric-desc="term.coursesCount ?? 0" />
@@ -30,24 +30,6 @@
</template>
</TextField>
</div>
<div class="term-form__cell term-form__cell--third">
<DatePickerField
v-model="form.startsAt"
name="startsAt"
label="تاریخ شروع"
:min="todayIso"
:error="errors.startsAt"
/>
</div>
<div class="term-form__cell term-form__cell--third">
<DatePickerField
v-model="form.endsAt"
name="endsAt"
label="تاریخ پایان"
:min="todayIso"
:error="errors.endsAt"
/>
</div>
<div class="term-form__cell term-form__cell--third">
<TextField
v-model="form.minimumScore"
@@ -136,7 +118,6 @@ import ImageCropper from '@/components/form/ImageCropper.vue'
import { objectToFormData } from '@/utils/object-to-formdata'
import { useUploadMediaMutation } from '@/services/query/auth'
import TextareaField from '@/components/form/TextareaField.vue'
import DatePickerField from '@/components/form/DatePickerField.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import {
adminTermsKeys,
@@ -152,14 +133,10 @@ const queryClient = useQueryClient()
const termId = computed(() => (route.params.id ? Number(route.params.id) : null))
const isEditMode = computed(() => !!termId.value)
const todayIso = new Date().toISOString()
const form = ref({
title: '',
description: '',
isActive: true,
startsAt: '',
endsAt: '',
minimumScore: '',
score: '',
coverMediaId: null,
@@ -181,8 +158,6 @@ watch(
title: term.title || '',
description: term.description || '',
isActive: term.isActive ?? true,
startsAt: term.startsAt || '',
endsAt: term.endsAt || '',
minimumScore: term.minimumScore ?? '',
score: term.score ?? '',
}
+1 -3
View File
@@ -4,11 +4,9 @@ export const termSchema = object().shape({
title: string().required().min(3),
description: string().nullable().notRequired(),
isActive: boolean().default(true),
startsAt: string().required(),
endsAt: string().required(),
minimumScore: number().typeError('حد نصاب قبولی باید عدد باشد').required(),
score: number().typeError('کل نمره آزمون باید عدد باشد').required(),
coverMediaId: number().nullable().notRequired(),
coverMediaId: number().typeError('شناسه تصویر جلد باید عدد باشد').nullable().notRequired(),
})
export const addTermStudentSchema = object().shape({
@@ -8,10 +8,15 @@
:show-close-button="true"
>
<template #default>
<div v-if="!user" class="user-verification-details">
<div v-if="detailsLoading || !user" class="user-verification-details">
<p class="user-verification-details__loading">در حال بارگذاری</p>
</div>
<div v-else class="user-verification-details">
<div v-else ref="pdfContent" class="user-verification-details">
<header class="user-verification-details__pdf-header">
<h1>اطلاعات احراز هویت کاربر</h1>
<p>{{ fullName }}</p>
</header>
<section class="user-verification-details__section">
<div class="user-verification-details__grid user-verification-details__grid--6">
<LineInfoBlock title="نام و نام خانوادگی" :numeric-desc="fullName" />
@@ -178,15 +183,33 @@
class="user-verification-details__video"
/>
</section>
<div class="user-verification-details__divider" />
<div class="user-verification-details__actions" data-pdf-ignore>
<BaseButton
text="ذخیره به صورت PDF"
:loading="pdfLoading"
custom-class="user-verification-details__pdf-btn"
@click="onSaveAsPdf"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
</template>
</BaseButton>
</div>
</div>
</template>
</BasicModal>
</template>
<script setup>
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { toast } from 'vue3-toastify'
import useModal from '@/composables/useModal'
import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { formatJalaaliDate } from '@/utils/date-utils'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import VoiceRecorder from '@/components/form/VoiceRecorder.vue'
@@ -213,15 +236,19 @@ const modalData = computed(() => getModal('UserVerificationDetailsModal')?.data
const userId = computed(() => modalData.value.id ?? null)
const userIdRef = computed(() => userId.value)
const { data: fetched } = useAdminUserQuery(userIdRef, {
const { data: fetched, isLoading: userLoading } = useAdminUserQuery(userIdRef, {
enabled: () => !!userIdRef.value,
})
const { data: registerData } = useAdminUserRegisterDataQuery(userIdRef, {
enabled: () => !!userIdRef.value,
})
const { data: registerData, isLoading: registerDataLoading } = useAdminUserRegisterDataQuery(
userIdRef,
{
enabled: () => !!userIdRef.value,
}
)
const user = computed(() => modalData.value.user || fetched.value || null)
const detailsLoading = computed(() => userLoading.value || registerDataLoading.value)
const profile = computed(() => ({ ...user.value?.profile, ...registerData.value }))
@@ -263,6 +290,49 @@ const onlinePlatformDetails = computed(
// `faithProductionAudio` / `leaderMessageVideo` (each carries a `url`).
const faithProduction = computed(() => profile.value.faithProductionAudio || null)
const leaderMessage = computed(() => profile.value.leaderMessageVideo || null)
const pdfContent = ref(null)
const pdfLoading = ref(false)
const onSaveAsPdf = async () => {
if (!pdfContent.value || !userId.value || detailsLoading.value || pdfLoading.value) return
pdfLoading.value = true
try {
await document.fonts?.ready
const pdfSource = pdfContent.value.cloneNode(true)
pdfSource.classList.add('user-verification-details--pdf')
pdfSource.querySelectorAll('[data-pdf-ignore]').forEach((element) => element.remove())
const html2pdfModule = await import('html2pdf.js')
const html2pdf = html2pdfModule.default || html2pdfModule
await html2pdf()
.set({
margin: [8, 8, 8, 8],
filename: `user-verification-${userId.value}.pdf`,
image: { type: 'jpeg', quality: 0.98 },
html2canvas: {
scale: 2,
useCORS: true,
backgroundColor: '#ffffff',
logging: false,
},
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' },
pagebreak: {
mode: ['css', 'legacy'],
avoid: ['.user-verification-details__section'],
},
})
.from(pdfSource)
.save()
} catch {
toast.error('ساخت فایل PDF با خطا مواجه شد.')
} finally {
pdfLoading.value = false
}
}
</script>
<style lang="scss" scoped>
@@ -270,6 +340,10 @@ const leaderMessage = computed(() => profile.value.leaderMessageVideo || null)
width: 100%;
text-align: start;
&__pdf-header {
display: none;
}
&__loading {
text-align: center;
padding: 2rem;
@@ -439,5 +513,61 @@ const leaderMessage = computed(() => profile.value.leaderMessageVideo || null)
&__pdf-btn {
min-width: 12rem;
}
&--pdf {
width: 100%;
padding: 0.25rem;
background: #fff;
box-sizing: border-box;
direction: rtl;
}
&--pdf &__pdf-header {
display: block;
margin-bottom: 1.5rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #eee;
text-align: right;
break-after: avoid;
h1 {
margin: 0;
font-family: var(--font-family-fa);
font-size: 1.25rem;
font-weight: 500;
color: #4b4b4b;
}
p {
margin: 0.25rem 0 0;
font-family: var(--font-family-fa);
font-size: 0.875rem;
color: #808080;
}
}
&--pdf &__section {
margin-bottom: 1rem;
break-inside: avoid;
page-break-inside: avoid;
}
&--pdf &__grid--4 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
&--pdf &__grid--6 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
&--pdf &__cell--span-3 {
grid-column: span 3;
}
&--pdf :deep(.line-info__title) {
overflow: visible;
text-overflow: initial;
white-space: normal;
}
}
</style>
+1 -1
View File
@@ -20,7 +20,7 @@ const baseUserShape = {
provinceId: string().nullable().notRequired(),
cityId: string().nullable().notRequired(),
address: string().nullable().notRequired(),
avatarMediaId: number().nullable().notRequired(),
avatarMediaId: number().typeError('شناسه تصویر پروفایل باید عدد باشد').nullable().notRequired(),
}
export const userAddSchema = object().shape({
@@ -89,9 +89,9 @@
<script setup>
import useYup from '@/composables/useYup'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import TextField from '@/components/form/TextField.vue'
import SelectField from '@/components/form/SelectField.vue'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import TextareaField from '@/components/form/TextareaField.vue'
import { useSaveRegisterDataMutation } from '@/services/query/auth'
@@ -124,6 +124,8 @@ const universityLevelOptions = Object.entries(UNIVERSITY_LEVEL).map(([value, lab
const form = ref({ ...store.educational })
watch(form, (value) => store.updateSection('educational', value), { deep: true })
const { validate, validateAt, errors } = useYup(educationalInformationSchema)
const saveRegisterMutation = useSaveRegisterDataMutation()
@@ -136,8 +138,6 @@ const onSubmit = async () => {
store.markStepAsCompleted(STUDENT_REGISTRATION.EDUCATIONAL_INFORMATION)
store.goToNextStep()
}
store.updateCurrentStep(STUDENT_REGISTRATION.EDUCATIONAL_INFORMATION)
</script>
<style lang="scss" scoped>
@@ -76,9 +76,9 @@
<script setup>
import { toast } from 'vue3-toastify'
import useModal from '@/composables/useModal'
import { computed, onBeforeUnmount, ref } from 'vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { objectToFormData } from '@/utils/object-to-formdata'
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
import { useUploadMediaMutation } from '@/services/query/auth'
import PermissionModal from '@/features/auth/components/studentRegister/PermissionModal.vue'
@@ -170,9 +170,6 @@ const onStart = async () => {
try {
const constraints = props.kind === 'video' ? { audio: true, video: true } : { audio: true }
mediaStream = await navigator.mediaDevices.getUserMedia(constraints)
if (props.kind === 'video' && liveEl.value) {
liveEl.value.srcObject = mediaStream
}
chunks = []
recorderType = pickRecorderType(props.kind)
mediaRecorder = new window.MediaRecorder(mediaStream, { mimeType: recorderType.mime })
@@ -180,8 +177,16 @@ const onStart = async () => {
if (e.data?.size > 0) chunks.push(e.data)
}
mediaRecorder.onstop = () => handleStop()
mediaRecorder.start()
isRecording.value = true
// The live video is conditionally rendered by isRecording, so wait until
// Vue mounts it before attaching the camera stream.
await nextTick()
if (props.kind === 'video' && liveEl.value) {
liveEl.value.srcObject = mediaStream
}
mediaRecorder.start()
elapsedMs.value = 0
timerStart = Date.now()
timerHandle = setInterval(() => {
@@ -230,16 +230,15 @@ import { toast } from 'vue3-toastify'
import useYup from '@/composables/useYup'
import useAuth from '@/composables/useAuth'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { convertToJalali } from '@/utils/date-utils'
import { useQueryClient } from '@tanstack/vue-query'
import TextField from '@/components/form/TextField.vue'
import SelectField from '@/components/form/SelectField.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ImageCropper from '@/components/form/ImageCropper.vue'
import { objectToFormData } from '@/utils/object-to-formdata'
import TextareaField from '@/components/form/TextareaField.vue'
import DatePickerField from '@/components/form/DatePickerField.vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { GENDER, MARITAL_STATUS, STUDENT_REGISTRATION } from '@/enums'
import { personalInformationSchema } from '@/features/auth/schema/student-register'
import StepActions from '@/features/auth/components/studentRegister/StepActions.vue'
@@ -269,15 +268,14 @@ const maritalStatusOptions = Object.entries(MARITAL_STATUS).map(([value, label])
}))
const genderOptions = Object.entries(GENDER).map(([value, label]) => ({ value, label }))
const maxBirthDate = computed(() => {
const maxBirthDate = (() => {
const d = new Date()
d.setFullYear(d.getFullYear() - 15)
try {
return convertToJalali(d.toISOString())
} catch {
return d.toISOString()
}
})
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
})()
const initialPhone = ref(user.value?.phone || store.personal.phone || '')
const initialNationalCode = ref(user.value?.nationalCode || store.personal.nationalCode || '')
@@ -305,6 +303,8 @@ const form = ref({
avatarId: store.personal.avatarId || '',
})
watch(form, (value) => store.updateSection('personal', value), { deep: true })
const avatar = ref(initialAvatar)
const { validate, validateAt, errors } = useYup(personalInformationSchema)
@@ -316,8 +316,8 @@ const { data: cities = ref([]) } = useGetCitiesOfProvinceQuery(provinceIdRef, {
enabled: () => !!provinceIdRef.value,
})
const onProvinceChange = () => {
provinceIdRef.value = form.value.provinceId
const onProvinceChange = (provinceId) => {
provinceIdRef.value = provinceId
form.value.cityId = null
}
@@ -382,8 +382,6 @@ const onSubmit = async () => {
store.markStepAsCompleted(STUDENT_REGISTRATION.PERSONAL_INFORMATION)
store.goToNextStep()
}
store.updateCurrentStep(STUDENT_REGISTRATION.PERSONAL_INFORMATION)
</script>
<style lang="scss" scoped>
@@ -111,10 +111,10 @@ import SvgIcon from '@/components/icons/SvgIcon.vue'
import TextField from '@/components/form/TextField.vue'
import SelectField from '@/components/form/SelectField.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import TextareaField from '@/components/form/TextareaField.vue'
import { useSaveRegisterDataMutation } from '@/services/query/auth'
import { PROPAGATION_PLATFORM, STUDENT_REGISTRATION } from '@/enums'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import StepActions from '@/features/auth/components/studentRegister/StepActions.vue'
import { professionalInformationSchema } from '@/features/auth/schema/student-register'
import { useStudentRegistrationStore } from '@/features/auth/store/student-registration'
@@ -150,6 +150,8 @@ const form = ref({
onlinePlatformDetails: seedOnline,
})
watch(form, (value) => store.updateSection('professional', value), { deep: true })
const hasOther = computed(() => form.value.propagationPlatforms.includes('other'))
const hasOnline = computed(() => form.value.propagationPlatforms.includes('online'))
@@ -195,8 +197,6 @@ const onSubmit = async () => {
store.markStepAsCompleted(STUDENT_REGISTRATION.PROFESSIONAL_INFORMATION)
store.goToNextStep()
}
store.updateCurrentStep(STUDENT_REGISTRATION.PROFESSIONAL_INFORMATION)
</script>
<style lang="scss" scoped>
@@ -7,33 +7,57 @@
<div class="sidebar-steps__logo">
<img :src="logoWhite" alt="مدرسه بانو" />
</div>
<ul class="sidebar-steps__list">
<li
v-for="tab in tabs"
:key="tab.key"
class="sidebar-steps__item"
:class="{ 'sidebar-steps__item--active': activeTab === tab.key }"
@click="emit('update:active-tab', tab.key)"
>
<SvgIcon :name="tab.icon" :size="22" color="#fff" />
<ul ref="stepsList" class="sidebar-steps__list scrollbar-thin">
<li v-for="tab in tabs" :key="tab.step" class="sidebar-steps__item">
<button
type="button"
class="sidebar-steps__button"
:class="{
'sidebar-steps__button--active': activeStep === tab.step,
'sidebar-steps__button--completed': tab.completed,
}"
:disabled="tab.disabled"
:aria-label="`${tab.title}، مرحله ${tab.step}`"
:aria-current="activeStep === tab.step ? 'step' : undefined"
@click="emit('select-step', tab.step)"
>
<span class="sidebar-steps__icon">
<SvgIcon :name="tab.completed ? 'check-square' : tab.icon" :size="20" color="#fff" />
</span>
<span class="sidebar-steps__label">{{ tab.title }}</span>
<span class="sidebar-steps__number">{{ tab.step }}</span>
</button>
</li>
</ul>
<SidebarLogout color="#fff" stacked />
<div class="sidebar-steps__logout">
<SidebarLogout color="#fff" />
</div>
</div>
</aside>
</template>
<script setup>
import { gallery } from '@/utils/gallery'
import { nextTick, ref, watch } from 'vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import SidebarLogout from '@/layouts/sidebars/SidebarLogout.vue'
defineProps({
const props = defineProps({
tabs: { type: Array, required: true },
activeTab: { type: String, default: '' },
activeStep: { type: Number, default: 1 },
})
const emit = defineEmits(['update:active-tab'])
const emit = defineEmits(['select-step'])
const stepsList = ref(null)
const scrollActiveStepIntoView = async () => {
await nextTick()
stepsList.value
?.querySelector('[aria-current="step"]')
?.scrollIntoView({ block: 'nearest', inline: 'center' })
}
watch(() => props.activeStep, scrollActiveStepIntoView, { immediate: true })
const logoWhite = gallery.logoWhite
const logoPinkishRed = gallery.logoPinkishRed
@@ -67,14 +91,11 @@ const logoPinkishRed = gallery.logoPinkishRed
gap: 1rem;
@media (min-width: 1024px) {
// Fill the full-height column only on desktop; on mobile the panel is a
// content-height bar, so height:100% would overflow past the logo above it
// and collide with the page heading.
height: 100%;
flex-direction: column;
align-items: center;
gap: 1.5rem;
padding: 1.25rem 0.75rem;
align-items: stretch;
gap: 1rem;
padding: 1.25rem 0.875rem;
border-radius: 1.5rem;
}
}
@@ -101,30 +122,127 @@ const logoPinkishRed = gallery.logoPinkishRed
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 1rem;
justify-content: flex-start;
gap: 0.375rem;
flex: 1;
min-width: 0;
overflow-x: auto;
@media (min-width: 1024px) {
flex-direction: column;
justify-content: flex-start;
gap: 1.25rem;
padding-top: 1rem;
gap: 0.25rem;
padding-top: 0.5rem;
overflow: hidden auto;
}
}
&__item {
cursor: pointer;
opacity: 0.5;
transition: opacity 0.15s ease;
flex: 0 0 auto;
min-width: 0;
@media (min-width: 1024px) {
width: 100%;
}
}
&__button {
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
gap: 0.5rem;
width: 2.75rem;
min-height: 2.75rem;
padding: 0.375rem;
border: 1px solid transparent;
border-radius: 0.875rem;
background: transparent;
color: #fff;
cursor: pointer;
opacity: 0.62;
transition: opacity 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
&--active,
&:hover {
@media (min-width: 1024px) {
justify-content: flex-start;
width: 100%;
padding: 0.5rem 0.625rem;
}
&:hover:not(:disabled),
&--active {
opacity: 1;
background: rgba(255, 255, 255, 14%);
border-color: rgba(255, 255, 255, 22%);
}
&--completed {
&:not(.sidebar-steps__button--active) {
opacity: 0.85;
}
}
&:disabled {
cursor: not-allowed;
opacity: 0.32;
}
}
&__icon {
display: inline-flex;
flex-shrink: 0;
}
&__label {
display: none;
min-width: 0;
overflow: hidden;
font-family: var(--font-family-fa);
font-size: 0.875rem;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
@media (min-width: 1024px) {
display: block;
}
}
&__number {
display: none;
margin-inline-start: auto;
font-family: var(--font-family-number-fa);
font-size: 0.75rem;
opacity: 0.75;
@media (min-width: 1024px) {
display: inline;
}
}
&__logout {
flex: 0 0 auto;
:deep(.sidebar-logout) {
padding: 0.25rem;
}
:deep(.sidebar-logout__labels) {
display: none;
}
@media (min-width: 1024px) {
border-top: 1px solid rgba(255, 255, 255, 20%);
padding-top: 0.5rem;
:deep(.sidebar-logout) {
justify-content: flex-start;
width: 100%;
padding: 0.5rem 0.625rem;
}
:deep(.sidebar-logout__labels) {
display: flex;
}
}
}
}
@@ -43,7 +43,7 @@
</div>
<div class="skill-five__divider" />
<StepActions @back="store.goToPrevStep()" />
<StepActions :loading="saveRegisterMutation.isPending.value" @back="store.goToPrevStep()" />
</form>
</template>
@@ -51,9 +51,10 @@
import useYup from '@/composables/useYup'
import { STUDENT_REGISTRATION } from '@/enums'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import TextareaField from '@/components/form/TextareaField.vue'
import { useSaveRegisterDataMutation } from '@/services/query/auth'
import StepActions from '@/features/auth/components/studentRegister/StepActions.vue'
import { skillAssessmentPart5Schema } from '@/features/auth/schema/student-register'
import { useStudentRegistrationStore } from '@/features/auth/store/student-registration'
@@ -69,17 +70,19 @@ onBeforeUnmount(() => window.removeEventListener('resize', onResize))
const form = ref({ ...store.skill5 })
watch(form, (value) => store.updateSection('skill5', value), { deep: true })
const { validate, validateAt, errors } = useYup(skillAssessmentPart5Schema)
const saveRegisterMutation = useSaveRegisterDataMutation()
const onSubmit = async () => {
const { isValid, payload } = await validate(form.value)
if (!isValid) return
await saveRegisterMutation.mutateAsync(payload)
store.skill5 = { ...store.skill5, ...payload }
store.markStepAsCompleted(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_5)
store.goToNextStep()
}
store.updateCurrentStep(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_5)
</script>
<style lang="scss" scoped>
@@ -10,7 +10,6 @@
purpose="voice"
:max-seconds="180"
:error="errors.faithProductionAudio"
@uploaded="onUploaded"
/>
</div>
<div class="skill-six__content-col">
@@ -23,7 +22,8 @@
<video :src="videoUrl" controls playsinline class="skill-six__video" />
</div>
<div v-else class="skill-six__video-placeholder">
<p>ویدیویی در دسترس نیست</p>
<p>ویدیوی راهنما هنوز بارگذاری نشده است.</p>
<p>تا زمان انتشار ویدیو، ارسال پاسخ در این مرحله اختیاری است.</p>
</div>
</div>
</div>
@@ -34,8 +34,8 @@
</template>
<script setup>
import { computed, ref } from 'vue'
import useYup from '@/composables/useYup'
import { computed, ref, watch } from 'vue'
import { STUDENT_REGISTRATION } from '@/enums'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import { useSaveRegisterDataMutation } from '@/services/query/auth'
@@ -55,10 +55,12 @@ const form = ref({
const { validate, errors } = useYup(skillAssessmentPart6Schema)
const onUploaded = (value) => {
watch(audio, (value) => {
form.value.faithProductionAudio = value
form.value.faithProductionId = value?.uploadId || ''
}
})
watch(form, (value) => store.updateSection('skill6', value), { deep: true })
const videoUrl = computed(() => store.registrationMedia.faithProductionVideoUrl || '')
@@ -72,8 +74,6 @@ const onSubmit = async () => {
store.markStepAsCompleted(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_6)
store.goToNextStep()
}
store.updateCurrentStep(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_6)
</script>
<style lang="scss" scoped>
@@ -107,8 +107,8 @@ store.updateCurrentStep(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_6)
&__intro {
font-family: var(--font-family-fa);
font-weight: 300;
font-size: 0.875rem;
font-weight: 400;
font-size: 0.9375rem;
line-height: 1.7;
margin: 0 0 0.75rem;
text-align: justify;
@@ -130,11 +130,16 @@ store.updateCurrentStep(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_6)
&__video-placeholder {
border-radius: 1rem;
background: #f3f4f6;
color: var(--color-thd-gray);
color: #666;
padding: 2rem;
text-align: center;
font-family: var(--font-family-fa);
font-size: 0.875rem;
font-size: 0.9375rem;
font-weight: 400;
p + p {
margin-top: 0.5rem;
}
}
&__divider {
@@ -9,7 +9,6 @@
context="verification"
:max-seconds="180"
:error="errors.leaderMessageVideo"
@uploaded="onUploaded"
/>
</div>
<div class="skill-seven__content-col">
@@ -22,7 +21,8 @@
<video :src="videoUrl" controls playsinline class="skill-seven__video" />
</div>
<div v-else class="skill-seven__video-placeholder">
<p>ویدیویی در دسترس نیست</p>
<p>ویدیوی راهنما هنوز بارگذاری نشده است.</p>
<p>تا زمان انتشار ویدیو، ارسال پاسخ در این مرحله اختیاری است.</p>
</div>
</div>
</div>
@@ -37,9 +37,9 @@
</template>
<script setup>
import { computed, ref } from 'vue'
import { toast } from 'vue3-toastify'
import useYup from '@/composables/useYup'
import { computed, ref, watch } from 'vue'
import { STUDENT_REGISTRATION } from '@/enums'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import { useSaveRegisterDataMutation } from '@/services/query/auth'
@@ -59,10 +59,12 @@ const form = ref({
const { validate, errors } = useYup(skillAssessmentPart7Schema)
const onUploaded = (value) => {
watch(video, (value) => {
form.value.leaderMessageVideo = value
form.value.leaderMessageId = value?.uploadId || ''
}
})
watch(form, (value) => store.updateSection('skill7', value), { deep: true })
const videoUrl = computed(() => store.registrationMedia.leaderMessageVideoUrl || '')
@@ -80,8 +82,6 @@ const onSubmit = async () => {
toast.error(error?.response?.data?.message || 'عملیات با خطا مواجه شد')
}
}
store.updateCurrentStep(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_7)
</script>
<style lang="scss" scoped>
@@ -115,8 +115,8 @@ store.updateCurrentStep(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_7)
&__intro {
font-family: var(--font-family-fa);
font-weight: 300;
font-size: 0.875rem;
font-weight: 400;
font-size: 0.9375rem;
line-height: 1.7;
margin: 0 0 0.75rem;
text-align: justify;
@@ -138,11 +138,16 @@ store.updateCurrentStep(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_7)
&__video-placeholder {
border-radius: 1rem;
background: #f3f4f6;
color: var(--color-thd-gray);
color: #666;
padding: 2rem;
text-align: center;
font-family: var(--font-family-fa);
font-size: 0.875rem;
font-size: 0.9375rem;
font-weight: 400;
p + p {
margin-top: 0.5rem;
}
}
&__divider {
@@ -42,9 +42,9 @@ import useYup from '@/composables/useYup'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import SelectField from '@/components/form/SelectField.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import TextareaField from '@/components/form/TextareaField.vue'
import { useSaveRegisterDataMutation } from '@/services/query/auth'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import StepActions from '@/features/auth/components/studentRegister/StepActions.vue'
import { useStudentRegistrationStore } from '@/features/auth/store/student-registration'
@@ -73,6 +73,8 @@ const form = ref({
[props.descField]: seed[props.descField] || '',
})
watch(form, (value) => store.updateSection(props.storeKey, value), { deep: true })
const hasOther = computed(() => form.value[props.selectField] === 'other')
const onSelect = () => {
@@ -91,8 +93,6 @@ const onSubmit = async () => {
store.markStepAsCompleted(props.step)
store.goToNextStep()
}
store.updateCurrentStep(props.step)
</script>
<style lang="scss" scoped>
@@ -115,8 +115,8 @@ store.updateCurrentStep(props.step)
p {
font-family: var(--font-family-fa);
font-weight: 300;
font-size: 0.875rem;
font-weight: 400;
font-size: 0.9375rem;
line-height: 1.7;
color: #a7a7a7;
text-align: justify;
@@ -45,21 +45,27 @@ const emit = defineEmits(['back'])
<style lang="scss" scoped>
.step-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
&__back {
flex: 1 1 60%;
flex: 1 1 10rem;
min-width: 0;
display: flex;
justify-content: flex-start;
}
&__next {
flex: 1 1 40%;
flex: 1 1 12rem;
min-width: 0;
max-width: 16rem;
margin-inline-start: auto;
@media (min-width: 768px) {
flex: 0 0 16rem;
@media (max-width: 767px) {
order: -1;
flex-basis: 100%;
max-width: none;
}
}
@@ -67,10 +73,17 @@ const emit = defineEmits(['back'])
color: #969696;
padding: 0 1rem;
width: fit-content;
font-weight: 500;
}
&__next-btn {
width: 100%;
min-height: 2.65rem;
font-weight: 500;
}
:deep(.base-button__text) {
font-size: 0.9375rem;
}
}
</style>
+130 -46
View File
@@ -2,16 +2,26 @@
<div class="student-register">
<div class="student-register__container">
<aside class="student-register__sidebar">
<SideBarSteps :tabs="tabs" :active-tab="activeTab" @update:active-tab="goToStep" />
<SideBarSteps :tabs="tabs" :active-step="store.currentStep" @select-step="goToStep" />
</aside>
<main class="student-register__main">
<header class="student-register__heading">
<div class="student-register__step-meta">
<span>مرحله {{ currentStepNumber }} از {{ totalSteps }}</span>
<span>{{ progressPercent }}٪</span>
</div>
<div class="student-register__progress" aria-hidden="true">
<span :style="{ width: `${progressPercent}%` }" />
</div>
<h1 class="student-register__title">{{ stepLabel.title }}</h1>
<p class="student-register__desc">{{ stepLabel.desc }}</p>
</header>
<component :is="currentComponent" />
<div v-if="isRegistrationDataPending" class="student-register__loading" aria-live="polite">
در حال بازیابی اطلاعات ثبت‌نام
</div>
<component :is="currentComponent" v-else />
</main>
</div>
@@ -23,10 +33,10 @@
import useModal from '@/composables/useModal'
import { computed, markRaw, watch } from 'vue'
import { STUDENT_REGISTRATION } from '@/enums'
import { useGetRegisterDataQuery } from '@/services/query/auth'
import SideBarSteps from '@/features/auth/components/studentRegister/SideBarSteps.vue'
import SuccessModal from '@/features/auth/components/studentRegister/SuccessModal.vue'
import { useStudentRegistrationStore } from '@/features/auth/store/student-registration'
import { useGetRegisterDataQuery, useGetRegistrationVideoQuery } from '@/services/query/auth'
import PersonalInformation from '@/features/auth/components/studentRegister/PersonalInformation.vue'
import SkillAssessmentPart1 from '@/features/auth/components/studentRegister/SkillAssessmentPart1.vue'
import SkillAssessmentPart2 from '@/features/auth/components/studentRegister/SkillAssessmentPart2.vue'
@@ -41,15 +51,21 @@ import ProfessionalInformation from '@/features/auth/components/studentRegister/
const store = useStudentRegistrationStore()
const { isModal, openModal } = useModal()
const { data: registerData } = useGetRegisterDataQuery()
const { data: registerData, isPending: isRegistrationDataPending } = useGetRegisterDataQuery()
watch(
registerData,
(value) => {
if (value && Object.keys(value).length > 0) store.hydrateFromRegisterData(value)
},
{ immediate: true }
{ immediate: true, flush: 'sync' }
)
const { data: registrationMedia } = useGetRegistrationVideoQuery({ retry: false })
watch(registrationMedia, (value) => store.setRegistrationMedia(value), {
immediate: true,
flush: 'sync',
})
const STEP_TO_COMPONENT = {
[STUDENT_REGISTRATION.PERSONAL_INFORMATION]: markRaw(PersonalInformation),
[STUDENT_REGISTRATION.EDUCATIONAL_INFORMATION]: markRaw(EducationalInformation),
@@ -63,26 +79,6 @@ const STEP_TO_COMPONENT = {
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_7]: markRaw(SkillAssessmentPart7),
}
const STEP_TO_TAB = {
[STUDENT_REGISTRATION.PERSONAL_INFORMATION]: 'personal',
[STUDENT_REGISTRATION.EDUCATIONAL_INFORMATION]: 'educational',
[STUDENT_REGISTRATION.PROFESSIONAL_INFORMATION]: 'professional',
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_1]: 'skill',
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_2]: 'skill',
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_3]: 'skill',
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_4]: 'skill',
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_5]: 'skill',
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_6]: 'skill',
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_7]: 'skill',
}
const TAB_TO_STEP = {
personal: STUDENT_REGISTRATION.PERSONAL_INFORMATION,
educational: STUDENT_REGISTRATION.EDUCATIONAL_INFORMATION,
professional: STUDENT_REGISTRATION.PROFESSIONAL_INFORMATION,
skill: STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_1,
}
const STEP_LABELS = {
[STUDENT_REGISTRATION.PERSONAL_INFORMATION]: {
title: 'اطلاعـــــات شخصـــــی',
@@ -126,14 +122,33 @@ const STEP_LABELS = {
},
}
const tabs = [
{ key: 'personal', icon: 'user' },
{ key: 'educational', icon: 'book' },
{ key: 'professional', icon: 'file' },
{ key: 'skill', icon: 'check-square' },
const STEP_NAVIGATION = [
{ step: STUDENT_REGISTRATION.PERSONAL_INFORMATION, title: 'مشخصات فردی', icon: 'user' },
{ step: STUDENT_REGISTRATION.EDUCATIONAL_INFORMATION, title: 'اطلاعات تحصیلی', icon: 'book' },
{ step: STUDENT_REGISTRATION.PROFESSIONAL_INFORMATION, title: 'فعالیت‌های تبلیغی', icon: 'file' },
{ step: STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_1, title: 'مهارت ۱', icon: 'check-square' },
{ step: STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_2, title: 'مهارت ۲', icon: 'check-square' },
{ step: STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_3, title: 'مهارت ۳', icon: 'check-square' },
{ step: STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_4, title: 'مهارت ۴', icon: 'check-square' },
{ step: STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_5, title: 'مهارت ۵', icon: 'check-square' },
{ step: STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_6, title: 'مهارت ۶', icon: 'check-square' },
{ step: STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_7, title: 'مهارت ۷', icon: 'check-square' },
]
const activeTab = computed(() => STEP_TO_TAB[store.currentStep] || 'personal')
const tabs = computed(() =>
STEP_NAVIGATION.map((tab) => ({
...tab,
completed: store.completedSteps.includes(tab.step),
disabled: !store.canGoToStep(tab.step),
}))
)
const totalSteps = STEP_NAVIGATION.length
const currentStepNumber = computed(() => {
const index = STEP_NAVIGATION.findIndex((item) => item.step === store.currentStep)
return index >= 0 ? index + 1 : 1
})
const progressPercent = computed(() => Math.round((currentStepNumber.value / totalSteps) * 100))
const currentComponent = computed(
() =>
@@ -145,12 +160,8 @@ const stepLabel = computed(
() => STEP_LABELS[store.currentStep] || STEP_LABELS[STUDENT_REGISTRATION.PERSONAL_INFORMATION]
)
const goToStep = (tabKey) => {
const targetStep = TAB_TO_STEP[tabKey]
if (!targetStep) return
if (store.canGoToStep(targetStep)) {
store.updateCurrentStep(targetStep)
}
const goToStep = (step) => {
store.updateCurrentStep(step)
}
watch(
@@ -188,9 +199,8 @@ watch(
width: 100%;
@media (min-width: 1024px) {
width: 8.333%;
min-width: 5rem;
max-width: 6.5rem;
width: 13.5rem;
min-width: 13.5rem;
}
}
@@ -206,23 +216,97 @@ watch(
}
&__heading {
margin-bottom: 1.25rem;
}
&__step-meta {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.375rem;
color: #6f6f6f;
font-family: var(--font-family-number-fa);
font-size: 0.875rem;
font-weight: 500;
}
&__progress {
width: 100%;
height: 0.35rem;
margin-bottom: 1rem;
overflow: hidden;
border-radius: 999px;
background: #f2f2f2;
span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--color-primary);
transition: width 0.2s ease;
}
}
&__title {
font-family: var(--font-family-fa);
font-weight: 500;
font-size: 1.125rem;
color: #4c4c4c;
margin: 0 0 0.25rem;
font-weight: 600;
font-size: 1.375rem;
color: #3f3f3f;
margin: 0 0 0.375rem;
}
&__desc {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.75rem;
color: #adadad;
font-size: 0.9375rem;
line-height: 1.8;
color: #747474;
margin: 0;
}
&__loading {
padding: 3rem 1rem;
color: #747474;
font-family: var(--font-family-fa);
font-size: 1rem;
font-weight: 500;
text-align: center;
}
&__main :deep(.text-field__label),
&__main :deep(.textarea-field__label),
&__main :deep(.select-field__label),
&__main :deep(.datepicker-field__label) {
color: #626262;
font-size: 0.9375rem;
font-weight: 400;
}
&__main :deep(.text-field__input),
&__main :deep(.textarea-field__input),
&__main :deep(.select-field__trigger),
&__main :deep(.datepicker-field__trigger) {
min-height: 2.6rem;
font-size: 1rem;
font-weight: 400;
}
&__main :deep(.line-title__fa) {
font-size: 1rem;
font-weight: 600;
}
&__main :deep(.line-title__en) {
font-size: 0.8125rem;
font-weight: 400;
}
&__main :deep(.text-field__error),
&__main :deep(.textarea-field__error),
&__main :deep(.select-field__error),
&__main :deep(.datepicker-field__error) {
font-size: 0.8125rem;
font-weight: 400;
}
}
</style>
+16 -9
View File
@@ -1,6 +1,5 @@
import { array, mixed, number, object, string } from 'yup'
import { phoneNumberRule } from '@/constants/rules/phoneNumberRule'
import { nationalCodeRule } from '@/constants/rules/nationalCodeRule'
const requiredString = (min = 0) => {
@@ -16,7 +15,9 @@ export const personalInformationSchema = object().shape({
nationalCode: nationalCodeRule,
maritalStatus: string().required(),
gender: string().required(),
phone: phoneNumberRule,
// The verified phone is read-only here and may be returned in international
// format (for example +98912...). Do not re-validate it as a local 09 number.
phone: string().required(),
provinceId: mixed().required(),
cityId: mixed().required(),
address: requiredString(10),
@@ -25,10 +26,15 @@ export const personalInformationSchema = object().shape({
.matches(/^\d{10}$/, 'کد پستی باید ۱۰ رقم باشد'),
virtualPhoneNumber: string()
.required()
.matches(/^\d{10,15}$/, 'شماره تلفن مجازی نامعتبر است'),
.matches(/^\d{10,15}$/, 'شماره تلفن مجازی باید فقط شامل ۱۰ تا ۱۵ رقم باشد'),
seminaryCode: requiredString(),
seminaryServiceCenterCode: requiredString(),
childrenCount: number().typeError('تعداد فرزند باید عدد باشد').required().min(0).max(20),
childrenCount: number()
.typeError('تعداد فرزند باید به‌صورت عدد وارد شود')
.required()
.integer('تعداد فرزند باید عدد صحیح باشد')
.min(0, 'تعداد فرزند نمی‌تواند کمتر از صفر باشد')
.max(20, 'تعداد فرزند نمی‌تواند بیشتر از ۲۰ باشد'),
})
export const educationalInformationSchema = object().shape({
@@ -42,10 +48,11 @@ export const educationalInformationSchema = object().shape({
export const professionalInformationSchema = object().shape({
propagationExperienceYears: number()
.typeError(نوات تجربه تبلیغ باید عدد باشد')
.typeError(ابقه فعالیت تبلیغی باید به‌صورت عدد وارد شود')
.required()
.min(0)
.max(70),
.integer('سابقه فعالیت تبلیغی باید عدد صحیح باشد')
.min(0, 'سابقه فعالیت تبلیغی نمی‌تواند کمتر از صفر باشد')
.max(70, 'سابقه فعالیت تبلیغی نمی‌تواند بیشتر از ۷۰ سال باشد'),
propagationMethodDescription: requiredString(10),
specializedTopics: requiredString(10),
propagationPlatforms: array()
@@ -112,11 +119,11 @@ export const skillAssessmentPart5Schema = object().shape({
})
export const skillAssessmentPart6Schema = object().shape({
faithProductionAudio: mixed().required(),
faithProductionAudio: mixed().nullable().notRequired(),
faithProductionId: mixed().notRequired(),
})
export const skillAssessmentPart7Schema = object().shape({
leaderMessageVideo: mixed().required(),
leaderMessageVideo: mixed().nullable().notRequired(),
leaderMessageId: mixed().notRequired(),
})
@@ -145,6 +145,21 @@ const readPersisted = () => {
}
}
const normalizeCompletedSteps = (steps) =>
Array.isArray(steps) ? STUDENT_REGISTRATION_ORDER.filter((step) => steps.includes(step)) : []
const firstIncompleteIndex = (steps) => {
const index = STUDENT_REGISTRATION_ORDER.findIndex((step) => !steps.includes(step))
return index === -1 ? STUDENT_REGISTRATION_ORDER.length - 1 : index
}
const resolveCurrentStep = (candidate, completed) => {
const candidateIndex = STUDENT_REGISTRATION_ORDER.indexOf(candidate)
const reachableIndex = firstIncompleteIndex(completed)
if (candidateIndex >= 0 && candidateIndex <= reachableIndex) return candidate
return STUDENT_REGISTRATION_ORDER[reachableIndex]
}
export const useStudentRegistrationStore = defineStore('studentRegistration', () => {
const persisted = readPersisted() || {}
const initial = { ...emptyState(), ...persisted }
@@ -160,8 +175,8 @@ export const useStudentRegistrationStore = defineStore('studentRegistration', ()
const skill5 = ref({ ...emptyState().skill5, ...persisted.skill5 })
const skill6 = ref({ ...emptyState().skill6, ...persisted.skill6 })
const skill7 = ref({ ...emptyState().skill7, ...persisted.skill7 })
const currentStep = ref(initial.currentStep)
const completedSteps = ref(initial.completedSteps || [])
const completedSteps = ref(normalizeCompletedSteps(initial.completedSteps))
const currentStep = ref(resolveCurrentStep(initial.currentStep, completedSteps.value))
const registrationMedia = ref(initial.registrationMedia || {})
const sectionRefs = {
@@ -207,18 +222,33 @@ export const useStudentRegistrationStore = defineStore('studentRegistration', ()
)
const markStepAsCompleted = (stepKey) => {
if (!completedSteps.value.includes(stepKey)) {
if (STUDENT_REGISTRATION_ORDER.includes(stepKey) && !completedSteps.value.includes(stepKey)) {
completedSteps.value = [...completedSteps.value, stepKey]
}
}
const updateCurrentStep = (stepKey) => {
if (stepKey) currentStep.value = stepKey
if (canGoToStep(stepKey)) currentStep.value = stepKey
}
const updateSection = (section, value) => {
const target = sectionRefs[section]
if (!target || !value || typeof value !== 'object') return
target.value = { ...target.value, ...value }
}
const setRegistrationMedia = (value) => {
if (!value || typeof value !== 'object') return
registrationMedia.value = { ...registrationMedia.value, ...value }
}
const goToNextStep = () => {
const idx = STUDENT_REGISTRATION_ORDER.indexOf(currentStep.value)
if (idx >= 0 && idx < STUDENT_REGISTRATION_ORDER.length - 1) {
if (
idx >= 0 &&
idx < STUDENT_REGISTRATION_ORDER.length - 1 &&
completedSteps.value.includes(currentStep.value)
) {
currentStep.value = STUDENT_REGISTRATION_ORDER[idx + 1]
}
}
@@ -229,9 +259,8 @@ export const useStudentRegistrationStore = defineStore('studentRegistration', ()
}
const canGoToStep = (stepKey) => {
const currentIndex = STUDENT_REGISTRATION_ORDER.indexOf(currentStep.value)
const targetIndex = STUDENT_REGISTRATION_ORDER.indexOf(stepKey)
return targetIndex <= currentIndex + 1
return targetIndex >= 0 && targetIndex <= firstIncompleteIndex(completedSteps.value)
}
const buildCompletePayload = () => ({
@@ -307,6 +336,8 @@ export const useStudentRegistrationStore = defineStore('studentRegistration', ()
registrationMedia,
markStepAsCompleted,
updateCurrentStep,
updateSection,
setRegistrationMedia,
goToNextStep,
goToPrevStep,
canGoToStep,
@@ -19,6 +19,7 @@
:value="stat.value"
:unit="stat.unit"
:subtitle="stat.subtitle"
:to="stat.to"
/>
</div>
</section>
@@ -91,6 +92,7 @@ const ticketStats = computed(() => {
value: toFaDigits(stats.openTickets ?? 0),
unit: 'تیکت',
subtitle: 'در انتظار پاسخ',
to: { name: 'counselor-consultations' },
},
{
icon: 'check-circle',
@@ -98,6 +100,7 @@ const ticketStats = computed(() => {
value: toFaDigits(stats.answeredTickets ?? 0),
unit: 'تیکت',
subtitle: 'پاسخ داده شده',
to: { name: 'counselor-consultations' },
},
{
icon: 'check',
@@ -105,6 +108,7 @@ const ticketStats = computed(() => {
value: toFaDigits(stats.closedTickets ?? 0),
unit: 'تیکت',
subtitle: 'بسته شده',
to: { name: 'counselor-consultations' },
},
]
})
@@ -202,7 +206,7 @@ const calendar = useTehranCalendar()
gap: 1.5rem;
@media (min-width: 1024px) {
width: 19.625rem;
width: clamp(21rem, 26vw, 24rem);
flex-shrink: 0;
border-inline-start: 1px solid #e6e6e6;
padding-inline-start: 2rem;
@@ -19,6 +19,7 @@
:value="stat.value"
:unit="stat.unit"
:subtitle="stat.subtitle"
:to="stat.to"
/>
</div>
</section>
@@ -91,6 +92,7 @@ const requestStats = computed(() => {
value: toFaDigits(stats.totalRequests ?? 0),
unit: 'درخواست',
subtitle: 'ثبت شده',
to: { name: 'missionary-requests' },
},
{
icon: 'check-circle',
@@ -98,6 +100,7 @@ const requestStats = computed(() => {
value: toFaDigits(stats.acceptedRequests ?? 0),
unit: 'درخواست',
subtitle: 'پذیرفته شده',
to: { name: 'missionary-requests' },
},
{
icon: 'clock',
@@ -105,6 +108,7 @@ const requestStats = computed(() => {
value: toFaDigits(stats.pendingRequests ?? 0),
unit: 'درخواست',
subtitle: 'در انتظار',
to: { name: 'missionary-requests' },
},
]
})
@@ -221,7 +225,7 @@ const calendar = useTehranCalendar()
gap: 1.5rem;
@media (min-width: 1024px) {
width: 19.625rem;
width: clamp(21rem, 26vw, 24rem);
flex-shrink: 0;
border-inline-start: 1px solid #e6e6e6;
padding-inline-start: 2rem;
@@ -19,6 +19,7 @@
:value="stat.value"
:unit="stat.unit"
:subtitle="stat.subtitle"
:to="stat.to"
/>
</div>
</section>
@@ -90,11 +91,12 @@ const performanceStats = computed(() => {
const stats = dashboard.value.stats ?? {}
return [
{
icon: 'user-sound',
label: 'تعداد جلسات اعزام شده',
icon: 'video',
label: 'تعداد جلسات آموزشی گذرانده‌شده',
value: toFaDigits(stats.attendedSessions ?? 0),
unit: 'جلسه',
subtitle: 'اعزام شده است',
subtitle: 'با موفقیت گذرانده شده',
to: { name: 'student-terms' },
},
{
icon: 'video',
@@ -102,6 +104,7 @@ const performanceStats = computed(() => {
value: toFaDigits(stats.enrolledTerms ?? 0),
unit: 'دوره',
subtitle: 'خریداری شده',
to: { name: 'student-terms' },
},
{
icon: 'clock',
@@ -109,6 +112,7 @@ const performanceStats = computed(() => {
value: toFaDigits(stats.hoursSpent ?? 0),
unit: 'ساعت',
subtitle: 'گذرانده شده',
to: { name: 'student-terms' },
},
]
})
@@ -288,7 +292,7 @@ const calendar = buildTehranCalendar()
gap: 1.5rem;
@media (min-width: 1024px) {
width: 19.625rem;
width: clamp(21rem, 26vw, 24rem);
flex-shrink: 0;
border-inline-start: 1px solid #e6e6e6;
padding-inline-start: 2rem;
@@ -1,5 +1,10 @@
<template>
<div class="stat-card">
<component
:is="to ? RouterLink : 'div'"
:to="to || undefined"
class="stat-card"
:class="{ 'stat-card--linked': to }"
>
<SvgIcon name="card-corner-arrow" :size="'1.5rem'" class="stat-card__corner" />
<div class="stat-card__head">
@@ -14,10 +19,11 @@
<span class="stat-card__subtitle">{{ subtitle }}</span>
</div>
</div>
</div>
</component>
</template>
<script setup>
import { RouterLink } from 'vue-router'
import SvgIcon from '@/components/icons/SvgIcon.vue'
defineProps({
@@ -27,6 +33,7 @@ defineProps({
value: { type: [String, Number], default: '' },
unit: { type: String, default: '' },
subtitle: { type: String, default: '' },
to: { type: [String, Object], default: null },
})
</script>
@@ -41,6 +48,23 @@ defineProps({
background: rgba(255, 255, 255, 58%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.9375rem;
color: inherit;
text-decoration: none;
transition: transform 0.2s ease, box-shadow 0.2s ease;
&--linked {
cursor: pointer;
&:hover {
transform: translateY(-2px);
box-shadow: 0 9px 24px rgba(0, 0, 0, 7%);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 3px;
}
}
&__corner {
position: absolute;
+4 -1
View File
@@ -22,7 +22,10 @@ export const studentProfileSchema = object().shape({
virtualPhoneNumber: string()
.nullable()
.notRequired()
.matches(/^\d{10,15}$/, { message: 'شماره تلفن مجازی نامعتبر است', excludeEmptyString: true }),
.matches(/^\d{10,15}$/, {
message: 'شماره تلفن مجازی باید فقط شامل ۱۰ تا ۱۵ رقم باشد',
excludeEmptyString: true,
}),
seminaryCode: string().nullable().notRequired(),
seminaryServiceCenterCode: string().nullable().notRequired(),
childrenCount: string()
@@ -3,11 +3,14 @@
<form class="csf__form" @submit.prevent="onSubmit">
<div class="csf__row">
<div class="csf__cell">
<TextField
<SelectField
v-model="form.category"
name="category"
label="نوع خدمت"
placeholder="نوع خدمت را وارد کنید"
placeholder="نوع خدمت را انتخاب کنید"
:options="serviceTypeOptions"
option-label="label"
option-value="value"
:error="errors.category"
/>
</div>
@@ -71,6 +74,7 @@ import useYup from '@/composables/useYup'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import TextField from '@/components/form/TextField.vue'
import SelectField from '@/components/form/SelectField.vue'
import FileUploader from '@/components/form/FileUploader.vue'
import { objectToFormData } from '@/utils/object-to-formdata'
import { useUploadMediaMutation } from '@/services/query/auth'
@@ -82,6 +86,11 @@ defineProps({
const emit = defineEmits(['submit', 'file-error'])
const serviceTypeOptions = [
{ value: 'خدمات رفاهی', label: 'خدمات رفاهی' },
{ value: 'خدمات آموزشی', label: 'خدمات آموزشی' },
]
const form = ref({
category: '',
subject: '',
@@ -94,7 +103,7 @@ const form = ref({
const files = ref([])
const schema = yup.object({
category: yup.string().trim().required('نوع خدمت را وارد کنید'),
category: yup.string().trim().required('نوع خدمت را انتخاب کنید'),
subject: yup.string().trim().required('عنوان خدمت را وارد کنید'),
message: yup
.string()
@@ -31,10 +31,7 @@
/>
</div>
<div v-else-if="contentKind === 'audio'" class="ssd__audio">
<VoiceRecorder
model-value="http://api.tripwisedaily.ir/storage/pending/3/test2-AmQiegjc.mp3?expires=1783677165&signature=38104395400100f1bd74594bfc64ac462c14634a8c2bc4541c988766a08ea2bd"
disabled
/>
<VoiceRecorder :model-value="session?.audioUrl" disabled />
</div>
<div v-else-if="contentKind === 'text'" class="ssd__text">
<SvgIcon name="warning" :size="22" color="#b8b8b8" class="ssd__text-icon" />
-6
View File
@@ -84,12 +84,6 @@ const menuItems = computed(() => [
to: { name: 'student-services' },
active: route.name === 'student-services',
},
{
title: 'مشاوره',
icon: 'chat-teardrop-text',
to: { name: 'student-consultants' },
active: route.name === 'student-consultants',
},
{
title: 'گواهینامه',
icon: 'certificate',
+94 -16
View File
@@ -5,27 +5,47 @@
</div>
<nav class="admin-sidebar__nav">
<ul>
<li v-for="item in menuItems" :key="item.title" class="admin-sidebar__item">
<component
:is="item.to ? 'RouterLink' : 'div'"
<li v-for="(item, index) in menuItems" :key="item.title" class="admin-sidebar__item">
<button
v-if="item.children?.length"
type="button"
class="admin-sidebar__link"
:class="{ 'admin-sidebar__link--active': item.active }"
:aria-expanded="isExpanded(item)"
:aria-controls="submenuId(index)"
@click="toggle(item)"
>
<SvgIcon v-if="item.icon" :name="item.icon" :size="22" />
<span>{{ item.title }}</span>
<SvgIcon
name="caret-down"
:size="16"
class="admin-sidebar__chevron"
:class="{ 'admin-sidebar__chevron--expanded': isExpanded(item) }"
/>
</button>
<RouterLink
v-else
:to="item.to"
class="admin-sidebar__link"
:class="{ 'admin-sidebar__link--active': item.active }"
>
<SvgIcon v-if="item.icon" :name="item.icon" :size="22" />
<span>{{ item.title }}</span>
</component>
<ul v-if="item.children?.length" class="admin-sidebar__submenu">
<li v-for="child in item.children" :key="child.title">
<RouterLink
:to="child.to"
class="admin-sidebar__sublink"
:class="{ 'admin-sidebar__sublink--active': child.active }"
>
{{ child.title }}
</RouterLink>
</li>
</ul>
</RouterLink>
<Transition v-if="item.children?.length" name="admin-sidebar__accordion">
<ul v-show="isExpanded(item)" :id="submenuId(index)" class="admin-sidebar__submenu">
<li v-for="child in item.children" :key="child.title">
<RouterLink
:to="child.to"
class="admin-sidebar__sublink"
:class="{ 'admin-sidebar__sublink--active': child.active }"
>
{{ child.title }}
</RouterLink>
</li>
</ul>
</Transition>
</li>
</ul>
</nav>
@@ -34,16 +54,43 @@
</template>
<script setup>
import { ref, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { gallery } from '@/utils/gallery'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import SidebarLogout from '@/layouts/sidebars/SidebarLogout.vue'
defineProps({
const props = defineProps({
menuItems: { type: Array, default: () => [] },
})
const logo = gallery.logoPinkishRed
const expandedItems = ref({})
watch(
() => props.menuItems,
(items) => {
const next = { ...expandedItems.value }
items.forEach((item) => {
if (!item.children?.length) return
if (!(item.title in next)) next[item.title] = false
if (item.active) next[item.title] = true
})
expandedItems.value = next
},
{ immediate: true }
)
const isExpanded = (item) => Boolean(expandedItems.value[item.title])
const toggle = (item) => {
expandedItems.value = {
...expandedItems.value,
[item.title]: !isExpanded(item),
}
}
const submenuId = (index) => `admin-sidebar-submenu-${index}`
</script>
<style lang="scss" scoped>
@@ -82,6 +129,7 @@ const logo = gallery.logoPinkishRed
}
&__link {
width: 100%;
display: flex;
align-items: center;
gap: 0.625rem;
@@ -91,6 +139,9 @@ const logo = gallery.logoPinkishRed
font-size: 0.875rem;
color: #4b5563;
text-decoration: none;
text-align: start;
border: 0;
background: transparent;
transition: background-color 0.15s, color 0.15s;
cursor: pointer;
@@ -105,6 +156,15 @@ const logo = gallery.logoPinkishRed
}
}
&__chevron {
margin-inline-start: auto;
transition: transform 0.2s ease;
&--expanded {
transform: rotate(180deg);
}
}
&__submenu {
margin-top: 0.25rem !important;
padding-inline-start: 1.5rem !important;
@@ -130,5 +190,23 @@ const logo = gallery.logoPinkishRed
font-weight: 700;
}
}
&__accordion-enter-active,
&__accordion-leave-active {
overflow: hidden;
transition: max-height 0.2s ease, opacity 0.2s ease;
}
&__accordion-enter-from,
&__accordion-leave-to {
max-height: 0;
opacity: 0;
}
&__accordion-enter-to,
&__accordion-leave-from {
max-height: 16rem;
opacity: 1;
}
}
</style>
+10 -4
View File
@@ -11,10 +11,16 @@ export const adminDashboard = {
openTickets: 32,
},
stats: {
activeUsers: 11_000,
totalUsers: 11_540,
coursesCreated: 6,
coursesSold: 12,
totalApplicants: 18,
awaitingEvaluation: 1,
term1Students: 2,
term2Students: 0,
term3Students: 1,
graduates: 0,
activeUsers: 11,
totalUsers: 24,
coursesCreated: 4,
coursesSold: 3,
},
recentRegistrations: [
{
+1 -1
View File
@@ -18,7 +18,7 @@ export const currentMe = {
lastName: 'محمدی',
fullName: 'سجاد محمدی',
phoneNumber: '09123456789',
nationalCode: '0079827498',
nationalCode: '0079827497',
status: 'approved',
address: {
address: 'خیابان آزادی، پلاک ۱۲',
+5 -1
View File
@@ -2,4 +2,8 @@ import { register } from '@/services/mock/registry'
import { endpoints } from '@/services/api/endpoints'
import { adminDashboard } from '@/services/mock/fixtures/admin-dashboard'
register('GET', endpoints.getAdminDashboard, () => ({ data: adminDashboard }))
register('GET', endpoints.getAdminDashboard, () => ({
success: true,
message: 'انجام شد.',
data: adminDashboard,
}))
+8
View File
@@ -4,6 +4,7 @@ import { endpoints } from '@/services/api/endpoints'
import { currentMe } from '@/services/mock/fixtures/me'
const fakeToken = 'mock-token-1234567890'
let registerData = {}
register('POST', endpoints.login, () => ({
data: { user: currentMe, token: fakeToken },
@@ -86,6 +87,13 @@ register('POST', endpoints.completeProfile, ({ data }) => ({
data: { user: { ...currentMe, ...data } },
}))
register('GET', endpoints.registerData, () => ({ data: registerData }))
register('POST', endpoints.registerData, ({ data }) => {
registerData = { ...registerData, ...data?.data }
return { data: registerData }
})
register('GET', endpoints.getRegistrationQuestionVideo, () => ({
data: { url: '', id: 0 },
}))
@@ -2,4 +2,8 @@ import { register } from '@/services/mock/registry'
import { endpoints } from '@/services/api/endpoints'
import { counselorDashboard } from '@/services/mock/fixtures/counselor-dashboard'
register('GET', endpoints.getCounselorDashboard, () => ({ data: counselorDashboard }))
register('GET', endpoints.getCounselorDashboard, () => ({
success: true,
message: 'انجام شد.',
data: counselorDashboard,
}))
@@ -2,4 +2,8 @@ import { register } from '@/services/mock/registry'
import { endpoints } from '@/services/api/endpoints'
import { missionaryDashboard } from '@/services/mock/fixtures/missionary-dashboard'
register('GET', endpoints.getMissionaryDashboard, () => ({ data: missionaryDashboard }))
register('GET', endpoints.getMissionaryDashboard, () => ({
success: true,
message: 'انجام شد.',
data: missionaryDashboard,
}))
@@ -2,4 +2,8 @@ import { register } from '@/services/mock/registry'
import { endpoints } from '@/services/api/endpoints'
import { studentDashboard } from '@/services/mock/fixtures/student-dashboard'
register('GET', endpoints.getStudentDashboard, () => ({ data: studentDashboard }))
register('GET', endpoints.getStudentDashboard, () => ({
success: true,
message: 'انجام شد.',
data: studentDashboard,
}))
+12 -1
View File
@@ -60,10 +60,21 @@ export const useCompleteProfileMutation = () => useMutation({ mutationFn: apiCom
export const useUpdateProfileMutation = () => useMutation({ mutationFn: apiUpdateProfile })
const getRegistrationVideoIfAvailable = async () => {
try {
return await apiGetRegistrationQuestionVideo()
} catch (error) {
// The migrated backend does not expose this optional content endpoint yet.
// Treat a missing route as "no tutorial uploaded" so registration remains usable.
if (error?.response?.status === 404) return {}
throw error
}
}
export const useGetRegistrationVideoQuery = (options = {}) =>
useQuery({
queryKey: authKeys.registrationVideo(),
queryFn: () => apiGetRegistrationQuestionVideo(),
queryFn: getRegistrationVideoIfAvailable,
select: (response) => response?.data ?? response,
...options,
})
+4 -8
View File
@@ -35,8 +35,7 @@ export function gregorianToJalaliString(isoString, separator = '/', persianDigit
jd = jd.replace(/\d/g, (d) => enToFa[d])
}
return `${jy}${separator}${jm}${separator}${jd}`
} catch (error) {
console.error('Date conversion error:', error)
} catch {
return ''
}
}
@@ -83,8 +82,7 @@ export function gregorianToJalaliStringLong(isoString, separator = '/', persianD
}
return ` ${jy}${separator}${jm}${separator}${jd}، ${hh}:${mm}`
} catch (error) {
console.error('Date conversion error:', error)
} catch {
return ''
}
}
@@ -98,8 +96,7 @@ export function JalaliToGregorianString(jalaliStr) {
2,
'0'
)}T00:00:00.000Z`
} catch (error) {
console.log('Jalali to Gregorian conversion error:', error)
} catch {
return ''
}
}
@@ -119,8 +116,7 @@ export function JalaliToGregorianStringWithTime(jalaliDateTimeStr) {
2,
'0'
)}T${timePart}:00.000Z`
} catch (error) {
console.log('Jalali to Gregorian with time conversion error:', error)
} catch {
return ''
}
}
+68 -7
View File
@@ -2,21 +2,82 @@ import { setLocale } from 'yup'
import { fields } from '@/constants/fields'
const label = (path) => fields[path] || path
const FA_DIGITS = '۰۱۲۳۴۵۶۷۸۹'
const toFaDigits = (value) => String(value ?? '').replace(/\d/g, (digit) => FA_DIGITS[digit])
const label = (path) => {
const normalizedPath = String(path ?? '').replace(/\[\d+]/g, '')
const fieldName = normalizedPath.split('.').pop()
return fields[path] || fields[normalizedPath] || fields[fieldName] || path || 'مقدار'
}
const typeError = ({ path, type }) => {
const field = label(path)
const messages = {
array: `فیلد ${field} باید به‌صورت فهرست باشد`,
boolean: `فیلد ${field} باید مقدار بله یا خیر داشته باشد`,
date: `فیلد ${field} باید یک تاریخ معتبر باشد`,
number: `فیلد ${field} باید عدد باشد`,
object: `ساختار فیلد ${field} معتبر نیست`,
string: `فیلد ${field} باید متن باشد`,
}
return messages[type] || `نوع مقدار فیلد ${field} معتبر نیست`
}
setLocale({
mixed: {
default: ({ path }) => `مقدار فیلد ${label(path)} معتبر نیست`,
required: ({ path }) => `فیلد ${label(path)} ضروری است`,
defined: ({ path }) => `فیلد ${label(path)} باید مقدار داشته باشد`,
notNull: ({ path }) => `فیلد ${label(path)} نمی‌تواند خالی باشد`,
oneOf: ({ path }) => `مقدار انتخاب‌شده برای فیلد ${label(path)} معتبر نیست`,
notOneOf: ({ path }) => `مقدار انتخاب‌شده برای فیلد ${label(path)} مجاز نیست`,
notType: typeError,
},
string: {
min: ({ path, min }) => `فیلد ${label(path)} باید حداقل ${min} کاراکتر باشد`,
max: ({ path, max }) => `فیلد ${label(path)} باید حداکثر ${max} کاراکتر باشد`,
min: ({ path, min }) => `فیلد ${label(path)} باید حداقل ${toFaDigits(min)} کاراکتر باشد`,
max: ({ path, max }) => `فیلد ${label(path)} باید حداکثر ${toFaDigits(max)} کاراکتر باشد`,
email: ({ path }) => `فیلد ${label(path)} باید یک ایمیل معتبر باشد`,
url: ({ path }) => `فیلد ${label(path)} باید یک URL معتبر باشد`,
length: ({ path, length }) => `فیلد ${label(path)} باید دقیقا ${length} کاراکتر باشد`,
url: ({ path }) => `فیلد ${label(path)} باید یک نشانی اینترنتی معتبر باشد`,
length: ({ path, length }) =>
`فیلد ${label(path)} باید دقیقاً ${toFaDigits(length)} کاراکتر باشد`,
matches: ({ path }) => `فرمت فیلد ${label(path)} صحیح نیست`,
uuid: ({ path }) => `شناسه فیلد ${label(path)} معتبر نیست`,
datetime: ({ path }) => `فیلد ${label(path)} باید یک تاریخ و زمان معتبر باشد`,
datetime_precision: ({ path }) => `دقت زمانی فیلد ${label(path)} معتبر نیست`,
datetime_offset: ({ path }) => `منطقه زمانی فیلد ${label(path)} معتبر نیست`,
trim: ({ path }) => `ابتدا یا انتهای فیلد ${label(path)} نباید فاصله داشته باشد`,
lowercase: ({ path }) => `فیلد ${label(path)} باید با حروف کوچک نوشته شود`,
uppercase: ({ path }) => `فیلد ${label(path)} باید با حروف بزرگ نوشته شود`,
},
number: {
min: ({ path, min }) => `فیلد ${label(path)} باید حداقل ${toFaDigits(min)} باشد`,
max: ({ path, max }) => `فیلد ${label(path)} باید حداکثر ${toFaDigits(max)} باشد`,
lessThan: ({ path, less }) => `فیلد ${label(path)} باید کمتر از ${toFaDigits(less)} باشد`,
moreThan: ({ path, more }) => `فیلد ${label(path)} باید بیشتر از ${toFaDigits(more)} باشد`,
positive: ({ path }) => `فیلد ${label(path)} باید عددی بزرگ‌تر از صفر باشد`,
negative: ({ path }) => `فیلد ${label(path)} باید عددی کوچک‌تر از صفر باشد`,
integer: ({ path }) => `فیلد ${label(path)} باید عدد صحیح باشد`,
},
date: {
min: ({ path }) => `تاریخ فیلد ${label(path)} زودتر از محدوده مجاز است`,
max: ({ path }) => `تاریخ فیلد ${label(path)} دیرتر از محدوده مجاز است`,
},
boolean: {
isValue: ({ path }) => `مقدار فیلد ${label(path)} معتبر نیست`,
},
object: {
noUnknown: ({ path }) => `فیلد ${label(path)} شامل مقادیر ناشناخته است`,
exact: ({ path }) => `فیلد ${label(path)} شامل مقادیر اضافی است`,
},
array: {
min: ({ path, min }) => `فیلد ${label(path)} باید حداقل ${min} آیتم داشته باشد`,
max: ({ path, max }) => `فیلد ${label(path)} باید حداکثر ${max} آیتم داشته باشد`,
min: ({ path, min }) => `فیلد ${label(path)} باید حداقل ${toFaDigits(min)} مورد داشته باشد`,
max: ({ path, max }) => `فیلد ${label(path)} باید حداکثر ${toFaDigits(max)} مورد داشته باشد`,
length: ({ path, length }) =>
`فیلد ${label(path)} باید دقیقاً ${toFaDigits(length)} مورد داشته باشد`,
},
tuple: {
notType: typeError,
},
})