profile management integration
This commit is contained in:
parent
061c019b22
commit
cda7d9d551
280
package-lock.json
generated
280
package-lock.json
generated
|
|
@ -14,6 +14,7 @@
|
|||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"axios": "^1.13.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.561.0",
|
||||
|
|
@ -86,6 +87,7 @@
|
|||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
|
|
@ -2751,6 +2753,7 @@
|
|||
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
|
|
@ -2761,6 +2764,7 @@
|
|||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
|
|
@ -2771,6 +2775,7 @@
|
|||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
|
|
@ -2826,6 +2831,7 @@
|
|||
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.50.0",
|
||||
"@typescript-eslint/types": "8.50.0",
|
||||
|
|
@ -3077,6 +3083,7 @@
|
|||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -3187,6 +3194,12 @@
|
|||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.4.23",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
|
||||
|
|
@ -3224,6 +3237,17 @@
|
|||
"postcss": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
|
|
@ -3298,6 +3322,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
|
|
@ -3312,6 +3337,19 @@
|
|||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/callsites": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
|
|
@ -3449,6 +3487,18 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
||||
|
|
@ -3673,6 +3723,15 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-node-es": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
|
||||
|
|
@ -3693,6 +3752,20 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.267",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
|
||||
|
|
@ -3700,6 +3773,51 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.43.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz",
|
||||
|
|
@ -3781,6 +3899,7 @@
|
|||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
|
|
@ -4108,6 +4227,42 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||
|
|
@ -4141,7 +4296,6 @@
|
|||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
|
|
@ -4157,6 +4311,30 @@
|
|||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-nonce": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
||||
|
|
@ -4166,6 +4344,19 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
|
|
@ -4192,6 +4383,18 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
|
|
@ -4202,11 +4405,37 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
|
|
@ -4520,6 +4749,15 @@
|
|||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
|
|
@ -4557,6 +4795,27 @@
|
|||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
|
|
@ -4755,6 +5014,7 @@
|
|||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -4802,6 +5062,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
|
|
@ -4948,6 +5209,12 @@
|
|||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
|
|
@ -4984,6 +5251,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -4993,6 +5261,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
|
|
@ -5012,6 +5281,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
|
|
@ -5217,7 +5487,8 @@
|
|||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
|
|
@ -5611,6 +5882,7 @@
|
|||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
@ -5778,6 +6050,7 @@
|
|||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
|
|
@ -5915,6 +6188,7 @@
|
|||
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"axios": "^1.13.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.561.0",
|
||||
|
|
|
|||
22
src/api/auth.api.ts
Normal file
22
src/api/auth.api.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import http from "./http";
|
||||
import type { LoginRequest, LoginResponse, LoginResponseData } from "../types/auth.types";
|
||||
|
||||
export interface LoginResult {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
role: string;
|
||||
user_id: number;
|
||||
}
|
||||
|
||||
export const login = async (payload: LoginRequest): Promise<LoginResult> => {
|
||||
const res = await http.post<LoginResponse>("/auth/customer-login", payload);
|
||||
|
||||
const data: LoginResponseData = res.data.data;
|
||||
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
role: data.role,
|
||||
user_id: data.user_id,
|
||||
};
|
||||
};
|
||||
37
src/api/http.ts
Normal file
37
src/api/http.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import axios, { type AxiosInstance } from "axios";
|
||||
|
||||
const http: AxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
// Attach access token to every request
|
||||
http.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem("access_token");
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Handle 401 globally
|
||||
http.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Clear stored tokens and user info
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
localStorage.removeItem("user_id");
|
||||
localStorage.removeItem("role");
|
||||
|
||||
// Redirect to login page
|
||||
window.location.href = "/login";
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default http;
|
||||
13
src/api/users.api.ts
Normal file
13
src/api/users.api.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import http from "./http";
|
||||
import { type UserProfileResponse, type GetUsersResponse } from "../types/user.types";
|
||||
|
||||
export const getUsers = (page?: number, pageSize?: number) =>
|
||||
http.get<GetUsersResponse>("/users", {
|
||||
params: {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
},
|
||||
});
|
||||
|
||||
export const getUserById = (id: number) =>
|
||||
http.get<UserProfileResponse>(`/user/single/${id}`);
|
||||
|
|
@ -1,9 +1,47 @@
|
|||
"use client" // make sure this is a client component
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Bell } from "lucide-react"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"
|
||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
export function Topbar() {
|
||||
// const [showLogoutConfirm, setShowLogoutConfirm] = useState(false)
|
||||
const [shortName, setShortName] = useState("AA")
|
||||
|
||||
useEffect(() => {
|
||||
const first = localStorage.getItem("user_first_name") ?? "A"
|
||||
const last = localStorage.getItem("user_last_name") ?? "A"
|
||||
setShortName(first.charAt(0).toUpperCase() + last.charAt(0).toUpperCase())
|
||||
}, [])
|
||||
|
||||
const handleOptionClick = (option: string) => {
|
||||
switch (option) {
|
||||
case "profile":
|
||||
console.log("Go to profile")
|
||||
break
|
||||
case "settings":
|
||||
console.log("Go to settings")
|
||||
break
|
||||
case "logout":
|
||||
localStorage.clear()
|
||||
window.location.href = "/login"
|
||||
// setShowLogoutConfirm(true) // Show confirmation popup instead of immediate logout
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// const confirmLogout = () => {
|
||||
// localStorage.clear()
|
||||
// window.location.href = "/login"
|
||||
// }
|
||||
|
||||
// const cancelLogout = () => setShowLogoutConfirm(false)
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-10 flex h-16 items-center justify-end gap-3 border-b bg-grayScale-50/85 px-6 backdrop-blur">
|
||||
{/* Notifications */}
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-10 w-10 place-items-center rounded-full border bg-white text-grayScale-500 hover:text-brand-600 transition-colors"
|
||||
|
|
@ -12,12 +50,79 @@ export function Topbar() {
|
|||
<Bell className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<Avatar className="h-10 w-10 ring-2 ring-brand-100">
|
||||
<AvatarImage src="" alt="Admin" />
|
||||
<AvatarFallback className="bg-brand-500 text-sm font-medium text-white">JA</AvatarFallback>
|
||||
</Avatar>
|
||||
{/* Avatar + Radix Dropdown */}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button className="focus:outline-none">
|
||||
<Avatar className="h-10 w-10 ring-2 ring-brand-100">
|
||||
<AvatarImage src="" alt="Admin" />
|
||||
<AvatarFallback className="bg-brand-500 text-sm font-medium text-white">
|
||||
{shortName}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</button>
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content
|
||||
side="bottom"
|
||||
align="end"
|
||||
className="z-50 w-40 rounded-lg bg-white p-2 shadow-lg ring-1 ring-black ring-opacity-5"
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
className={cn(
|
||||
"cursor-pointer rounded px-3 py-2 text-grayScale-700 text-sm hover:bg-grayScale-100"
|
||||
)}
|
||||
onClick={() => handleOptionClick("profile")}
|
||||
>
|
||||
Profile
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
className={cn(
|
||||
"cursor-pointer rounded px-3 py-2 text-grayScale-700 text-sm hover:bg-grayScale-100"
|
||||
)}
|
||||
onClick={() => handleOptionClick("settings")}
|
||||
>
|
||||
Settings
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
className={cn(
|
||||
"cursor-pointer rounded px-3 py-2 text-grayScale-700 text-sm hover:bg-grayScale-100"
|
||||
)}
|
||||
onClick={() => handleOptionClick("logout")}
|
||||
>
|
||||
Logout
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
{/* Logout Confirmation Modal */}
|
||||
{/* {showLogoutConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 transition-animation-fade">
|
||||
<div className="w-80 rounded-lg bg-white p-6 shadow-lg">
|
||||
<h3 className="mb-4 text-lg font-semibold text-grayScale-700">
|
||||
Confirm Logout
|
||||
</h3>
|
||||
<p className="mb-6 text-sm text-grayScale-500">
|
||||
Are you sure you want to log out?
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={cancelLogout}
|
||||
className="rounded bg-grayScale-200 px-4 py-2 text-sm font-medium text-grayScale-700 hover:bg-grayScale-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmLogout}
|
||||
className="rounded bg-brand-500 px-4 py-2 text-sm font-medium text-white hover:bg-brand-600"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)} */}
|
||||
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
// import type { UserProfileResponse } from "../types/user.types";
|
||||
import {
|
||||
Activity,
|
||||
BadgeCheck,
|
||||
|
|
@ -24,6 +25,9 @@ import { StatCard } from "../components/dashboard/StatCard"
|
|||
import { Button } from "../components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"
|
||||
import { cn } from "../lib/utils"
|
||||
import { getUserById } from "../api/users.api"
|
||||
import type { UserProfileResponse } from "../types/user.types"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
const userGrowth = [
|
||||
{ month: "Jan", users: 2400 },
|
||||
|
|
@ -59,12 +63,33 @@ const revenueTrend = [
|
|||
const ranges = ["1D", "1W", "1M", "3M", "6M", "1Y"] as const
|
||||
|
||||
export function DashboardPage() {
|
||||
const [userFirstName, setUserFirstName] = useState<string>("")
|
||||
const activeRange = "1Y"
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const userId = Number(localStorage.getItem("user_id"))
|
||||
const res = await getUserById(userId)
|
||||
const userProfile: UserProfileResponse = res.data
|
||||
|
||||
setUserFirstName(userProfile.data.first_name)
|
||||
localStorage.setItem("user_first_name", userProfile.data.first_name)
|
||||
localStorage.setItem("user_last_name", userProfile.data.last_name)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
fetchUser()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl">
|
||||
<div className="mb-2 text-sm font-semibold text-grayScale-500">Dashboard</div>
|
||||
<div className="mb-5 text-2xl font-semibold tracking-tight">Welcome, Josh</div>
|
||||
<div className="mb-5 text-2xl font-semibold tracking-tight">
|
||||
Welcome, {userFirstName || localStorage.getItem("user_first_name")}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
|
||||
<StatCard
|
||||
|
|
|
|||
|
|
@ -1,20 +1,50 @@
|
|||
import { useState } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { Eye, EyeOff } from "lucide-react"
|
||||
import { BrandLogo } from "../../components/brand/BrandLogo"
|
||||
import { Button } from "../../components/ui/button"
|
||||
import { Input } from "../../components/ui/input"
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
|
||||
import { BrandLogo } from "../../components/brand/BrandLogo";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Input } from "../../components/ui/input";
|
||||
|
||||
import { login } from "../../api/auth.api";
|
||||
import type { LoginRequest } from "../../types/auth.types";
|
||||
import type { LoginResult } from "../../api/auth.api";
|
||||
|
||||
export function LoginPage() {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
// Handle login logic here
|
||||
console.log("Login:", { email, password })
|
||||
}
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const payload: LoginRequest = {
|
||||
email,
|
||||
password,
|
||||
};
|
||||
|
||||
try {
|
||||
const res: LoginResult = await login(payload);
|
||||
|
||||
// Store tokens
|
||||
localStorage.setItem("access_token", res.accessToken);
|
||||
localStorage.setItem("refresh_token", res.refreshToken);
|
||||
localStorage.setItem("role", res.role);
|
||||
localStorage.setItem("user_id", res.user_id.toString());
|
||||
|
||||
navigate("/dashboard");
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || "Invalid email or password");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-grayScale-100 px-4 py-12">
|
||||
|
|
@ -25,34 +55,54 @@ export function LoginPage() {
|
|||
</div>
|
||||
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="mb-2 text-2xl font-semibold text-grayScale-600">Admin Login</h1>
|
||||
<p className="text-sm text-grayScale-400">Please enter your details to continue</p>
|
||||
<h1 className="mb-2 text-2xl font-semibold text-grayScale-600">
|
||||
Admin Login
|
||||
</h1>
|
||||
<p className="text-sm text-grayScale-400">
|
||||
Please enter your details to continue
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 px-4 py-2 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6" autoComplete="on">
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label htmlFor="email" className="mb-2 block text-sm font-medium text-grayScale-600">
|
||||
Email Address
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="mb-2 block text-sm font-medium text-grayScale-600"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="admin@yimaruacademy.com"
|
||||
autoComplete="username"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label htmlFor="password" className="mb-2 block text-sm font-medium text-grayScale-600">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="mb-2 block text-sm font-medium text-grayScale-600"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Enter your password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
|
|
@ -61,7 +111,8 @@ export function LoginPage() {
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400 hover:text-grayScale-600"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
|
|
@ -69,21 +120,17 @@ export function LoginPage() {
|
|||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="text-sm font-medium text-brand-500 hover:text-brand-600"
|
||||
>
|
||||
<Link to="/forgot-password" className="text-sm text-brand-500">
|
||||
Forgot Password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
Login
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Signing in..." : "Login"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,37 +1,56 @@
|
|||
import { ArrowLeft } from "lucide-react"
|
||||
import { Link, useParams } from "react-router-dom"
|
||||
import { Badge } from "../../components/ui/badge"
|
||||
import { Button } from "../../components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||
import { Separator } from "../../components/ui/separator"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { useUsersStore } from "../../stores/usersStore"
|
||||
import { useEffect } from "react";
|
||||
import { ArrowLeft, UserCircle2 } from "lucide-react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Card, CardContent } from "../../components/ui/card";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { useUsersStore } from "../../zustand/userStore";
|
||||
import { getUserById } from "../../api/users.api";
|
||||
|
||||
export function UserDetailPage() {
|
||||
const { id } = useParams()
|
||||
const user = useUsersStore((s) => (id ? s.getUserById(id) : undefined))
|
||||
const { id } = useParams();
|
||||
const userProfile = useUsersStore((s) => s.userProfile);
|
||||
const setUserProfile = useUsersStore((s) => s.setUserProfile);
|
||||
|
||||
if (!user) {
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const res = await getUserById(Number(id));
|
||||
setUserProfile(res.data.data);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch user profile", err);
|
||||
setUserProfile(null);
|
||||
}
|
||||
};
|
||||
fetchUser();
|
||||
}, [id, setUserProfile]);
|
||||
|
||||
if (!userProfile) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
<div className="mb-4 text-sm font-semibold text-grayScale-500">User Detail</div>
|
||||
<Card className="shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle>User not found</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild>
|
||||
<Link to="/users">Back to Users</Link>
|
||||
<div className="mx-auto w-full max-w-3xl space-y-4">
|
||||
<div className="text-sm font-semibold text-grayScale-500">User Detail</div>
|
||||
<Card className="overflow-hidden shadow-sm">
|
||||
<div className="h-2 bg-gradient-to-r from-brand-500 to-brand-600" />
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<div className="text-lg font-semibold text-grayScale-900">User not found</div>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link to="/users/list">Back to Users</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const user = userProfile;
|
||||
const fullName = `${user.first_name} ${user.last_name}`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="space-y-6">
|
||||
{/* Back Link */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/users"
|
||||
className="inline-flex items-center gap-2 text-sm font-semibold text-grayScale-500 hover:text-brand-600"
|
||||
|
|
@ -41,166 +60,157 @@ export function UserDetailPage() {
|
|||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 text-sm font-semibold text-grayScale-500">User Detail</div>
|
||||
<div className="text-xl font-semibold text-grayScale-900">User Detail</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
{/* Left Column */}
|
||||
<div className="space-y-4 lg:col-span-1">
|
||||
<Card className="shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle>Basic Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Basic Information */}
|
||||
<Card className="overflow-hidden shadow-sm">
|
||||
<div className="h-2 bg-gradient-to-r from-brand-500 to-brand-600" />
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-12 w-12 rounded-full bg-grayScale-200" />
|
||||
<div className="h-12 w-12 rounded-full bg-grayScale-200 flex items-center justify-center overflow-hidden">
|
||||
{user.profile_picture_url ? (
|
||||
<img
|
||||
src={user.profile_picture_url}
|
||||
alt={fullName}
|
||||
className="h-12 w-12 object-cover"
|
||||
/>
|
||||
) : (
|
||||
<UserCircle2 className="h-12 w-12 text-grayScale-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-grayScale-600">{user.fullName}</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-xs text-grayScale-500">
|
||||
<div className="truncate text-lg font-semibold text-grayScale-900">{fullName}</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-xs text-grayScale-400">
|
||||
<span>ID: {user.id}</span>
|
||||
<span className="h-1 w-1 rounded-full bg-grayScale-300" />
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className={cn("h-2 w-2 rounded-full", user.isActive ? "bg-mint-500" : "bg-grayScale-300")} />
|
||||
{user.isActive ? "Active" : "Inactive"}
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
user.status === "ACTIVE" ? "bg-mint-500" : "bg-destructive"
|
||||
)}
|
||||
/>
|
||||
{user.status === "ACTIVE" ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="space-y-3 text-sm text-grayScale-600">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-grayScale-400">Phone</div>
|
||||
<div className="font-medium text-grayScale-600">{user.phone}</div>
|
||||
<div className="font-semibold">{user.phone_number || "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-grayScale-400">Email</div>
|
||||
<div className="font-medium text-grayScale-600">{user.email}</div>
|
||||
<div className="font-semibold">{user.email || "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-grayScale-400">Region</div>
|
||||
<div className="font-medium text-grayScale-600">{user.region}</div>
|
||||
<div className="font-semibold">{user.region || "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-grayScale-400">Joined Date</div>
|
||||
<div className="font-medium text-grayScale-600">{user.joinedDate}</div>
|
||||
<div className="font-semibold">
|
||||
{user.created_at ? new Date(user.created_at).toLocaleDateString() : "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Subscription</CardTitle>
|
||||
<Badge className={cn(user.isActive ? "bg-mint-500" : "bg-destructive")}>
|
||||
{user.isActive ? "Active" : "Inactive"}
|
||||
{/* Subscription */}
|
||||
<Card className="overflow-hidden shadow-sm">
|
||||
<div className="h-2 bg-gradient-to-r from-brand-500 to-brand-600" />
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-lg font-semibold text-grayScale-900">Subscription</div>
|
||||
<Badge
|
||||
className={cn(
|
||||
user.status === "ACTIVE" ? "bg-mint-500 text-white" : "bg-destructive text-white"
|
||||
)}
|
||||
>
|
||||
{user.status === "ACTIVE" ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-semibold text-grayScale-400">Current Plan</div>
|
||||
<div className="text-sm font-semibold text-grayScale-600">{user.currentPlan}</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-semibold text-grayScale-400">Expires On</div>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-grayScale-600">
|
||||
{user.expiresOn}
|
||||
<span className="rounded-full bg-gold-100 px-2 py-0.5 text-xs font-semibold text-gold-600">
|
||||
{user.daysLeftLabel}
|
||||
</span>
|
||||
<div className="space-y-1 text-sm text-grayScale-600">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-grayScale-400">Profile Completed</div>
|
||||
<div className="font-semibold">{user.profile_completed ? "Yes" : "No"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-grayScale-400">Preferred Language</div>
|
||||
<div className="font-semibold">{user.preferred_language || "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button className="w-full">Extend Subscription</Button>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button variant="outline" className="w-full">
|
||||
Mark as Paid
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Column */}
|
||||
<div className="space-y-4 lg:col-span-2">
|
||||
<Card className="shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle>Learning Profile</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Learning Profile */}
|
||||
<Card className="overflow-hidden shadow-sm">
|
||||
<div className="h-2 bg-gradient-to-r from-brand-500 to-brand-600" />
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-grayScale-400">Education Level</div>
|
||||
<div className="mt-1 text-sm font-semibold text-grayScale-600">{user.learningProfile.educationLevel}</div>
|
||||
<div className="text-sm font-semibold text-grayScale-600">{user.education_level || "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-grayScale-400">Age Group</div>
|
||||
<div className="mt-1 text-sm font-semibold text-grayScale-600">{user.learningProfile.ageGroup}</div>
|
||||
<div className="text-xs font-semibold text-grayScale-400">Age</div>
|
||||
<div className="text-sm font-semibold text-grayScale-600">{user.age || "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="text-xs font-semibold text-grayScale-400">Current Proficiency</div>
|
||||
<Badge variant="secondary" className="border-brand-200 bg-brand-100/50 text-brand-600">
|
||||
{user.learningProfile.currentProficiency}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-grayScale-400">Preferred Topic</div>
|
||||
<div className="mt-1 text-sm font-semibold text-grayScale-600">{user.learningProfile.preferredTopic}</div>
|
||||
<div className="text-xs font-semibold text-grayScale-400">Nick Name</div>
|
||||
<div className="text-sm font-semibold text-grayScale-600">{user.nick_name || "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-grayScale-400">Primary Goal</div>
|
||||
<div className="mt-2 rounded-lg border bg-white px-3 py-2 text-sm text-grayScale-600">
|
||||
{user.learningProfile.primaryGoal}
|
||||
</div>
|
||||
<div className="text-xs font-semibold text-grayScale-400">Occupation</div>
|
||||
<div className="text-sm font-semibold text-grayScale-600">{user.occupation || "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-grayScale-400">Challenges</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{user.learningProfile.challenges.map((c) => (
|
||||
<Badge key={c} variant="secondary">
|
||||
{c}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs font-semibold text-grayScale-400">Learning Goal</div>
|
||||
<div className="text-sm font-semibold text-grayScale-600">{user.learning_goal || "-"}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-grayScale-400">Language Challenge</div>
|
||||
<div className="text-sm font-semibold text-grayScale-600">{user.language_challange || "-"}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-grayScale-400">Favourite Topic</div>
|
||||
<div className="text-sm font-semibold text-grayScale-600">{user.favoutite_topic || "-"}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{user.recentActivity.map((a) => (
|
||||
<div key={a.id} className="flex items-start gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
"mt-1 h-2.5 w-2.5 rounded-full",
|
||||
a.dotColor === "brand" ? "bg-brand-500" : "bg-grayScale-300",
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-grayScale-600">{a.text}</div>
|
||||
<div className="text-xs text-grayScale-400">{a.time}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Status / Dates */}
|
||||
<Card className="overflow-hidden shadow-sm">
|
||||
<div className="h-2 bg-gradient-to-r from-brand-500 to-brand-600" />
|
||||
<CardContent className="p-6 space-y-3 text-sm text-grayScale-600">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-grayScale-400">Last Login</div>
|
||||
<div className="font-semibold">{user.last_login ? new Date(user.last_login).toLocaleString() : "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-grayScale-400">Updated At</div>
|
||||
<div className="font-semibold">{user.updated_at ? new Date(user.updated_at).toLocaleString() : "-"}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
import { Eye, Search } from "lucide-react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useEffect } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { Badge } from "../../components/ui/badge"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||
import { Input } from "../../components/ui/input"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table"
|
||||
import { cn } from "../../lib/utils"
|
||||
import type { SubscriptionType, User } from "../../stores/usersStore"
|
||||
import { useUsersStore } from "../../stores/usersStore"
|
||||
import { getUsers } from "../../api/users.api"
|
||||
import { mapUserApiToUser } from "../../types/user.types"
|
||||
import { useUsersStore } from "../../zustand/userStore"
|
||||
|
||||
type SubscriptionType = "Monthly" | "Free" | "Expired" | "3-Month" | "6-Month" | "N/A"
|
||||
|
||||
function subscriptionVariant(sub: SubscriptionType) {
|
||||
switch (sub) {
|
||||
|
|
@ -27,74 +30,59 @@ function subscriptionVariant(sub: SubscriptionType) {
|
|||
}
|
||||
|
||||
export function UsersListPage() {
|
||||
const [page, setPage] = useState(1)
|
||||
const search = useUsersStore((s) => s.search)
|
||||
const region = useUsersStore((s) => s.region)
|
||||
const subscription = useUsersStore((s) => s.subscription)
|
||||
const allUsers = useUsersStore((s) => s.users)
|
||||
const setSearch = useUsersStore((s) => s.setSearch)
|
||||
const setRegion = useUsersStore((s) => s.setRegion)
|
||||
const setSubscription = useUsersStore((s) => s.setSubscription)
|
||||
const getFilteredUsers = useUsersStore((s) => s.getFilteredUsers)
|
||||
const {
|
||||
users,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
search,
|
||||
setUsers,
|
||||
setTotal,
|
||||
setPage,
|
||||
setPageSize,
|
||||
setSearch,
|
||||
} = useUsersStore()
|
||||
|
||||
const users = getFilteredUsers()
|
||||
useEffect(() => {
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const res = await getUsers(page, pageSize)
|
||||
const apiUsers = res.data.data.users
|
||||
|
||||
const pageSize = 10
|
||||
const pageCount = Math.max(1, Math.ceil(users.length / pageSize))
|
||||
setUsers(apiUsers.map(mapUserApiToUser))
|
||||
setTotal(res.data.data.total)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch users:", error)
|
||||
setUsers([])
|
||||
setTotal(0)
|
||||
}
|
||||
}
|
||||
|
||||
fetchUsers()
|
||||
}, [page, pageSize, setUsers, setTotal])
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(total / pageSize))
|
||||
const safePage = Math.min(page, pageCount)
|
||||
const start = (safePage - 1) * pageSize
|
||||
const end = safePage * pageSize
|
||||
const paged = users.slice(start, end)
|
||||
const pageNumbers = Array.from({ length: pageCount }, (_, i) => i + 1)
|
||||
|
||||
const regions = useMemo(() => Array.from(new Set(allUsers.map((u) => u.region))).sort(), [allUsers])
|
||||
const handlePrev = () => safePage > 1 && setPage(safePage - 1)
|
||||
const handleNext = () => safePage < pageCount && setPage(safePage + 1)
|
||||
|
||||
return (
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>User Management</CardTitle>
|
||||
|
||||
<div className="mt-3 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="relative w-full md:max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
|
||||
<Input
|
||||
placeholder="Search by name, phone number"
|
||||
placeholder="Search by name or phone number"
|
||||
className="pl-9"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={region}
|
||||
onChange={(e) => setRegion(e.target.value)}
|
||||
className={cn(
|
||||
"h-10 rounded-lg border bg-white px-3 text-sm font-medium text-grayScale-600",
|
||||
"focus:outline-none focus:ring-2 focus:ring-ring",
|
||||
)}
|
||||
>
|
||||
<option value="All">Region</option>
|
||||
{regions.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={subscription}
|
||||
onChange={(e) => setSubscription(e.target.value as SubscriptionType | "All")}
|
||||
className={cn(
|
||||
"h-10 rounded-lg border bg-white px-3 text-sm font-medium text-grayScale-600",
|
||||
"focus:outline-none focus:ring-2 focus:ring-ring",
|
||||
)}
|
||||
>
|
||||
<option value="All">Subscription</option>
|
||||
<option value="Monthly">Monthly</option>
|
||||
<option value="Free">Free</option>
|
||||
<option value="Expired">Expired</option>
|
||||
<option value="3-Month">3-Month</option>
|
||||
<option value="6-Month">6-Month</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
|
|
@ -102,62 +90,114 @@ export function UsersListPage() {
|
|||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[40%]">Full Name</TableHead>
|
||||
<TableHead className="w-8">#</TableHead>
|
||||
<TableHead>First Name</TableHead>
|
||||
<TableHead>Last Name</TableHead>
|
||||
<TableHead>Nick Name</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Phone Number</TableHead>
|
||||
<TableHead>Region</TableHead>
|
||||
<TableHead>Country</TableHead>
|
||||
<TableHead>Last Active</TableHead>
|
||||
<TableHead>Subscription</TableHead>
|
||||
<TableHead className="w-[56px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{paged.map((u: User) => (
|
||||
<TableRow key={u.id}>
|
||||
<TableCell className="font-medium text-grayScale-600">{u.fullName}</TableCell>
|
||||
<TableCell className="text-grayScale-500">{u.phone}</TableCell>
|
||||
<TableCell className="text-grayScale-500">{u.region}</TableCell>
|
||||
<TableCell className="text-grayScale-500">{u.lastActive}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={subscriptionVariant(u.subscription)}>{u.subscription}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Link
|
||||
to={`/users/${u.id}`}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border bg-white text-grayScale-500 hover:text-brand-600"
|
||||
aria-label="View user"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
{users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="text-center text-grayScale-400">
|
||||
No users found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
) : (
|
||||
users.map((u, index) => (
|
||||
<TableRow key={u.id}>
|
||||
<TableCell className="text-grayScale-500">{(page - 1) * pageSize + index + 1}</TableCell>
|
||||
<TableCell className="text-grayScale-600">{u.firstName}</TableCell>
|
||||
<TableCell className="text-grayScale-600">{u.lastName}</TableCell>
|
||||
<TableCell className="text-grayScale-600">{u.nickName}</TableCell>
|
||||
<TableCell className="text-grayScale-500">{u.email}</TableCell>
|
||||
<TableCell className="text-grayScale-500">{u.phoneNumber || "-"}</TableCell>
|
||||
<TableCell className="text-grayScale-500">{u.region}</TableCell>
|
||||
<TableCell className="text-grayScale-500">{u.country}</TableCell>
|
||||
<TableCell className="text-grayScale-500">
|
||||
{u.lastLogin ? new Date(u.lastLogin).toLocaleString() : "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={subscriptionVariant("N/A")}>N/A</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Link
|
||||
to={`/users/${u.id}`}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border bg-white text-grayScale-500 hover:text-brand-600"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t px-4 py-3 text-xs text-grayScale-500">
|
||||
<div className="flex items-center gap-2">
|
||||
Row Per Page
|
||||
<span className="rounded-md border bg-white px-2 py-1 font-semibold text-grayScale-600">{pageSize}</span>
|
||||
Entries
|
||||
Rows per page
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => {
|
||||
setPageSize(Number(e.target.value))
|
||||
setPage(1)
|
||||
}}
|
||||
className="h-8 rounded-md border bg-white px-2 text-xs font-semibold text-grayScale-600 focus:outline-none"
|
||||
>
|
||||
{[1, 2, 3, 4, 5, 10, 20, 30].map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
of {total}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{[1, 2, 3, 4].map((n) => (
|
||||
<button
|
||||
onClick={handlePrev}
|
||||
disabled={safePage === 1}
|
||||
className={cn(
|
||||
"h-8 w-12 rounded-md border bg-white text-xs font-semibold text-grayScale-500",
|
||||
safePage === 1 && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
|
||||
{pageNumbers.map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onClick={() => setPage(n)}
|
||||
className={cn(
|
||||
"h-8 w-8 rounded-md border bg-white text-xs font-semibold text-grayScale-500",
|
||||
n === safePage && "border-brand-200 bg-brand-100/40 text-brand-600",
|
||||
n === safePage && "border-brand-200 bg-brand-100/40 text-brand-600"
|
||||
)}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
<span className="px-2">…</span>
|
||||
<button type="button" className="h-8 w-10 rounded-md border bg-white font-semibold">
|
||||
15
|
||||
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={safePage === pageCount}
|
||||
className={cn(
|
||||
"h-8 w-12 rounded-md border bg-white text-xs font-semibold text-grayScale-500",
|
||||
safePage === pageCount && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -165,5 +205,3 @@ export function UsersListPage() {
|
|||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,142 +0,0 @@
|
|||
import { create } from "zustand"
|
||||
|
||||
export type SubscriptionType = "Monthly" | "Free" | "Expired" | "3-Month" | "6-Month"
|
||||
|
||||
export type UserActivity = {
|
||||
id: string
|
||||
text: string
|
||||
time: string
|
||||
dotColor: "brand" | "muted"
|
||||
}
|
||||
|
||||
export type User = {
|
||||
id: string
|
||||
fullName: string
|
||||
phone: string
|
||||
region: string
|
||||
lastActive: string
|
||||
subscription: SubscriptionType
|
||||
|
||||
email: string
|
||||
joinedDate: string
|
||||
|
||||
learningProfile: {
|
||||
educationLevel: string
|
||||
ageGroup: string
|
||||
currentProficiency: string
|
||||
preferredTopic: string
|
||||
primaryGoal: string
|
||||
challenges: string[]
|
||||
}
|
||||
|
||||
currentPlan: string
|
||||
expiresOn: string
|
||||
daysLeftLabel: string
|
||||
isActive: boolean
|
||||
|
||||
recentActivity: UserActivity[]
|
||||
}
|
||||
|
||||
const MOCK_USERS: User[] = [
|
||||
{
|
||||
id: "u_1001",
|
||||
fullName: "Richard Wilson",
|
||||
phone: "(555) 123-4567",
|
||||
region: "Addis Ababa",
|
||||
lastActive: "24 Dec 2024",
|
||||
subscription: "6-Month",
|
||||
email: "contact@capitalflow.com",
|
||||
joinedDate: "Oct 12, 2024",
|
||||
learningProfile: {
|
||||
educationLevel: "Undergraduate Student",
|
||||
ageGroup: "18-24 Years",
|
||||
currentProficiency: "Intermediate (B2)",
|
||||
preferredTopic: "Business, Travel, Culture",
|
||||
primaryGoal: "To achieve fluency for business communication in an international company",
|
||||
challenges: ["Speaking Confidence", "Vocabulary Retention"],
|
||||
},
|
||||
currentPlan: "6-Month",
|
||||
expiresOn: "Nov 13, 2025",
|
||||
daysLeftLabel: "65 days left",
|
||||
isActive: true,
|
||||
recentActivity: [
|
||||
{ id: "a1", text: "Completed Unit 4: Business Emails", time: "Today, 10:27 AM", dotColor: "brand" },
|
||||
{ id: "a2", text: "Started new course: Advanced English", time: "Today, 7:20 AM", dotColor: "muted" },
|
||||
{ id: "a3", text: "Started new course: Advanced English", time: "Today, 7:20 AM", dotColor: "muted" },
|
||||
],
|
||||
},
|
||||
...Array.from({ length: 14 }).map((_, idx) => {
|
||||
const i = idx + 1
|
||||
const sub: SubscriptionType[] = ["Monthly", "Free", "Expired", "3-Month", "6-Month"]
|
||||
const subscription = sub[i % sub.length]!
|
||||
return {
|
||||
id: `u_10${10 + i}`,
|
||||
fullName: "Abebe Kebede",
|
||||
phone: "+251912345678",
|
||||
region: "Addis Ababa",
|
||||
lastActive: "24 Dec 2024",
|
||||
subscription,
|
||||
email: "abebe@example.com",
|
||||
joinedDate: "Sep 02, 2024",
|
||||
learningProfile: {
|
||||
educationLevel: "High School",
|
||||
ageGroup: "18-24 Years",
|
||||
currentProficiency: "Beginner (A2)",
|
||||
preferredTopic: "General English",
|
||||
primaryGoal: "Improve daily conversation skills",
|
||||
challenges: ["Listening", "Pronunciation"],
|
||||
},
|
||||
currentPlan: subscription,
|
||||
expiresOn: "Nov 13, 2025",
|
||||
daysLeftLabel: "65 days left",
|
||||
isActive: subscription !== "Expired",
|
||||
recentActivity: [
|
||||
{ id: "a1", text: "Completed Lesson 2", time: "Today, 9:00 AM", dotColor: "brand" },
|
||||
{ id: "a2", text: "Started new course: English Basics", time: "Yesterday, 7:20 AM", dotColor: "muted" },
|
||||
],
|
||||
} satisfies User
|
||||
}),
|
||||
]
|
||||
|
||||
type UsersState = {
|
||||
users: User[]
|
||||
search: string
|
||||
region: string
|
||||
subscription: SubscriptionType | "All"
|
||||
|
||||
setSearch: (value: string) => void
|
||||
setRegion: (value: string) => void
|
||||
setSubscription: (value: UsersState["subscription"]) => void
|
||||
|
||||
getUserById: (id: string) => User | undefined
|
||||
getFilteredUsers: () => User[]
|
||||
}
|
||||
|
||||
export const useUsersStore = create<UsersState>((set, get) => ({
|
||||
users: MOCK_USERS,
|
||||
search: "",
|
||||
region: "All",
|
||||
subscription: "All",
|
||||
|
||||
setSearch: (value) => set({ search: value }),
|
||||
setRegion: (value) => set({ region: value }),
|
||||
setSubscription: (value) => set({ subscription: value }),
|
||||
|
||||
getUserById: (id) => get().users.find((u) => u.id === id),
|
||||
getFilteredUsers: () => {
|
||||
const { users, search, region, subscription } = get()
|
||||
const q = search.trim().toLowerCase()
|
||||
return users.filter((u) => {
|
||||
const matchesQuery =
|
||||
q.length === 0 ||
|
||||
u.fullName.toLowerCase().includes(q) ||
|
||||
u.phone.toLowerCase().includes(q) ||
|
||||
u.region.toLowerCase().includes(q)
|
||||
const matchesRegion = region === "All" || u.region === region
|
||||
const matchesSub = subscription === "All" || u.subscription === subscription
|
||||
return matchesQuery && matchesRegion && matchesSub
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
20
src/types/auth.types.ts
Normal file
20
src/types/auth.types.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginResponseData {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
role: string;
|
||||
user_id: number;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
message: string;
|
||||
data: LoginResponseData;
|
||||
success: boolean;
|
||||
status_code: number;
|
||||
metadata: any | null;
|
||||
}
|
||||
|
||||
7
src/types/common.types.ts
Normal file
7
src/types/common.types.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// export interface SuccessResponse<T> {
|
||||
// message: string;
|
||||
// Success: boolean;
|
||||
// status_code: number;
|
||||
// metaData: any | null;
|
||||
// data: T;
|
||||
// }
|
||||
114
src/types/user.types.ts
Normal file
114
src/types/user.types.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
// This matches the API response 1:1
|
||||
export interface UserApiDTO {
|
||||
ID: number
|
||||
FirstName: string
|
||||
LastName: string
|
||||
Gender: string
|
||||
birth_day: string | null
|
||||
|
||||
Email: string
|
||||
PhoneNumber: string
|
||||
Role: string
|
||||
|
||||
Age: number
|
||||
EducationLevel: string
|
||||
Country: string
|
||||
Region: string
|
||||
|
||||
KnowledgeLevel: string
|
||||
InitialAssessmentCompleted: boolean
|
||||
NickName: string
|
||||
Occupation: string
|
||||
LearningGoal: string
|
||||
LanguageGoal: string
|
||||
LanguageChallange: string
|
||||
FavouriteTopic: string
|
||||
|
||||
EmailVerified: boolean
|
||||
PhoneVerified: boolean
|
||||
Status: string
|
||||
|
||||
LastLogin: string | null
|
||||
ProfileCompleted: boolean
|
||||
ProfilePictureURL: string
|
||||
PreferredLanguage: string
|
||||
|
||||
CreatedAt: string
|
||||
UpdatedAt: string | null
|
||||
}
|
||||
|
||||
export interface GetUsersResponse {
|
||||
status: string
|
||||
message: string
|
||||
data: {
|
||||
total: number
|
||||
users: UserApiDTO[]
|
||||
}
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number
|
||||
firstName: string
|
||||
lastName: string
|
||||
nickName: string
|
||||
email: string
|
||||
phoneNumber: string
|
||||
region: string
|
||||
country: string
|
||||
lastLogin: string | null
|
||||
}
|
||||
|
||||
export const mapUserApiToUser = (u: UserApiDTO): User => ({
|
||||
id: u.ID,
|
||||
firstName: u.FirstName,
|
||||
lastName: u.LastName,
|
||||
nickName: u.NickName,
|
||||
email: u.Email,
|
||||
phoneNumber: u.PhoneNumber,
|
||||
region: u.Region,
|
||||
country: u.Country,
|
||||
lastLogin: u.LastLogin,
|
||||
})
|
||||
|
||||
export interface UserProfileData {
|
||||
id: number
|
||||
first_name: string
|
||||
last_name: string
|
||||
gender: string
|
||||
birth_day: string | null
|
||||
|
||||
email: string
|
||||
phone_number: string
|
||||
role: string
|
||||
age: number
|
||||
education_level: string
|
||||
country: string
|
||||
region: string
|
||||
|
||||
nick_name: string
|
||||
occupation: string
|
||||
learning_goal: string
|
||||
language_goal: string
|
||||
language_challange: string
|
||||
favoutite_topic: string
|
||||
|
||||
email_verified: boolean
|
||||
phone_verified: boolean
|
||||
status: string
|
||||
|
||||
last_login: string | null
|
||||
profile_completed: boolean
|
||||
preferred_language: string
|
||||
profile_picture_url: string
|
||||
|
||||
created_at: string
|
||||
updated_at?: string | null // optional
|
||||
}
|
||||
|
||||
export interface UserProfileResponse {
|
||||
status: string
|
||||
message: string
|
||||
data: UserProfileData
|
||||
timestamp: string
|
||||
}
|
||||
38
src/zustand/userStore.ts
Normal file
38
src/zustand/userStore.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { create } from "zustand"
|
||||
import { type User } from "../types/user.types"
|
||||
import {type UserProfileData } from "../types/user.types"
|
||||
|
||||
interface UsersState {
|
||||
users: User[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
search: string
|
||||
|
||||
// Detailed user for the detail page
|
||||
userProfile: UserProfileData | null
|
||||
|
||||
// Actions
|
||||
setUsers: (users: User[]) => void
|
||||
setTotal: (total: number) => void
|
||||
setPage: (page: number) => void
|
||||
setPageSize: (size: number) => void
|
||||
setSearch: (search: string) => void
|
||||
setUserProfile: (profile: UserProfileData | null) => void
|
||||
}
|
||||
|
||||
export const useUsersStore = create<UsersState>((set) => ({
|
||||
users: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 5,
|
||||
search: "",
|
||||
userProfile: null,
|
||||
|
||||
setUsers: (users) => set({ users }),
|
||||
setTotal: (total) => set({ total }),
|
||||
setPage: (page) => set({ page }),
|
||||
setPageSize: (pageSize) => set({ pageSize }),
|
||||
setSearch: (search) => set({ search }),
|
||||
setUserProfile: (profile) => set({ userProfile: profile }),
|
||||
}))
|
||||
Loading…
Reference in New Issue
Block a user