DONE: digital card with profile display: WIP: membership condition on letter, is_first_time column in users table

This commit is contained in:
ISMAIL MASSERAN
2026-07-06 09:43:58 +08:00
parent 61345e9cf0
commit ac8f00175a
34 changed files with 1662 additions and 99 deletions
+2
View File
@@ -57,6 +57,7 @@
"lodash": "^4.17.21",
"maplibre-gl": "^5.18.0",
"pinia": "^3.0.4",
"qrcode": "^1.5.4",
"sweetalert2": "^11.26.25",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.1.18",
@@ -71,6 +72,7 @@
"@types/jsdom": "^27.0.0",
"@types/lodash": "^4.17.21",
"@types/node": "^24.10.4",
"@types/qrcode": "^1.5.6",
"@vitejs/plugin-vue": "^6.0.3",
"@vitest/eslint-plugin": "^1.6.4",
"@vue/eslint-config-prettier": "^10.2.0",
+164
View File
@@ -119,6 +119,9 @@ importers:
pinia:
specifier: ^3.0.4
version: 3.0.4(typescript@5.9.3)(vue@3.5.35(typescript@5.9.3))
qrcode:
specifier: ^1.5.4
version: 1.5.4
sweetalert2:
specifier: ^11.26.25
version: 11.26.25
@@ -156,6 +159,9 @@ importers:
'@types/node':
specifier: ^24.10.4
version: 24.12.4
'@types/qrcode':
specifier: ^1.5.6
version: 1.5.6
'@vitejs/plugin-vue':
specifier: ^6.0.3
version: 6.0.7(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0))(vue@3.5.35(typescript@5.9.3))
@@ -1041,6 +1047,9 @@ packages:
'@types/pako@2.0.4':
resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==}
'@types/qrcode@1.5.6':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
'@types/raf@3.4.3':
resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==}
@@ -1588,6 +1597,10 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
caniuse-lite@1.0.30001793:
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
@@ -1637,6 +1650,9 @@ packages:
resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==}
engines: {node: '>=20'}
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@@ -1758,6 +1774,10 @@ packages:
supports-color:
optional: true
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
@@ -1784,6 +1804,9 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
dompurify@3.4.7:
resolution: {integrity: sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==}
@@ -2055,6 +2078,10 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
@@ -2109,6 +2136,10 @@ packages:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
get-east-asian-width@1.6.0:
resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==}
engines: {node: '>=18'}
@@ -2501,6 +2532,10 @@ packages:
resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==}
engines: {node: '>=20.0.0'}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@@ -2679,14 +2714,26 @@ packages:
ospath@1.2.2:
resolution: {integrity: sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
p-locate@4.1.0:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
@@ -2769,6 +2816,10 @@ packages:
typescript:
optional: true
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
postcss-selector-parser@7.1.1:
resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
engines: {node: '>=4'}
@@ -2833,6 +2884,11 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
qs@6.15.2:
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
engines: {node: '>=0.6'}
@@ -2856,10 +2912,17 @@ packages:
request-progress@3.0.0:
resolution: {integrity: sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -2916,6 +2979,9 @@ packages:
engines: {node: '>=10'}
hasBin: true
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -3393,6 +3459,9 @@ packages:
resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==}
engines: {node: '>=20'}
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@@ -3420,6 +3489,10 @@ packages:
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
engines: {node: '>=0.8'}
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -3467,9 +3540,20 @@ packages:
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
yauzl@3.3.2:
resolution: {integrity: sha512-Md9ankxxN23wncAN8s7+Tn3Co52zLUPMtnrLAbVCnfG5d2tKBFfmygYSgXlqFgXObtzIgqkx7aNgDBpso9+4qA==}
engines: {node: '>=12'}
@@ -4215,6 +4299,10 @@ snapshots:
'@types/pako@2.0.4': {}
'@types/qrcode@1.5.6':
dependencies:
'@types/node': 24.12.4
'@types/raf@3.4.3':
optional: true
@@ -4978,6 +5066,8 @@ snapshots:
callsites@3.1.0: {}
camelcase@5.3.1: {}
caniuse-lite@1.0.30001793: {}
canvg@3.0.11:
@@ -5033,6 +5123,12 @@ snapshots:
slice-ansi: 8.0.0
string-width: 8.2.1
cliui@6.0.0:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
clsx@2.1.1: {}
codepage@1.15.0: {}
@@ -5172,6 +5268,8 @@ snapshots:
optionalDependencies:
supports-color: 8.1.1
decamelize@1.2.0: {}
decimal.js@10.6.0: {}
deep-is@0.1.4: {}
@@ -5189,6 +5287,8 @@ snapshots:
detect-libc@2.1.2: {}
dijkstrajs@1.0.3: {}
dompurify@3.4.7:
optionalDependencies:
'@types/trusted-types': 2.0.7
@@ -5505,6 +5605,11 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
find-up@4.1.0:
dependencies:
locate-path: 5.0.0
path-exists: 4.0.0
find-up@5.0.0:
dependencies:
locate-path: 6.0.0
@@ -5554,6 +5659,8 @@ snapshots:
gensync@1.0.0-beta.2: {}
get-caller-file@2.0.5: {}
get-east-asian-width@1.6.0: {}
get-intrinsic@1.3.0:
@@ -5928,6 +6035,10 @@ snapshots:
rfdc: 1.4.1
wrap-ansi: 9.0.2
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@@ -6107,14 +6218,24 @@ snapshots:
ospath@1.2.2: {}
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
p-limit@3.1.0:
dependencies:
yocto-queue: 0.1.0
p-locate@4.1.0:
dependencies:
p-limit: 2.3.0
p-locate@5.0.0:
dependencies:
p-limit: 3.1.0
p-try@2.2.0: {}
package-json-from-dist@1.0.1: {}
pako@2.1.0: {}
@@ -6177,6 +6298,8 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
pngjs@5.0.0: {}
postcss-selector-parser@7.1.1:
dependencies:
cssesc: 3.0.0
@@ -6225,6 +6348,12 @@ snapshots:
punycode@2.3.1: {}
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
qs@6.15.2:
dependencies:
side-channel: 1.1.0
@@ -6250,8 +6379,12 @@ snapshots:
dependencies:
throttleit: 1.0.1
require-directory@2.1.1: {}
require-from-string@2.0.2: {}
require-main-filename@2.0.0: {}
resolve-from@4.0.0: {}
resolve-protobuf-schema@2.1.0:
@@ -6323,6 +6456,8 @@ snapshots:
semver@7.8.1: {}
set-blocking@2.0.0: {}
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
@@ -6781,6 +6916,8 @@ snapshots:
tr46: 6.0.0
webidl-conversions: 8.0.1
which-module@2.0.1: {}
which@2.0.2:
dependencies:
isexe: 2.0.0
@@ -6800,6 +6937,12 @@ snapshots:
word@0.3.0: {}
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
@@ -6843,8 +6986,29 @@ snapshots:
xmlchars@2.2.0: {}
y18n@4.0.3: {}
yallist@3.1.1: {}
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
yargs@15.4.1:
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
yauzl@3.3.2:
dependencies:
pend: 1.2.0
+1
View File
@@ -37,6 +37,7 @@ export interface AuthUser {
image_url: string | null
member_number: number | null
member_type: string | null
public_profile_token: string | null
status: string
gender: string | null
marriage_status: string | null
@@ -0,0 +1,186 @@
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import Swal from 'sweetalert2'
import { Button } from '@/components/ui/button'
import { Lucide } from '@/components/ui/lucide'
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
import { downloadMemberDigitalCard } from '../services/member-digital-card.service'
import { toProxiedStorageUrl } from '../utils/member-digital-card.utils'
import MemberDigitalCardFlip from './MemberDigitalCardFlip.vue'
const props = defineProps<{
memberNumber?: string | number | null
memberName?: string | null
memberType?: string | null
companyName?: string | null
profileUrl?: string | null
imageUrl?: string | null
large?: boolean
}>()
const previewMaxWidthClass = computed(() =>
props.large ? 'max-w-sm sm:max-w-md lg:max-w-lg' : 'max-w-68 sm:max-w-xs',
)
const resolvedImageUrl = computed(() => toProxiedStorageUrl(props.imageUrl))
const isFlipped = ref(false)
const expandedOpen = ref(false)
const isPortraitPhone = ref(false)
const downloading = ref(false)
let portraitQuery: MediaQueryList | null = null
function updatePortraitPhone() {
isPortraitPhone.value = portraitQuery?.matches ?? false
}
function openExpanded() {
expandedOpen.value = true
}
function closeExpanded() {
expandedOpen.value = false
}
function toggleFlip() {
isFlipped.value = !isFlipped.value
}
async function downloadCard() {
if (downloading.value) return
downloading.value = true
const side = isFlipped.value ? 'belakang' : 'depan'
try {
await downloadMemberDigitalCard(props.memberNumber, side)
await Swal.fire({
toast: true,
position: 'top-end',
icon: 'success',
title: `Kad ${side} berjaya disimpan.`,
showConfirmButton: false,
timer: 3000,
})
} catch (error) {
await Swal.fire({
icon: 'error',
title: 'Ralat',
text: getApiErrorMessage(error, 'Gagal menyimpan kad.'),
})
} finally {
downloading.value = false
}
}
watch(expandedOpen, (open) => {
document.body.style.overflow = open ? 'hidden' : ''
})
onMounted(() => {
portraitQuery = window.matchMedia('(max-width: 767px) and (orientation: portrait)')
updatePortraitPhone()
portraitQuery.addEventListener('change', updatePortraitPhone)
})
onUnmounted(() => {
document.body.style.overflow = ''
portraitQuery?.removeEventListener('change', updatePortraitPhone)
})
</script>
<template>
<div class="flex w-full flex-col items-center gap-2">
<button
type="button"
class="relative w-full cursor-pointer border-0 bg-transparent p-0 transition-transform active:scale-[0.98]"
:class="previewMaxWidthClass"
aria-label="Buka kad digital penuh"
@click="openExpanded">
<MemberDigitalCardFlip :member-number="memberNumber" :member-name="memberName"
:member-type="memberType" :company-name="companyName" :profile-url="profileUrl"
:image-url="resolvedImageUrl" :is-flipped="isFlipped" />
</button>
<p class="text-center text-[11px] text-slate-500">
Klik kad untuk paparan penuh
</p>
<div class="flex flex-wrap items-center justify-center gap-2">
<Button type="button" variant="ghost" class="border border-foreground/15 shadow-none text-xs"
:aria-pressed="isFlipped" :disabled="downloading" @click="toggleFlip">
<Lucide class="mr-2 size-4" icon="RotateCw" />
{{ isFlipped ? 'Papar depan kad' : 'Imbas kod QR' }}
</Button>
<Button type="button" variant="ghost" class="border border-foreground/15 shadow-none text-xs"
:disabled="downloading" @click="downloadCard">
<Lucide class="mr-2 size-4" :icon="downloading ? 'LoaderCircle' : 'Download'"
:class="{ 'animate-spin': downloading }" />
{{ downloading ? 'Menyimpan...' : 'Simpan kad' }}
</Button>
</div>
<Teleport to="body">
<div
v-if="expandedOpen"
class="fixed inset-0 z-70 flex flex-col items-center justify-center gap-4 bg-black/90 p-5"
role="dialog"
aria-modal="true"
aria-label="Kad digital anggota"
@click.self="closeExpanded">
<button
type="button"
class="absolute right-4 top-4 flex size-10 items-center justify-center rounded-full border border-white/20 bg-white/10 text-white"
aria-label="Tutup"
@click="closeExpanded">
<Lucide class="size-5" icon="X" />
</button>
<div v-if="isPortraitPhone" class="flex items-center justify-center" @click.stop>
<div class="w-[min(90vh,34rem)] rotate-90">
<MemberDigitalCardFlip :member-number="memberNumber" :member-name="memberName"
:member-type="memberType" :company-name="companyName" :profile-url="profileUrl"
:image-url="resolvedImageUrl" :is-flipped="isFlipped" expanded />
</div>
</div>
<div v-else class="w-[min(100vw-2rem,32rem)] lg:w-[min(100vw-2rem,40rem)]" @click.stop>
<MemberDigitalCardFlip :member-number="memberNumber" :member-name="memberName"
:member-type="memberType" :company-name="companyName" :profile-url="profileUrl"
:image-url="resolvedImageUrl" :is-flipped="isFlipped" expanded />
</div>
<div class="flex flex-wrap items-center justify-center gap-2">
<Button
type="button"
variant="ghost"
class="border border-white/20 bg-white/10 text-xs text-white shadow-none hover:bg-white/15"
:aria-pressed="isFlipped"
:disabled="downloading"
@click.stop="toggleFlip">
<Lucide class="mr-2 size-4" icon="RotateCw" />
{{ isFlipped ? 'Papar depan kad' : 'Imbas kod QR' }}
</Button>
<Button
type="button"
variant="ghost"
class="border border-white/20 bg-white/10 text-xs text-white shadow-none hover:bg-white/15"
:disabled="downloading"
@click.stop="downloadCard">
<Lucide class="mr-2 size-4" :icon="downloading ? 'LoaderCircle' : 'Download'"
:class="{ 'animate-spin': downloading }" />
{{ downloading ? 'Menyimpan...' : 'Simpan kad' }}
</Button>
</div>
<p class="text-center text-xs text-white/60">
Klik di luar kad untuk tutup
</p>
</div>
</Teleport>
</div>
</template>
@@ -0,0 +1,104 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
import QRCode from 'qrcode'
import logoUrl from '@/assets/images/logo.svg'
import { displayCardValue } from '../utils/member-digital-card.utils'
const props = withDefaults(
defineProps<{
profileUrl?: string | null
memberNumber?: string | number | null
expanded?: boolean
}>(),
{ expanded: false },
)
const qrDataUrl = ref('')
const qrError = ref(false)
const qrPixelSize = computed(() => (props.expanded ? 220 : 120))
async function renderQrCode() {
if (!props.profileUrl) {
qrDataUrl.value = ''
qrError.value = false
return
}
try {
qrDataUrl.value = await QRCode.toDataURL(props.profileUrl, {
margin: 1,
width: qrPixelSize.value,
color: {
dark: '#0f172a',
light: '#ffffff',
},
})
qrError.value = false
} catch {
qrDataUrl.value = ''
qrError.value = true
}
}
watch([() => props.profileUrl, () => props.expanded], renderQrCode, { immediate: true })
</script>
<template>
<div :class="[
'relative h-full w-full overflow-hidden rounded-2xl bg-linear-to-br from-primary/90 via-primary to-primary/80 text-primary-foreground shadow-lg ring-1 ring-white/20',
expanded ? 'p-5 sm:p-6' : 'p-3 sm:p-4',
]">
<div class="pointer-events-none absolute inset-0 bg-noise opacity-30" />
<div class="pointer-events-none absolute -right-12 -top-12 rounded-full bg-white/10"
:class="expanded ? 'size-44' : 'size-32'" />
<div class="pointer-events-none absolute -bottom-16 -left-10 rounded-full bg-white/5"
:class="expanded ? 'size-48' : 'size-36'" />
<div class="relative flex h-full min-h-0 flex-col">
<div class="flex shrink-0 items-center justify-between gap-2">
<img :src="logoUrl" alt="" class="w-auto shrink-0 brightness-0 invert"
:class="expanded ? 'h-7 sm:h-9' : 'h-5 sm:h-6'" />
<div class="text-right font-semibold uppercase opacity-75"
:class="expanded ? 'text-sm tracking-[0.18em]' : 'text-[9px] tracking-[0.18em]'">
Belakang · Kod QR
</div>
</div>
<div class="flex min-h-0 flex-1 items-center" :class="expanded ? 'mt-5 gap-6' : 'mt-3 gap-3'">
<div class="shrink-0 rounded-lg bg-white shadow-sm" :class="expanded ? 'p-3' : 'p-1.5'">
<img v-if="qrDataUrl" :src="qrDataUrl" alt="Kod QR profil anggota" class="block"
:class="expanded ? 'size-32 sm:size-36' : 'size-18 sm:size-20'" />
<div v-else class="flex items-center justify-center"
:class="expanded ? 'size-32 sm:size-36' : 'size-18 sm:size-20'">
<span class="px-1 text-center leading-tight text-slate-500" :class="expanded ? 'text-sm' : 'text-[9px]'">
{{ qrError ? 'Kod QR tidak tersedia.' : 'Memuatkan...' }}
</span>
</div>
</div>
<div class="flex min-w-0 flex-1 flex-col justify-center" :class="expanded ? 'gap-5' : 'gap-3'">
<p class="leading-snug opacity-85" :class="expanded ? 'text-base sm:text-lg' : 'text-[9px] sm:text-[10px]'">
Imbas untuk sahkan profil anggota MyKOPKB.
</p>
<div>
<div class="font-medium uppercase tracking-widest opacity-60" :class="expanded ? 'text-sm' : 'text-[9px]'">
No. Anggota
</div>
<div class="mt-0.5 font-mono font-semibold tracking-widest"
:class="expanded ? 'text-3xl sm:text-4xl' : 'text-base sm:text-lg'">
{{ displayCardValue(memberNumber) }}
</div>
</div>
</div>
</div>
<div class="shrink-0 border-t border-white/15 text-center" :class="expanded ? 'mt-4 pt-3' : 'mt-2 pt-2'">
<p class="uppercase opacity-50" :class="expanded ? 'text-xs tracking-[0.2em]' : 'text-[8px] tracking-[0.2em]'">
Koperasi Permodalan Kelantan Berhad (KOPKB)
</p>
</div>
</div>
</div>
</template>
@@ -0,0 +1,34 @@
<script lang="ts" setup>
import MemberDigitalCardBack from './MemberDigitalCardBack.vue'
import MemberDigitalCardFront from './MemberDigitalCardFront.vue'
defineProps<{
memberNumber?: string | number | null
memberName?: string | null
memberType?: string | null
companyName?: string | null
profileUrl?: string | null
imageUrl?: string | null
isFlipped: boolean
expanded?: boolean
}>()
</script>
<template>
<div class="relative w-full" style="perspective: 1000px">
<div
class="relative aspect-7/4.5 w-full transition-transform duration-500 ease-in-out"
:style="{
transformStyle: 'preserve-3d',
transform: isFlipped ? 'rotateY(180deg)' : 'rotateY(0deg)',
}">
<div class="absolute inset-0" style="backface-visibility: hidden">
<MemberDigitalCardFront :member-number="memberNumber" :member-name="memberName"
:member-type="memberType" :company-name="companyName" :image-url="imageUrl" :expanded="expanded" />
</div>
<div class="absolute inset-0" :style="{ backfaceVisibility: 'hidden', transform: 'rotateY(180deg)' }">
<MemberDigitalCardBack :profile-url="profileUrl" :member-number="memberNumber" :expanded="expanded" />
</div>
</div>
</div>
</template>
@@ -0,0 +1,97 @@
<script lang="ts" setup>
import { computed } from 'vue'
import logoUrl from '@/assets/images/logo.svg'
import { displayCardValue } from '../utils/member-digital-card.utils'
const props = withDefaults(
defineProps<{
memberNumber?: string | number | null
memberName?: string | null
memberType?: string | null
companyName?: string | null
imageUrl?: string | null
expanded?: boolean
}>(),
{ expanded: false },
)
const avatarFallback = computed(() => {
const name = props.memberName?.trim()
if (!name) return '--'
return name.slice(0, 2).toUpperCase()
})
</script>
<template>
<div :class="[
'relative h-full w-full overflow-hidden rounded-2xl bg-linear-to-br from-primary via-primary/95 to-primary/75 text-primary-foreground shadow-lg ring-1 ring-white/20',
expanded ? 'p-6 sm:p-8' : 'p-4 sm:p-5',
]">
<div class="pointer-events-none absolute inset-0 bg-noise opacity-30" />
<div class="pointer-events-none absolute -right-10 -top-10 rounded-full bg-white/10"
:class="expanded ? 'size-48' : 'size-36'" />
<div class="pointer-events-none absolute -bottom-12 -left-8 rounded-full bg-white/5"
:class="expanded ? 'size-52' : 'size-40'" />
<div
class="pointer-events-none absolute top-1/2 -translate-y-1/2 overflow-hidden rounded-md border border-white/25 bg-white/10 shadow-sm"
:class="expanded ? 'right-6 size-20 sm:size-24' : 'right-4 size-14'">
<img v-if="imageUrl" :src="imageUrl" :alt="memberName ?? 'Profil anggota'" class="size-full object-cover" />
<div v-else class="flex size-full items-center justify-center bg-white/15 font-semibold uppercase tracking-wide"
:class="expanded ? 'text-base' : 'text-[11px]'">
{{ avatarFallback }}
</div>
</div>
<div class="relative flex h-full min-h-0 flex-col">
<div class="flex shrink-0 items-start justify-between gap-3">
<img :src="logoUrl" alt="" class="w-auto brightness-0 invert"
:class="expanded ? 'h-8 sm:h-10' : 'h-6 sm:h-7'" />
<div class="text-right font-semibold uppercase opacity-80"
:class="expanded ? 'text-sm tracking-[0.2em]' : 'text-[10px] tracking-[0.2em]'">
Kad Digital
</div>
</div>
<div class="flex min-h-0 flex-1 flex-col justify-center py-2" :class="expanded ? 'gap-4' : 'gap-2'">
<div>
<div class="font-medium uppercase tracking-widest opacity-70" :class="expanded ? 'text-sm' : 'text-[10px]'">
No. Anggota
</div>
<div class="mt-0.5 font-mono font-semibold"
:class="expanded ? 'text-4xl tracking-[0.15em] sm:text-5xl' : 'text-xl tracking-[0.15em] sm:text-2xl'">
{{ displayCardValue(memberNumber) }}
</div>
</div>
<div class="min-w-0" :class="expanded ? 'pr-28 sm:pr-32' : 'pr-16'">
<div class="font-medium uppercase tracking-widest opacity-70" :class="expanded ? 'text-sm' : 'text-[10px]'">
Unit
</div>
<div class="truncate font-medium" :class="expanded ? 'text-lg sm:text-xl' : 'text-xs'">
{{ displayCardValue(companyName) }}
</div>
</div>
</div>
<div class="flex shrink-0 items-end justify-between gap-3 border-t border-white/15"
:class="expanded ? 'pt-4' : 'pt-2'">
<div class="min-w-0 flex-1">
<div class="truncate font-medium" :class="expanded ? 'text-xl sm:text-2xl' : 'text-sm'">
{{ memberName || '-' }}
</div>
<div class="mt-0.5 uppercase tracking-wide opacity-60" :class="expanded ? 'text-sm' : 'text-[10px]'">
Nama
</div>
</div>
<div class="shrink-0 text-right">
<div class="font-semibold" :class="expanded ? 'text-xl sm:text-2xl' : 'text-sm'">
{{ displayCardValue(memberType) }}
</div>
<div class="mt-0.5 uppercase tracking-wide opacity-60" :class="expanded ? 'text-sm' : 'text-[10px]'">
Jenis Anggota
</div>
</div>
</div>
</div>
</div>
</template>
+1 -1
View File
@@ -1,2 +1,2 @@
export { profileLayoutRoutes } from './routes'
export { profileLayoutRoutes, profilePublicRoutes } from './routes'
export { profileMenu } from './menu'
@@ -0,0 +1,140 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Box } from '@/components/ui/box'
import { Lucide } from '@/components/ui/lucide'
import logoUrl from '@/assets/images/logo.svg'
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
import { getPublicMemberProfile } from '../services/public-member-profile.service'
import type { PublicMemberProfile } from '../types/public-member-profile.types'
const route = useRoute()
const loading = ref(true)
const error = ref<string | null>(null)
const member = ref<PublicMemberProfile | null>(null)
const token = computed(() => String(route.params.token ?? '').trim())
const avatarFallback = computed(() => {
const name = member.value?.name?.trim()
if (!name) return '--'
return name.slice(0, 2).toUpperCase()
})
const displayValue = (value: string | number | null | undefined) => {
if (value === null || value === undefined || value === '') return '-'
return String(value).trim() || '-'
}
async function fetchMemberProfile() {
loading.value = true
error.value = null
member.value = null
if (!token.value) {
error.value = 'Pautan pengesahan tidak sah.'
loading.value = false
return
}
try {
const response = await getPublicMemberProfile(token.value)
if (!response.success || !response.data) {
throw new Error(response.message ?? 'Anggota tidak dijumpai atau tidak sah.')
}
member.value = response.data
} catch (err) {
error.value = getApiErrorMessage(err, 'Gagal memuatkan profil anggota.')
} finally {
loading.value = false
}
}
onMounted(() => {
fetchMemberProfile()
})
</script>
<template>
<div class="min-h-screen bg-slate-100 px-4 py-10">
<div class="mx-auto w-full max-w-md">
<div class="mb-6 flex flex-col items-center text-center">
<img :src="logoUrl" alt="MyKOPKB" class="h-10 w-auto" />
<h1 class="mt-4 text-xl font-semibold text-slate-900">Maklumat Anggota</h1>
</div>
<AlertRoot v-if="error" variant="danger">
<AlertTitle>Pengesahan gagal</AlertTitle>
<AlertDescription>{{ error }}</AlertDescription>
</AlertRoot>
<Box v-else-if="loading" raised="single" class="p-8 text-center text-sm text-slate-500">
Memuatkan maklumat anggota...
</Box>
<Box v-else-if="member" raised="single" class="overflow-hidden p-0">
<div class="bg-linear-to-br from-primary via-primary/95 to-primary/75 p-6 text-primary-foreground">
<div class="flex items-start justify-between gap-3">
<Badge class="bg-white/15 text-white">Disahkan</Badge>
<div class="text-right text-[10px] font-semibold uppercase tracking-[0.2em] opacity-80">
MyKOPKB
</div>
</div>
<div class="mt-6 flex items-center gap-4">
<AvatarRoot class="size-16 border-4 border-white/20 bg-white/10">
<AvatarFallback>{{ avatarFallback }}</AvatarFallback>
<AvatarImage v-if="member.image_url" :src="member.image_url" :alt="member.name" />
</AvatarRoot>
<div class="min-w-0 flex-1">
<div class="truncate text-lg font-semibold">{{ member.name }}</div>
<div class="mt-1 text-sm opacity-80">{{ displayValue(member.member_type) }}</div>
</div>
</div>
</div>
<div class="space-y-4 p-6">
<div class="flex items-center gap-3 rounded-lg border border-foreground/10 p-4">
<Lucide class="size-5 text-primary" icon="IdCard" />
<div>
<div class="text-xs uppercase tracking-wide text-slate-500">No. Anggota</div>
<div class="font-mono text-base font-semibold text-slate-900">
{{ displayValue(member.member_number) }}
</div>
</div>
</div>
<div class="flex items-center gap-3 rounded-lg border border-foreground/10 p-4">
<Lucide class="size-5 text-primary" icon="Briefcase" />
<div class="min-w-0">
<div class="text-xs uppercase tracking-wide text-slate-500">Syarikat</div>
<div class="truncate text-base font-medium text-slate-900">
{{ displayValue(member.company_name) }}
</div>
</div>
</div>
<div class="flex items-center gap-3 rounded-lg border border-foreground/10 p-4">
<Lucide class="size-5 text-primary" icon="CircleCheck" />
<div>
<div class="text-xs uppercase tracking-wide text-slate-500">Status</div>
<div class="text-base font-medium capitalize text-slate-900">
{{ displayValue(member.status) }}
</div>
</div>
</div>
<p class="text-center text-xs text-slate-500">
Disahkan pada {{ new Date(member.verified_at).toLocaleString('ms-MY') }}
</p>
</div>
</Box>
</div>
</div>
</template>
+9
View File
@@ -1,5 +1,14 @@
import type { RouteRecordRaw } from 'vue-router'
export const profilePublicRoutes: RouteRecordRaw[] = [
{
path: '/v/:token',
name: 'public-member-profile',
component: () => import('./pages/PublicMemberProfile.vue'),
meta: { public: true, module: 'profile' },
},
]
export const profileLayoutRoutes: RouteRecordRaw[] = [
{
path: 'profile-overview-2',
@@ -0,0 +1,35 @@
import { saveAs } from 'file-saver'
import axios from 'axios'
import { api } from '@/core/services/api'
import { buildCardDownloadFileName } from '../utils/member-digital-card.utils'
export async function downloadMemberDigitalCard(
memberNumber: string | number | null | undefined,
side: 'depan' | 'belakang',
) {
try {
const { data } = await api.get<Blob>('/v1/profile/digital-card', {
params: { side },
responseType: 'blob',
})
saveAs(data, buildCardDownloadFileName(memberNumber, side))
} catch (error) {
if (axios.isAxiosError(error) && error.response?.data instanceof Blob) {
const text = await error.response.data.text()
try {
const payload = JSON.parse(text) as { message?: string }
throw new Error(payload.message ?? 'Gagal menyimpan kad.')
} catch (parseError) {
if (parseError instanceof SyntaxError) {
throw error
}
throw parseError
}
}
throw error
}
}
@@ -0,0 +1,12 @@
import { api } from '@/core/services/api'
import type { PublicMemberProfileApiResponse } from '../types/public-member-profile.types'
export async function getPublicMemberProfile(
token: string,
): Promise<PublicMemberProfileApiResponse> {
const { data } = await api.get<PublicMemberProfileApiResponse>(
`/v1/public/members/${encodeURIComponent(token)}`,
)
return data
}
@@ -0,0 +1,16 @@
export interface PublicMemberProfile {
name: string
member_number: number | null
member_type: string | null
status: string
image_url: string | null
company_name: string | null
verified_at: string
}
export interface PublicMemberProfileApiResponse {
success: boolean
message?: string
code?: 'public_profile_not_found' | 'public_profile_token_expired'
data: PublicMemberProfile | null
}
@@ -0,0 +1,27 @@
export function displayCardValue(value: string | number | null | undefined) {
if (value === null || value === undefined || value === '') return '-'
return String(value).trim() || '-'
}
export function buildCardDownloadFileName(
memberNumber: string | number | null | undefined,
side: 'depan' | 'belakang',
) {
const number = memberNumber ?? 'anggota'
return `kad-digital-${number}-${side}.png`
}
export function toProxiedStorageUrl(url: string | null | undefined): string | null | undefined {
if (!url) return url
try {
const parsed = new URL(url, window.location.origin)
if (parsed.pathname.startsWith('/storage/')) {
return `${parsed.pathname}${parsed.search}`
}
} catch {
// Keep original URL when parsing fails.
}
return url
}
+2 -96
View File
@@ -5,7 +5,7 @@ import { hasAnyActiveRolePermission } from '@/core/utils/activeRolePermission'
import { getRouteRequiredPermissions } from '@/core/utils/routePermission'
import { authPublicRoutes, resolvePostAuthRoute } from '@/modules/auth'
import { membershipApplicationPublicRoutes, membershipApplicationLayoutRoutes } from '@/modules/membership-application'
import { profileLayoutRoutes } from '@/modules/profile'
import { profileLayoutRoutes, profilePublicRoutes } from '@/modules/profile'
import { pinia } from '@/pinia'
import { useAuthStore } from '@/stores/auth'
import { userLayoutRoutes } from '@/modules/user'
@@ -70,11 +70,6 @@ const router = createRouter({
name: 'reviews',
component: () => import('../views/Reviews.vue'),
},
{
path: 'inbox',
name: 'inbox',
component: () => import('../views/Inbox.vue'),
},
{
path: 'file-manager',
name: 'file-manager',
@@ -85,51 +80,11 @@ const router = createRouter({
name: 'point-of-sale',
component: () => import('../views/PointOfSale.vue'),
},
{
path: 'chat',
name: 'chat',
component: () => import('../views/Chat.vue'),
},
{
path: 'post',
name: 'post',
component: () => import('../views/Post.vue'),
},
{
path: 'crud-form',
name: 'crud-form',
component: () => import('../views/CrudForm.vue'),
},
{
path: 'users-layout-1',
name: 'users-layout-1',
component: () => import('../views/UsersLayout1.vue'),
},
{
path: 'users-layout-2',
name: 'users-layout-2',
component: () => import('../views/UsersLayout2.vue'),
},
{
path: 'users-layout-3',
name: 'users-layout-3',
component: () => import('../views/UsersLayout3.vue'),
},
{
path: 'wizard-layout-1',
name: 'wizard-layout-1',
component: () => import('../views/WizardLayout1.vue'),
},
{
path: 'wizard-layout-2',
name: 'wizard-layout-2',
component: () => import('../views/WizardLayout2.vue'),
},
{
path: 'wizard-layout-3',
name: 'wizard-layout-3',
component: () => import('../views/WizardLayout3.vue'),
},
{
path: 'blog-layout-1',
name: 'blog-layout-1',
@@ -191,26 +146,6 @@ const router = createRouter({
component: DocsLayout,
children: [
// Documentation
{
path: "accordion",
name: "accordion",
component: () => import("../docs/pages/accordion.vue"),
},
{
path: "alert",
name: "alert",
component: () => import("../docs/pages/alert.vue"),
},
{
path: "avatar",
name: "avatar",
component: () => import("../docs/pages/avatar.vue"),
},
{
path: "badge",
name: "badge",
component: () => import("../docs/pages/badge.vue"),
},
{
path: "box",
name: "box",
@@ -221,26 +156,11 @@ const router = createRouter({
name: "breadcrumb",
component: () => import("../docs/pages/breadcrumb.vue"),
},
{
path: "button",
name: "button",
component: () => import("../docs/pages/button.vue"),
},
{
path: "carousel",
name: "carousel",
component: () => import("../docs/pages/carousel.vue"),
},
{
path: "chart",
name: "chart",
component: () => import("../docs/pages/chart.vue"),
},
{
path: "checkbox",
name: "checkbox",
component: () => import("../docs/pages/checkbox.vue"),
},
{
path: "combobox",
name: "combobox",
@@ -261,16 +181,6 @@ const router = createRouter({
name: "field",
component: () => import("../docs/pages/field.vue"),
},
{
path: "input",
name: "input",
component: () => import("../docs/pages/input.vue"),
},
{
path: "map",
name: "map",
component: () => import("../docs/pages/map.vue"),
},
{
path: "menu",
name: "menu",
@@ -331,11 +241,6 @@ const router = createRouter({
name: "switch",
component: () => import("../docs/pages/switch.vue"),
},
{
path: "table",
name: "table",
component: () => import("../docs/pages/table.vue"),
},
{
path: "tabs",
name: "tabs",
@@ -362,6 +267,7 @@ const router = createRouter({
},
...authPublicRoutes,
...membershipApplicationPublicRoutes,
...profilePublicRoutes,
{
path: '/error-page',
name: 'error-page',
+8
View File
@@ -18,4 +18,12 @@ export default defineConfig({
'@mykopkb/core': fileURLToPath(new URL('./src/components/ui', import.meta.url))
},
},
server: {
proxy: {
'/storage': {
target: process.env.VITE_API_BASE_URL || 'http://localhost',
changeOrigin: true,
},
},
},
})