Compare commits

...

15 Commits

Author SHA1 Message Date
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
root 32e97a7591 Revert "feat: update admin dashboard statistics"
Deploy banu-front / deploy (push) Failing after 1m24s
This reverts commit fe1fb6cfa4.
2026-07-30 14:16:10 +00:00
root fe1fb6cfa4 feat: update admin dashboard statistics
Deploy banu-front / deploy (push) Failing after 1m31s
2026-07-30 14:15:46 +00:00
sajjadtalkhabi bc9ce01d3f Merge branch 'master' of ssh://109.122.252.86:2222/front-end/banu-front
Deploy banu-front / deploy (push) Successful in 3m33s
2026-07-30 17:41:10 +03:30
sajjadtalkhabi 38051db180 fix: bugs 2026-07-30 17:40:41 +03:30
sajjadtalkhabi 36b747d267 fix: bugs 2026-07-30 17:38:27 +03:30
64 changed files with 974 additions and 218 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
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Banu</title>
<title>مدرسه بانو</title>
</head>
<body>
<div id="app"></div>
+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",
+4
View File
@@ -4,6 +4,10 @@
box-sizing: border-box;
}
html {
font-size: 106.25%;
}
body {
margin: 0 !important;
padding: 0 !important;
+21 -21
View File
@@ -1,6 +1,6 @@
@font-face {
font-family: montserrat;
src: url('../fonts/montserrat/Montserrat-ExtraLight.ttf') format('truetype');
src: url('../fonts/montserrat/Montserrat-Regular.ttf') format('truetype');
font-weight: 200;
font-style: normal;
font-display: swap;
@@ -16,8 +16,8 @@
@font-face {
font-family: aria;
src: url('../fonts/aria/AriaWebFaNum-Light.woff2') format('woff2');
font-weight: 300;
src: url('../fonts/aria/AriaWeb-Normal.woff2') format('woff2');
font-weight: 200;
font-style: normal;
font-display: swap;
}
@@ -25,7 +25,7 @@
@font-face {
font-family: aria;
src: url('../fonts/aria/AriaWebFaNum-Regular.woff2') format('woff2');
font-weight: 400;
font-weight: 300;
font-style: normal;
font-display: swap;
}
@@ -33,6 +33,14 @@
@font-face {
font-family: aria;
src: url('../fonts/aria/AriaWebFaNum-Medium.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: aria;
src: url('../fonts/aria/AriaWebFaNum-SemiBold.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
@@ -46,25 +54,17 @@
font-display: swap;
}
@font-face {
font-family: aria;
src: url('../fonts/aria/AriaWeb-Normal.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: iran-yekan;
src: url('../fonts/iran-yekan/iranyekanwebregularfanum.woff') format('woff');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: iran-yekan;
src: url('../fonts/iran-yekan/iranyekanwebmediumfanum.woff') format('woff');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: iran-yekan;
src: url('../fonts/iran-yekan/iranyekanwebboldfanum.woff') format('woff');
font-weight: 500;
font-style: normal;
font-display: swap;
@@ -80,7 +80,7 @@
@font-face {
font-family: iran-yekan;
src: url('../fonts/iran-yekan/iranyekanweblightfanum.woff') format('woff');
src: url('../fonts/iran-yekan/iranyekanwebregularfanum.woff') format('woff');
font-weight: 300;
font-style: normal;
font-display: swap;
+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;
+24 -2
View File
@@ -15,6 +15,11 @@ export const fields = {
lastName: 'نام خانوادگی',
nationalCode: 'کد ملی',
postalCode: 'کد پستی',
virtualPhoneNumber: 'شماره تلفن مجازی',
seminaryCode: 'کد حوزوی',
seminaryServiceCenterCode: 'کد مرکز خدمات حوزه',
childrenCount: 'تعداد فرزند',
marriageStatus: 'وضعیت تأهل',
birthDate: 'تاریخ تولد',
birthday: 'تاریخ تولد',
@@ -28,12 +33,17 @@ export const fields = {
address: 'آدرس',
title: 'عنوان',
subject: 'موضوع',
category: 'نوع خدمت',
note: 'یادداشت',
description: 'توضیحات',
bio: 'بیوگرافی',
message: 'پیام',
content: 'محتوا',
rejectionReason: 'دلیل رد',
reason: 'دلیل رد',
adminNote: 'یادداشت مدیر',
reviewerNote: 'یادداشت ارزیاب',
activitySummary: 'خلاصهٔ فعالیت',
avatar: 'تصویر پروفایل',
@@ -49,12 +59,15 @@ export const fields = {
startDate: 'تاریخ شروع',
endDate: 'تاریخ پایان',
deadline: 'مهلت تحویل',
fromDate: 'از تاریخ',
toDate: 'تا تاریخ',
capacity: 'ظرفیت',
minCapacity: 'حداقل ظرفیت',
maxCapacity: 'حداکثر ظرفیت',
score: 'نمره',
minimumScore: 'حد نصاب قبولی',
order: 'ترتیب',
priority: 'اولویت',
@@ -67,23 +80,32 @@ export const fields = {
termId: 'ترم',
courseId: 'دوره',
sessionId: 'جلسه',
missionaryRequestId: 'درخواست مبلغ',
sessionType: 'نوع جلسه',
type: 'نوع جلسه',
link: 'لینک جلسه',
teacherId: 'مدرس',
studentIds: 'دانش‌آموزان',
prerequisiteCourseIds: 'دوره‌های پیش‌نیاز',
contentMediaId: 'فایل محتوای دوره',
coverMediaId: 'تصویر جلد',
imageId: 'تصویر',
educationStatus: 'وضعیت تحصیلی',
fieldOfStudy: 'رشتهٔ تحصیلی',
seminaryLevel: 'مقطع حوزه',
universityLevel: 'مقطع دانشگاهی',
universityName: 'نام حوزه علمیه / دانشگاه',
workExperienceSummary: 'خلاصهٔ سوابق شغلی',
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}$/, 'شماره موبایل باید با ۰۹ شروع شود و ۱۱ رقم باشد')
+2 -6
View File
@@ -6,7 +6,7 @@ export const getEnumObject = (enumArray, key = 'value', value = 'label') =>
export const ROLES = Object.freeze([
{ value: 'admin', label: 'مدیر', changeable: false },
{ value: 'student', label: 'دانشجو', changeable: true },
{ value: 'student', label: 'طلبه', changeable: true },
{ value: 'teacher', label: 'استاد', changeable: true },
{ value: 'counselor', label: 'مشاور', changeable: true },
{ value: 'missionary', label: 'مبلغ', changeable: true },
@@ -53,8 +53,6 @@ export const USER_STATUS = Object.freeze({
export const MARITAL_STATUS = Object.freeze({
single: 'مجرد',
married: 'متاهل',
divorced: 'مطلقه',
widowed: 'بیوه',
})
export const GENDER = Object.freeze({
@@ -68,7 +66,6 @@ export const CHANGEABLE_ROLES = Object.freeze(ROLES.filter((r) => r.changeable).
export const EDUCATION_STATUS = Object.freeze({
seminary_student: 'طلبه',
seminary_and_university_student: 'طلبه و دانشجو',
graduate: 'فارغ التحصیلی',
})
@@ -76,14 +73,13 @@ export const SEMINARY_LEVEL = Object.freeze({
level_2: 'سطح دو',
level_3: 'سطح سه',
level_4: 'سطح چهار',
not_applicable: 'طلبه نیستم',
})
export const UNIVERSITY_LEVEL = Object.freeze({
bachelor: ' مقطع کارشناسی (لیسانس)',
master: ' مقطع کارشناسی ارشد (فوق لیسانس)',
phd: ' مقطع دکتری',
not_applicable: 'دانشجو نیستم',
not_applicable: 'طلبه نیستم',
})
export const PROPAGATION_PLATFORM = Object.freeze({
@@ -49,7 +49,7 @@
</div>
<div class="submission-details__row">
<LineInfoBlock title="پاسخ دانشجو" :desc="submission.userDescription || '—'" />
<LineInfoBlock title="پاسخ طلبه" :desc="submission.userDescription || '—'" />
</div>
<div v-if="submission.attachments?.length" class="submission-details__attachments">
+14 -3
View File
@@ -3,9 +3,16 @@ 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(),
contentMediaId: number()
.typeError('شناسه فایل محتوای دوره باید عدد باشد')
.nullable()
.notRequired(),
description: string().nullable().notRequired(),
termId: mixed().required(),
isActive: boolean().nullable().notRequired(),
@@ -14,7 +21,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(),
})
+47 -27
View File
@@ -1,15 +1,15 @@
<template>
<div class="dashboard">
<div class="dashboard__main">
<!-- اطلاعات موسسه -->
<!-- اطلاعات مدرسه -->
<section class="dashboard__section">
<BoxedIconTitleBlock title="اطلاعات موسسه" desc="آخرین اطلاعات موسسه شما.">
<BoxedIconTitleBlock title="اطلاعات مدرسه" desc="آخرین اطلاعات مدرسه شما.">
<template #icon>
<SvgIcon name="heart" :size="24" color="var(--color-primary)" />
</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;
+1 -1
View File
@@ -7,7 +7,7 @@ export default [
layout: 'admin',
role: 'admin',
title: 'سلام بر شما',
subtitle: 'به سامانه موسسه بانو خوش آمدید.',
subtitle: 'به سامانه مدرسه بانو خوش آمدید.',
},
},
{
@@ -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>
@@ -1,7 +1,7 @@
<template>
<BasicModal
title="افزودن دانشجو"
title-en="Add Student"
title="افزودن طلبه"
title-en="Add Seminarian"
width="95%"
max-width="42rem"
min-width="auto"
@@ -15,7 +15,7 @@
v-model="searchInput"
type="text"
class="add-term-student__search-input"
placeholder="اسم دانشجو را وارد نمایید"
placeholder="اسم طلبه را وارد نمایید"
@input="onSearchInput"
/>
</div>
@@ -56,7 +56,7 @@
</div>
</div>
<NoItems v-else title="بدون نتیجه" desc="دانشجویی یافت نشد." />
<NoItems v-else title="بدون نتیجه" desc="طلبه‌ای یافت نشد." />
<div class="add-term-student__divider" />
@@ -21,14 +21,10 @@
<div class="term-details__overview-grid">
<LineInfoBlock title="عنوان ترم" :desc="term.title || '-'" />
<LineInfoBlock
title="تاریخ شروع"
:numeric-desc="formatJalaaliDate(term.startsAt) || '-'"
title="تاریخ ایجاد"
:numeric-desc="formatJalaaliDate(term.createdAt) || '-'"
/>
<LineInfoBlock
title="تاریخ پایان"
:numeric-desc="formatJalaaliDate(term.endsAt) || '-'"
/>
<LineInfoBlock title="تعداد دانشجویان" :numeric-desc="term.studentsCount ?? 0" />
<LineInfoBlock title="تعداد طلبه‌ها" :numeric-desc="term.studentsCount ?? 0" />
<LineInfoBlock title="تعداد دوره‌ها" :numeric-desc="term.coursesCount ?? 0" />
<LineInfoBlock title="وضعیت" :desc="term.isActive ? 'فعال' : 'غیرفعال'" />
<div v-if="term.description" class="term-details__cell--full">
@@ -41,9 +37,9 @@
<TabsBlock :tabs="tabs" v-model="activeTab">
<template #students>
<div class="term-details__panel-header">
<LineTitleBlock title="لیست دانشجویان این ترم" title-en="Students List" />
<LineTitleBlock title="لیست طلبه‌های این ترم" title-en="Seminarians List" />
<BaseButton
text="افزودن دانشجو"
text="افزودن طلبه"
custom-class="term-details__panel-btn"
@click="onOpenAddStudent"
>
@@ -76,7 +72,7 @@
</div>
</div>
</div>
<NoItems v-else title="متاسفیم" desc="دانشجویی برای نمایش وجود ندارد." />
<NoItems v-else title="متاسفیم" desc="طلبه‌ای برای نمایش وجود ندارد." />
<PaginationBlock :pagination="studentPaginationMeta" @update:page="setStudentPage" />
</template>
@@ -161,7 +157,7 @@ const { data: term } = useAdminTermQuery(termId, {
const tabs = [
{ name: 'courses', label: 'دوره‌ها', icon: 'list-bullets' },
{ name: 'students', label: 'دانشجویان', icon: 'users' },
{ name: 'students', label: 'طلبه‌ها', icon: 'users' },
]
const activeTab = ref('courses')
@@ -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 ?? '',
}
+2 -4
View File
@@ -4,13 +4,11 @@ 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({
studentIds: array().of(mixed()).min(1, 'حداقل یک دانشجو را انتخاب کنید').required(),
studentIds: array().of(mixed()).min(1, 'حداقل یک طلبه را انتخاب کنید').required(),
})
@@ -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" />
@@ -59,12 +64,6 @@
<section class="user-verification-details__section">
<LineTitleBlock title="اطلاعات شغلی" title-en="Job information" />
<div class="user-verification-details__grid user-verification-details__grid--4">
<div class="user-verification-details__cell user-verification-details__cell--full">
<LineInfoBlock
title="خلاصه‌ای از سوابق شغلی"
:desc="profile.workExperienceSummary || ''"
/>
</div>
<div class="user-verification-details__cell user-verification-details__cell--full">
<LineInfoBlock
title="خلاصه‌ای از فعالیت‌ها در موقعیت‌های شغلی"
@@ -184,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'
@@ -219,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 }))
@@ -269,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>
@@ -276,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;
@@ -445,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({
+1 -1
View File
@@ -1,6 +1,6 @@
<template>
<div class="auth-heading">
<img :src="logo" alt="banu" class="auth-heading__logo" />
<img :src="logo" alt="مدرسه بانو" class="auth-heading__logo" />
<h2 class="auth-heading__title">{{ title }}</h2>
<p v-if="step === 'otp'" class="auth-heading__subtitle">
<span class="auth-heading__phone-label">ارسال کد فعالسازی به شماره</span>
@@ -2,7 +2,7 @@
<div class="pattern-section">
<div class="pattern-section__bg" :style="{ backgroundImage: `url(${bgImage})` }" />
<div class="pattern-section__front">
<img :src="frontImage" alt="banu" />
<img :src="frontImage" alt="مدرسه بانو" />
</div>
</div>
</template>
@@ -144,7 +144,7 @@ const onResend = async () => {
const body = { phone: props.phoneNumber }
if (props.mode === 'forgotPassword') await forgotMutation.mutateAsync(body)
else if (props.mode === 'loginWithCode') await requestLoginOtpMutation.mutateAsync(body)
else await resendCodeMutation.mutateAsync({ phoneNumber: props.phoneNumber })
else await resendCodeMutation.mutateAsync(body)
}
</script>
@@ -28,7 +28,7 @@
<SelectField
v-model="form.universityLevel"
name="universityLevel"
label="آخرین مدرک تحصیلی"
label="آخرین مدرک تحصیلی دانشگاهی"
:options="universityLevelOptions"
option-label="label"
option-value="value"
@@ -65,20 +65,6 @@
<LineTitleBlock title="مشخصات شغلی" title-en="Job Details" />
<div class="educational-info__row">
<div class="educational-info__cell educational-info__cell--full">
<TextareaField
v-model="form.workExperienceSummary"
name="workExperienceSummary"
label="خلاصه ای از سوابق شغلی خود را ذکر بفرمایید."
:row="isMobile ? 5 : 1"
:error="errors.workExperienceSummary"
@blur="validateAt('workExperienceSummary', form.workExperienceSummary)"
>
<template #appendIcon>
<SvgIcon name="file" :size="20" color="var(--color-thd-gray)" />
</template>
</TextareaField>
</div>
<div class="educational-info__cell educational-info__cell--full">
<TextareaField
v-model="form.activitySummary"
@@ -75,6 +75,51 @@
:error="errors.gender"
/>
</div>
<div class="personal-info__cell">
<TextField
v-model="form.childrenCount"
name="childrenCount"
label="تعداد فرزند"
inputmode="numeric"
:convert-digits="true"
:error="errors.childrenCount"
@blur="validateAt('childrenCount', form.childrenCount)"
>
<template #appendIcon>
<SvgIcon name="users-three" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
</div>
<div class="personal-info__cell">
<TextField
v-model="form.seminaryCode"
name="seminaryCode"
label="کد حوزوی"
inputmode="numeric"
:convert-digits="true"
:error="errors.seminaryCode"
@blur="validateAt('seminaryCode', form.seminaryCode)"
>
<template #appendIcon>
<SvgIcon name="graduation-cap" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
</div>
<div class="personal-info__cell">
<TextField
v-model="form.seminaryServiceCenterCode"
name="seminaryServiceCenterCode"
label="کد مرکز خدمات حوزه"
inputmode="numeric"
:convert-digits="true"
:error="errors.seminaryServiceCenterCode"
@blur="validateAt('seminaryServiceCenterCode', form.seminaryServiceCenterCode)"
>
<template #appendIcon>
<SvgIcon name="check-square" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
</div>
</div>
<LineTitleBlock title="اطلاعات تماس" title-en="Contact Details" />
@@ -95,6 +140,36 @@
</template>
</TextField>
</div>
<div class="personal-info__cell">
<TextField
v-model="form.virtualPhoneNumber"
name="virtualPhoneNumber"
label="شماره تلفن مجازی"
inputmode="numeric"
:convert-digits="true"
:error="errors.virtualPhoneNumber"
@blur="validateAt('virtualPhoneNumber', form.virtualPhoneNumber)"
>
<template #appendIcon>
<SvgIcon name="phone" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
</div>
<div class="personal-info__cell">
<TextField
v-model="form.postalCode"
name="postalCode"
label="کد پستی"
inputmode="numeric"
:convert-digits="true"
:error="errors.postalCode"
@blur="validateAt('postalCode', form.postalCode)"
>
<template #appendIcon>
<SvgIcon name="map-pin-simple-area" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
</div>
<div class="personal-info__cell">
<SelectField
v-model="form.provinceId"
@@ -141,7 +216,11 @@
<StepActions
:show-back="false"
:loading="uploadMutation.isPending.value || updateProfileMutation.isPending.value"
:loading="
uploadMutation.isPending.value ||
updateProfileMutation.isPending.value ||
saveRegisterMutation.isPending.value
"
/>
</form>
</template>
@@ -166,7 +245,12 @@ import { personalInformationSchema } from '@/features/auth/schema/student-regist
import StepActions from '@/features/auth/components/studentRegister/StepActions.vue'
import { useStudentRegistrationStore } from '@/features/auth/store/student-registration'
import { useGetCitiesOfProvinceQuery, useGetProvincesQuery } from '@/services/query/common'
import { authKeys, useUpdateProfileMutation, useUploadMediaMutation } from '@/services/query/auth'
import {
authKeys,
useSaveRegisterDataMutation,
useUpdateProfileMutation,
useUploadMediaMutation,
} from '@/services/query/auth'
const store = useStudentRegistrationStore()
const { user } = useAuth()
@@ -212,6 +296,11 @@ const form = ref({
cityId: user.value?.cityId || store.personal.cityId || null,
address:
(typeof user.value?.address === 'string' && user.value.address) || store.personal.address || '',
postalCode: store.personal.postalCode || '',
virtualPhoneNumber: store.personal.virtualPhoneNumber || '',
seminaryCode: store.personal.seminaryCode || '',
seminaryServiceCenterCode: store.personal.seminaryServiceCenterCode || '',
childrenCount: store.personal.childrenCount ?? '',
avatar: initialAvatar,
avatarId: store.personal.avatarId || '',
})
@@ -258,6 +347,7 @@ const onAvatarCropped = async (file) => {
const onAvatarError = (msg) => toast.error(msg)
const updateProfileMutation = useUpdateProfileMutation()
const saveRegisterMutation = useSaveRegisterDataMutation()
const onSubmit = async () => {
const { isValid, payload } = await validate(form.value)
@@ -280,6 +370,13 @@ const onSubmit = async () => {
: {}),
}
await updateProfileMutation.mutateAsync(body)
await saveRegisterMutation.mutateAsync({
postalCode: payload.postalCode,
virtualPhoneNumber: payload.virtualPhoneNumber,
seminaryCode: payload.seminaryCode,
seminaryServiceCenterCode: payload.seminaryServiceCenterCode,
childrenCount: payload.childrenCount,
})
await queryClient.invalidateQueries({ queryKey: authKeys.me() })
store.personal = { ...store.personal, ...next }
store.markStepAsCompleted(STUDENT_REGISTRATION.PERSONAL_INFORMATION)
@@ -1,6 +1,6 @@
<template>
<form class="professional-info" @submit.prevent="onSubmit">
<LineTitleBlock title="مشخصات شغلی" title-en="Job Details" />
<LineTitleBlock title="مشخصات تبلیغی" title-en="Propagation Details" />
<div class="professional-info__row">
<div class="professional-info__cell professional-info__cell--5">
<TextField
@@ -49,7 +49,7 @@
<SelectField
v-model="form.propagationPlatforms"
name="propagationPlatforms"
label="در چه بستر هایی فعالیت تبلیغی داشته اید؟"
label="در چه بستر هایی فعالیت تبلیغی داشته اید؟ (امکان انتخاب چند گزینه وجود دارد)"
:options="platformOptions"
option-label="label"
option-value="value"
@@ -1,11 +1,11 @@
<template>
<aside class="sidebar-steps">
<div class="sidebar-steps__mobile-logo">
<img :src="logoPinkishRed" alt="banu" />
<img :src="logoPinkishRed" alt="مدرسه بانو" />
</div>
<div class="sidebar-steps__panel">
<div class="sidebar-steps__logo">
<img :src="logoWhite" alt="banu" />
<img :src="logoWhite" alt="مدرسه بانو" />
</div>
<ul class="sidebar-steps__list">
<li
@@ -93,8 +93,8 @@ const STEP_LABELS = {
desc: 'لطفاً اطلاعات تحصیلی و شغلی خود را به‌صورت کامل و دقیق وارد کنید',
},
[STUDENT_REGISTRATION.PROFESSIONAL_INFORMATION]: {
title: 'اطلاعات فعالیت تبلیغاتی و حرفه‌ای',
desc: 'لطفاً اطلاعات تبلیغاتی و حرفه‌ای خود را به‌صورت کامل و دقیق وارد کنید',
title: 'اطلاعات فعالیت‌های تبلیغی',
desc: 'لطفاً اطلاعات فعالیت‌های تبلیغی خود را به‌صورت کامل و دقیق وارد کنید',
},
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_1]: {
title: 'بخش مهارت (بخش اول)',
+18 -4
View File
@@ -20,6 +20,20 @@ export const personalInformationSchema = object().shape({
provinceId: mixed().required(),
cityId: mixed().required(),
address: requiredString(10),
postalCode: string()
.required()
.matches(/^\d{10}$/, 'کد پستی باید ۱۰ رقم باشد'),
virtualPhoneNumber: string()
.required()
.matches(/^\d{10,15}$/, 'شماره تلفن مجازی باید فقط شامل ۱۰ تا ۱۵ رقم باشد'),
seminaryCode: requiredString(),
seminaryServiceCenterCode: requiredString(),
childrenCount: number()
.typeError('تعداد فرزند باید به‌صورت عدد وارد شود')
.required()
.integer('تعداد فرزند باید عدد صحیح باشد')
.min(0, 'تعداد فرزند نمی‌تواند کمتر از صفر باشد')
.max(20, 'تعداد فرزند نمی‌تواند بیشتر از ۲۰ باشد'),
})
export const educationalInformationSchema = object().shape({
@@ -28,16 +42,16 @@ export const educationalInformationSchema = object().shape({
universityLevel: string().required(),
universityName: requiredString(5),
fieldOfStudy: requiredString(5),
workExperienceSummary: requiredString(10),
activitySummary: requiredString(10),
})
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()
@@ -18,6 +18,11 @@ const emptyState = () => ({
provinceId: null,
cityId: null,
address: '',
postalCode: '',
virtualPhoneNumber: '',
seminaryCode: '',
seminaryServiceCenterCode: '',
childrenCount: '',
},
educational: {
educationStatus: '',
@@ -25,7 +30,6 @@ const emptyState = () => ({
universityLevel: '',
universityName: '',
fieldOfStudy: '',
workExperienceSummary: '',
activitySummary: '',
},
professional: {
@@ -83,6 +87,11 @@ const SECTION_FIELDS = {
'provinceId',
'cityId',
'address',
'postalCode',
'virtualPhoneNumber',
'seminaryCode',
'seminaryServiceCenterCode',
'childrenCount',
],
educational: [
'educationStatus',
@@ -90,7 +99,6 @@ const SECTION_FIELDS = {
'universityLevel',
'universityName',
'fieldOfStudy',
'workExperienceSummary',
'activitySummary',
],
professional: [
@@ -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;
+1 -1
View File
@@ -7,7 +7,7 @@ export default [
layout: 'counselor',
role: 'counselor',
title: 'سلام بر شما',
subtitle: 'به پنل مشاوره بانو خوش آمدید.',
subtitle: 'به پنل مشاوره مدرسه بانو خوش آمدید.',
},
},
{
@@ -10,7 +10,7 @@
<template #default="{ data, close }">
<form class="sample-modal__form" @submit.prevent="onSubmit(close)">
<p class="sample-modal__intro">
این مودال الگوی استاندارد بنو است: عنوان با
این مودال الگوی استاندارد مدرسه بانو است: عنوان با
<code>LineTitleBlock</code>
در راست، دکمهٔ بستن داخلی در چپ.
</p>
@@ -82,6 +82,7 @@ const onSubmit = async (close) => {
close()
form.value = { subject: '', note: '' }
resetErrors()
// eslint-disable-next-line no-console
console.info('SampleModal submit', payload)
}
</script>
@@ -142,7 +142,7 @@
/>
<BaseButton
variant="secondary"
text="نمایش مودال نمونه (الگوی بنو)"
text="نمایش مودال نمونه (الگوی مدرسه بانو)"
customClass="global-components__btn-wide"
@click="openModal('SampleModal', { context: 'پیش‌نمایش' })"
/>
@@ -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;
+1 -1
View File
@@ -7,7 +7,7 @@ export default [
layout: 'missionary',
role: 'missionary',
title: 'سلام بر شما',
subtitle: 'به پنل اعزام بانو خوش آمدید.',
subtitle: 'به پنل اعزام مدرسه بانو خوش آمدید.',
},
},
{
@@ -25,7 +25,7 @@
</div>
<div v-else class="se__empty">
<SvgIcon name="scroll" :size="128" color="#c3c3c3" />
<p class="se__empty-title">دانشجوی گرامی</p>
<p class="se__empty-title">طلبه گرامی</p>
<p class="se__empty-desc">شما تاکنون هیچ آزمونی را به شروع نکرده اید.</p>
</div>
</div>
@@ -19,6 +19,7 @@
:value="stat.value"
:unit="stat.unit"
:subtitle="stat.subtitle"
:to="stat.to"
/>
</div>
</section>
@@ -95,6 +96,7 @@ const performanceStats = computed(() => {
value: toFaDigits(stats.attendedSessions ?? 0),
unit: 'جلسه',
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;
@@ -101,6 +101,66 @@
option-value="value"
/>
</div>
<div class="edit-profile__cell edit-profile__cell--third">
<TextField
v-model="form.childrenCount"
name="childrenCount"
label="تعداد فرزند"
inputmode="numeric"
:convert-digits="true"
:error="errors.childrenCount"
@blur="validateAt('childrenCount', form.childrenCount)"
>
<template #appendIcon>
<SvgIcon name="users-three" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
</div>
<div class="edit-profile__cell edit-profile__cell--third">
<TextField
v-model="form.virtualPhoneNumber"
name="virtualPhoneNumber"
label="شماره تلفن مجازی"
inputmode="numeric"
:convert-digits="true"
:error="errors.virtualPhoneNumber"
@blur="validateAt('virtualPhoneNumber', form.virtualPhoneNumber)"
>
<template #appendIcon>
<SvgIcon name="phone" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
</div>
<div class="edit-profile__cell edit-profile__cell--third">
<TextField
v-model="form.seminaryCode"
name="seminaryCode"
label="کد حوزوی"
inputmode="numeric"
:convert-digits="true"
:error="errors.seminaryCode"
@blur="validateAt('seminaryCode', form.seminaryCode)"
>
<template #appendIcon>
<SvgIcon name="graduation-cap" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
</div>
<div class="edit-profile__cell edit-profile__cell--third">
<TextField
v-model="form.seminaryServiceCenterCode"
name="seminaryServiceCenterCode"
label="کد مرکز خدمات حوزه"
inputmode="numeric"
:convert-digits="true"
:error="errors.seminaryServiceCenterCode"
@blur="validateAt('seminaryServiceCenterCode', form.seminaryServiceCenterCode)"
>
<template #appendIcon>
<SvgIcon name="check-square" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
</div>
</div>
<LineTitleBlock title="مشخصات محل سکونت" title-en="Address Details" />
@@ -127,6 +187,21 @@
:disabled="!form.provinceId"
/>
</div>
<div class="edit-profile__cell edit-profile__cell--third">
<TextField
v-model="form.postalCode"
name="postalCode"
label="کد پستی"
inputmode="numeric"
:convert-digits="true"
:error="errors.postalCode"
@blur="validateAt('postalCode', form.postalCode)"
>
<template #appendIcon>
<SvgIcon name="map-pin-simple-area" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
</div>
<div class="edit-profile__cell edit-profile__cell--full">
<TextareaField
v-model="form.address"
@@ -227,6 +302,8 @@ import { studentProfileSchema, studentResetPasswordSchema } from '@/features/stu
import {
authKeys,
useGetMeQuery,
useGetRegisterDataQuery,
useSaveRegisterDataMutation,
useUpdateProfileMutation,
useUploadMediaMutation,
} from '@/services/query/auth'
@@ -262,6 +339,11 @@ const form = ref({
provinceId: '',
cityId: '',
address: '',
postalCode: '',
virtualPhoneNumber: '',
seminaryCode: '',
seminaryServiceCenterCode: '',
childrenCount: '',
avatarMediaId: null,
})
@@ -326,6 +408,24 @@ watch(
{ immediate: true }
)
const { data: registerData } = useGetRegisterDataQuery()
watch(
registerData,
(data) => {
if (!data) return
form.value = {
...form.value,
postalCode: data.postalCode || '',
virtualPhoneNumber: data.virtualPhoneNumber || '',
seminaryCode: data.seminaryCode || '',
seminaryServiceCenterCode: data.seminaryServiceCenterCode || '',
childrenCount: data.childrenCount ?? '',
}
},
{ immediate: true }
)
const uploadMutation = useUploadMediaMutation()
const onAvatarCropped = async (file) => {
@@ -343,6 +443,7 @@ const onAvatarCropped = async (file) => {
const onAvatarError = (msg) => toast.error(msg)
const updateMutation = useUpdateProfileMutation()
const saveRegisterDataMutation = useSaveRegisterDataMutation()
const resetPasswordMutation = useUpdateProfileMutation()
const onSubmit = async () => {
@@ -359,7 +460,18 @@ const onSubmit = async () => {
...(payload.avatarMediaId ? { avatarMediaId: payload.avatarMediaId } : {}),
}
await updateMutation.mutateAsync(body)
await saveRegisterDataMutation.mutateAsync({
postalCode: payload.postalCode || '',
virtualPhoneNumber: payload.virtualPhoneNumber || '',
seminaryCode: payload.seminaryCode || '',
seminaryServiceCenterCode: payload.seminaryServiceCenterCode || '',
childrenCount:
payload.childrenCount == null || payload.childrenCount === ''
? ''
: Number(payload.childrenCount),
})
await queryClient.invalidateQueries({ queryKey: authKeys.me() })
await queryClient.invalidateQueries({ queryKey: authKeys.registerData() })
toast.success('پروفایل با موفقیت به‌روز شد')
router.push({ name: 'student-dashboard' })
}
@@ -15,6 +15,23 @@ export const studentProfileSchema = object().shape({
provinceId: string().nullable().notRequired(),
cityId: string().nullable().notRequired(),
address: string().nullable().notRequired(),
postalCode: string()
.nullable()
.notRequired()
.matches(/^\d{10}$/, { message: 'کد پستی باید ۱۰ رقم باشد', excludeEmptyString: true }),
virtualPhoneNumber: string()
.nullable()
.notRequired()
.matches(/^\d{10,15}$/, {
message: 'شماره تلفن مجازی باید فقط شامل ۱۰ تا ۱۵ رقم باشد',
excludeEmptyString: true,
}),
seminaryCode: string().nullable().notRequired(),
seminaryServiceCenterCode: string().nullable().notRequired(),
childrenCount: string()
.nullable()
.notRequired()
.matches(/^\d+$/, { message: 'تعداد فرزند باید عدد باشد', excludeEmptyString: true }),
})
export const studentResetPasswordSchema = object().shape({
+1 -1
View File
@@ -7,7 +7,7 @@ export default [
layout: 'student',
role: 'student',
title: 'سلام بر شما',
subtitle: 'به سامانه موسسه بانو خوش آمدید.',
subtitle: 'به سامانه مدرسه بانو خوش آمدید.',
},
},
{
@@ -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()
@@ -32,7 +32,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"
:model-value="session?.audioUrl"
disabled
/>
</div>
-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',
+1 -1
View File
@@ -1,7 +1,7 @@
<template>
<aside class="admin-sidebar">
<div class="admin-sidebar__logo">
<img :src="logo" alt="banu" />
<img :src="logo" alt="مدرسه بانو" />
</div>
<nav class="admin-sidebar__nav">
<ul>
+1 -1
View File
@@ -1,7 +1,7 @@
<template>
<aside class="counselor-sidebar">
<div class="counselor-sidebar__logo">
<img :src="logo" alt="banu" />
<img :src="logo" alt="مدرسه بانو" />
</div>
<nav class="counselor-sidebar__nav">
<ul>
+1 -1
View File
@@ -1,7 +1,7 @@
<template>
<aside class="student-sidebar" :style="{ backgroundImage: `url(${bgPattern})` }">
<div class="student-sidebar__logo">
<img :src="logo" alt="banu" />
<img :src="logo" alt="مدرسه بانو" />
</div>
<nav class="student-sidebar__nav">
<ul>
+1 -1
View File
@@ -2,7 +2,7 @@ import { ROLE_HOME_ROUTES } from '@/enums'
import { useAuthStore } from '@/store/auth'
import { tokenService } from '@/services/api/token-service'
const APP_TITLE = 'بنو'
const APP_TITLE = 'مدرسه بانو'
const isPublic = (to) => to.meta?.public === true
+1 -1
View File
@@ -14,7 +14,7 @@ export const endpoints = {
verifyForgotPasswordCode: '/auth/password/verify-otp',
resetPassword: '/auth/password/reset',
resendVerificationCodeForRegister: '/resend-verification-code',
resendVerificationCodeForRegister: '/auth/login/otp/request',
verifyCode: '/auth/login/otp/verify',
completeProfile: '/complete-profile',
getRegistrationQuestionVideo: '/verification-registration-media',
+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: [
{
@@ -32,7 +32,6 @@ const makeStudent = (i) => {
universityLevel: 'not_applicable',
universityName: 'حوزه علمیه قم',
fieldOfStudy: 'فقه و اصول',
workExperienceSummary: 'پنج سال تدریس در مدرسه دینی.',
activitySummary: 'برگزاری جلسات قرآن و حدیث به صورت هفتگی.',
propagationExperienceYears: 6,
propagationMethodDescription: 'محوریت بیان داستان‌های قرآنی و قرابت‌های اخلاقی.',
+1 -1
View File
@@ -207,7 +207,7 @@ export const adminTickets = [
type: 'service',
category: 'insurance',
status: 'answered',
subject: 'درخواست بیمه دانشجویی',
subject: 'درخواست بیمه طلبگی',
createdAt: '2026-05-10T08:20:00.000Z',
media: [],
student: studentJane,
@@ -38,7 +38,7 @@ export const missionaryRequests = [
requesterPhone: '09354445566',
requesterEmail: 'fatemeh@example.com',
title: 'جلسه پرسش و پاسخ اعتقادی دانشگاه',
description: 'برگزاری نشست پرسش و پاسخ برای دانشجویان خوابگاه.',
description: 'برگزاری نشست پرسش و پاسخ برای طلبه‌های خوابگاه.',
location: 'اصفهان، ایران',
requestedDate: '2026-06-02',
status: 'accepted',
+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,
}))
-1
View File
@@ -49,7 +49,6 @@ register('GET', endpoints.getUserRegisterData, ({ params }) => {
{ key: 'universityLevel', value: 'not_applicable' },
{ key: 'universityName', value: 'حوزه علمیه قم' },
{ key: 'fieldOfStudy', value: 'فقه و اصول' },
{ key: 'workExperienceSummary', value: 'پنج سال تدریس در مدرسه دینی.' },
{ key: 'activitySummary', value: 'برگزاری جلسات قرآن و حدیث به صورت هفتگی.' },
{ key: 'specializedTopics', value: 'تربیت دینی نوجوانان' },
{ key: 'propagationExperienceYears', value: 6 },
-4
View File
@@ -27,10 +27,6 @@ register('POST', endpoints.register, ({ data }) => ({
data: { user: { ...currentMe, ...data }, token: fakeToken },
}))
register('POST', endpoints.resendVerificationCodeForRegister, () => ({
data: { message: 'کد جدید ارسال شد' },
}))
register('POST', endpoints.forgotPassword, () => ({
success: true,
message: 'If the phone is registered, a code has been sent.',
@@ -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,
}))
+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,
},
})