profile management integration

This commit is contained in:
Yared Yemane 2026-01-18 01:36:26 -08:00
parent 061c019b22
commit cda7d9d551
17 changed files with 998 additions and 387 deletions

1
.env Normal file
View File

@ -0,0 +1 @@
VITE_API_BASE_URL=http://195.35.29.82:8080/api/v1

280
package-lock.json generated
View File

@ -14,6 +14,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.561.0", "lucide-react": "^0.561.0",
@ -86,6 +87,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@ -2751,6 +2753,7 @@
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==", "integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
@ -2761,6 +2764,7 @@
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@ -2771,6 +2775,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
@ -2826,6 +2831,7 @@
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/scope-manager": "8.50.0",
"@typescript-eslint/types": "8.50.0", "@typescript-eslint/types": "8.50.0",
@ -3077,6 +3083,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -3187,6 +3194,12 @@
"node": ">=10" "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": { "node_modules/autoprefixer": {
"version": "10.4.23", "version": "10.4.23",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
@ -3224,6 +3237,17 @@
"postcss": "^8.1.0" "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": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@ -3298,6 +3322,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@ -3312,6 +3337,19 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "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": { "node_modules/callsites": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@ -3449,6 +3487,18 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/commander": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
@ -3673,6 +3723,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/detect-node-es": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
@ -3693,6 +3752,20 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/electron-to-chromium": {
"version": "1.5.267", "version": "1.5.267",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
@ -3700,6 +3773,51 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/es-toolkit": {
"version": "1.43.0", "version": "1.43.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz",
@ -3781,6 +3899,7 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@ -4108,6 +4227,42 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/fraction.js": {
"version": "5.3.4", "version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
@ -4141,7 +4296,6 @@
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
@ -4157,6 +4311,30 @@
"node": ">=6.9.0" "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": { "node_modules/get-nonce": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
@ -4166,6 +4344,19 @@
"node": ">=6" "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": { "node_modules/glob-parent": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@ -4192,6 +4383,18 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/has-flag": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@ -4202,11 +4405,37 @@
"node": ">=8" "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": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"function-bind": "^1.1.2" "function-bind": "^1.1.2"
@ -4520,6 +4749,15 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" "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": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@ -4557,6 +4795,27 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@ -4755,6 +5014,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -4802,6 +5062,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@ -4948,6 +5209,12 @@
"node": ">= 0.8.0" "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": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "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", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -4993,6 +5261,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@ -5012,6 +5281,7 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/use-sync-external-store": "^0.0.6", "@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0" "use-sync-external-store": "^1.4.0"
@ -5217,7 +5487,8 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/redux-thunk": { "node_modules/redux-thunk": {
"version": "3.1.0", "version": "3.1.0",
@ -5611,6 +5882,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -5778,6 +6050,7 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@ -5915,6 +6188,7 @@
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View File

@ -16,6 +16,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.561.0", "lucide-react": "^0.561.0",

22
src/api/auth.api.ts Normal file
View 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
View 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
View 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}`);

View File

@ -1,9 +1,47 @@
"use client" // make sure this is a client component
import { useEffect, useState } from "react"
import { Bell } from "lucide-react" import { Bell } from "lucide-react"
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"
import { cn } from "../../lib/utils"
export function Topbar() { 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 ( 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"> <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 <button
type="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" 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" /> <Bell className="h-5 w-5" />
</button> </button>
<Avatar className="h-10 w-10 ring-2 ring-brand-100"> {/* Avatar + Radix Dropdown */}
<AvatarImage src="" alt="Admin" /> <DropdownMenu.Root>
<AvatarFallback className="bg-brand-500 text-sm font-medium text-white">JA</AvatarFallback> <DropdownMenu.Trigger asChild>
</Avatar> <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> </header>
) )
} }

View File

@ -1,6 +1,7 @@
import { clsx, type ClassValue } from "clsx" import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
} }

View File

@ -1,3 +1,4 @@
// import type { UserProfileResponse } from "../types/user.types";
import { import {
Activity, Activity,
BadgeCheck, BadgeCheck,
@ -24,6 +25,9 @@ import { StatCard } from "../components/dashboard/StatCard"
import { Button } from "../components/ui/button" import { Button } from "../components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"
import { cn } from "../lib/utils" 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 = [ const userGrowth = [
{ month: "Jan", users: 2400 }, { month: "Jan", users: 2400 },
@ -59,12 +63,33 @@ const revenueTrend = [
const ranges = ["1D", "1W", "1M", "3M", "6M", "1Y"] as const const ranges = ["1D", "1W", "1M", "3M", "6M", "1Y"] as const
export function DashboardPage() { export function DashboardPage() {
const [userFirstName, setUserFirstName] = useState<string>("")
const activeRange = "1Y" 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 ( return (
<div className="mx-auto w-full max-w-6xl"> <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-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"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
<StatCard <StatCard

View File

@ -1,20 +1,50 @@
import { useState } from "react" import { useState } from "react";
import { Link } from "react-router-dom" import { Link, useNavigate } from "react-router-dom";
import { Eye, EyeOff } from "lucide-react" import { Eye, EyeOff } from "lucide-react";
import { BrandLogo } from "../../components/brand/BrandLogo"
import { Button } from "../../components/ui/button" import { BrandLogo } from "../../components/brand/BrandLogo";
import { Input } from "../../components/ui/input" 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() { export function LoginPage() {
const [showPassword, setShowPassword] = useState(false) const navigate = useNavigate();
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const handleSubmit = (e: React.FormEvent) => { const [showPassword, setShowPassword] = useState(false);
e.preventDefault() const [email, setEmail] = useState("");
// Handle login logic here const [password, setPassword] = useState("");
console.log("Login:", { email, password }) 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 ( return (
<div className="flex min-h-screen items-center justify-center bg-grayScale-100 px-4 py-12"> <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>
<div className="mb-8 text-center"> <div className="mb-8 text-center">
<h1 className="mb-2 text-2xl font-semibold text-grayScale-600">Admin Login</h1> <h1 className="mb-2 text-2xl font-semibold text-grayScale-600">
<p className="text-sm text-grayScale-400">Please enter your details to continue</p> Admin Login
</h1>
<p className="text-sm text-grayScale-400">
Please enter your details to continue
</p>
</div> </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> <div>
<label htmlFor="email" className="mb-2 block text-sm font-medium text-grayScale-600"> <label
Email Address htmlFor="email"
className="mb-2 block text-sm font-medium text-grayScale-600"
>
Email
</label> </label>
<Input <Input
id="email" id="email"
name="email"
type="email" type="email"
placeholder="admin@yimaruacademy.com" autoComplete="username"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required required
/> />
</div> </div>
{/* Password */}
<div> <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 Password
</label> </label>
<div className="relative"> <div className="relative">
<Input <Input
id="password" id="password"
name="password"
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
placeholder="Enter your password" autoComplete="current-password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
@ -61,7 +111,8 @@ export function LoginPage() {
<button <button
type="button" type="button"
onClick={() => setShowPassword(!showPassword)} 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} />} {showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button> </button>
@ -69,21 +120,17 @@ export function LoginPage() {
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<Link <Link to="/forgot-password" className="text-sm text-brand-500">
to="/forgot-password"
className="text-sm font-medium text-brand-500 hover:text-brand-600"
>
Forgot Password? Forgot Password?
</Link> </Link>
</div> </div>
<Button type="submit" className="w-full"> <Button type="submit" className="w-full" disabled={loading}>
Login {loading ? "Signing in..." : "Login"}
</Button> </Button>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
) );
} }

View File

@ -1,37 +1,56 @@
import { ArrowLeft } from "lucide-react" import { useEffect } from "react";
import { Link, useParams } from "react-router-dom" import { ArrowLeft, UserCircle2 } from "lucide-react";
import { Badge } from "../../components/ui/badge" import { Link, useParams } from "react-router-dom";
import { Button } from "../../components/ui/button" import { Badge } from "../../components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card" import { Button } from "../../components/ui/button";
import { Separator } from "../../components/ui/separator" import { Card, CardContent } from "../../components/ui/card";
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils";
import { useUsersStore } from "../../stores/usersStore" import { useUsersStore } from "../../zustand/userStore";
import { getUserById } from "../../api/users.api";
export function UserDetailPage() { export function UserDetailPage() {
const { id } = useParams() const { id } = useParams();
const user = useUsersStore((s) => (id ? s.getUserById(id) : undefined)) 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 ( return (
<div className="mx-auto w-full max-w-3xl"> <div className="mx-auto w-full max-w-3xl space-y-4">
<div className="mb-4 text-sm font-semibold text-grayScale-500">User Detail</div> <div className="text-sm font-semibold text-grayScale-500">User Detail</div>
<Card className="shadow-none"> <Card className="overflow-hidden shadow-sm">
<CardHeader> <div className="h-2 bg-gradient-to-r from-brand-500 to-brand-600" />
<CardTitle>User not found</CardTitle> <CardContent className="p-6 space-y-4">
</CardHeader> <div className="text-lg font-semibold text-grayScale-900">User not found</div>
<CardContent> <Button asChild variant="outline" className="w-full">
<Button asChild> <Link to="/users/list">Back to Users</Link>
<Link to="/users">Back to Users</Link>
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
) );
} }
const user = userProfile;
const fullName = `${user.first_name} ${user.last_name}`;
return ( return (
<div> <div className="space-y-6">
<div className="mb-4 flex items-center gap-3"> {/* Back Link */}
<div className="flex items-center gap-3">
<Link <Link
to="/users" to="/users"
className="inline-flex items-center gap-2 text-sm font-semibold text-grayScale-500 hover:text-brand-600" 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> </Link>
</div> </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"> <div className="grid gap-4 lg:grid-cols-3">
{/* Left Column */}
<div className="space-y-4 lg:col-span-1"> <div className="space-y-4 lg:col-span-1">
<Card className="shadow-none"> {/* Basic Information */}
<CardHeader> <Card className="overflow-hidden shadow-sm">
<CardTitle>Basic Information</CardTitle> <div className="h-2 bg-gradient-to-r from-brand-500 to-brand-600" />
</CardHeader> <CardContent className="p-6 space-y-4">
<CardContent className="space-y-4">
<div className="flex items-center gap-3"> <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="min-w-0">
<div className="truncate text-sm font-semibold text-grayScale-600">{user.fullName}</div> <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-500"> <div className="mt-1 flex items-center gap-2 text-xs text-grayScale-400">
<span>ID: {user.id}</span> <span>ID: {user.id}</span>
<span className="h-1 w-1 rounded-full bg-grayScale-300" /> <span className="h-1 w-1 rounded-full bg-grayScale-300" />
<span className="inline-flex items-center gap-1"> <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")} /> <span
{user.isActive ? "Active" : "Inactive"} className={cn(
"h-2 w-2 rounded-full",
user.status === "ACTIVE" ? "bg-mint-500" : "bg-destructive"
)}
/>
{user.status === "ACTIVE" ? "Active" : "Inactive"}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
<Separator /> <div className="space-y-3 text-sm text-grayScale-600">
<div className="space-y-3 text-sm">
<div> <div>
<div className="text-xs font-semibold text-grayScale-400">Phone</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> <div>
<div className="text-xs font-semibold text-grayScale-400">Email</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> <div>
<div className="text-xs font-semibold text-grayScale-400">Region</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> <div>
<div className="text-xs font-semibold text-grayScale-400">Joined Date</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>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card className="shadow-none"> {/* Subscription */}
<CardHeader className="pb-2"> <Card className="overflow-hidden shadow-sm">
<div className="flex items-center justify-between"> <div className="h-2 bg-gradient-to-r from-brand-500 to-brand-600" />
<CardTitle>Subscription</CardTitle> <CardContent className="p-6 space-y-4">
<Badge className={cn(user.isActive ? "bg-mint-500" : "bg-destructive")}> <div className="flex justify-between items-center">
{user.isActive ? "Active" : "Inactive"} <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> </Badge>
</div> </div>
</CardHeader> <div className="space-y-1 text-sm text-grayScale-600">
<CardContent className="space-y-4"> <div>
<div className="space-y-1"> <div className="text-xs font-semibold text-grayScale-400">Profile Completed</div>
<div className="text-xs font-semibold text-grayScale-400">Current Plan</div> <div className="font-semibold">{user.profile_completed ? "Yes" : "No"}</div>
<div className="text-sm font-semibold text-grayScale-600">{user.currentPlan}</div> </div>
</div> <div>
<div className="space-y-1"> <div className="text-xs font-semibold text-grayScale-400">Preferred Language</div>
<div className="text-xs font-semibold text-grayScale-400">Expires On</div> <div className="font-semibold">{user.preferred_language || "-"}</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> </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> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* Right Column */}
<div className="space-y-4 lg:col-span-2"> <div className="space-y-4 lg:col-span-2">
<Card className="shadow-none"> {/* Learning Profile */}
<CardHeader> <Card className="overflow-hidden shadow-sm">
<CardTitle>Learning Profile</CardTitle> <div className="h-2 bg-gradient-to-r from-brand-500 to-brand-600" />
</CardHeader> <CardContent className="p-6 space-y-4">
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div> <div>
<div className="text-xs font-semibold text-grayScale-400">Education Level</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> <div>
<div className="text-xs font-semibold text-grayScale-400">Age Group</div> <div className="text-xs font-semibold text-grayScale-400">Age</div>
<div className="mt-1 text-sm font-semibold text-grayScale-600">{user.learningProfile.ageGroup}</div> <div className="text-sm font-semibold text-grayScale-600">{user.age || "-"}</div>
</div> </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 className="grid gap-4 md:grid-cols-2">
<div> <div>
<div className="text-xs font-semibold text-grayScale-400">Preferred Topic</div> <div className="text-xs font-semibold text-grayScale-400">Nick Name</div>
<div className="mt-1 text-sm font-semibold text-grayScale-600">{user.learningProfile.preferredTopic}</div> <div className="text-sm font-semibold text-grayScale-600">{user.nick_name || "-"}</div>
</div> </div>
<div> <div>
<div className="text-xs font-semibold text-grayScale-400">Primary Goal</div> <div className="text-xs font-semibold text-grayScale-400">Occupation</div>
<div className="mt-2 rounded-lg border bg-white px-3 py-2 text-sm text-grayScale-600"> <div className="text-sm font-semibold text-grayScale-600">{user.occupation || "-"}</div>
{user.learningProfile.primaryGoal}
</div>
</div> </div>
</div> </div>
<div> <div>
<div className="text-xs font-semibold text-grayScale-400">Challenges</div> <div className="text-xs font-semibold text-grayScale-400">Learning Goal</div>
<div className="mt-2 flex flex-wrap gap-2"> <div className="text-sm font-semibold text-grayScale-600">{user.learning_goal || "-"}</div>
{user.learningProfile.challenges.map((c) => ( </div>
<Badge key={c} variant="secondary">
{c} <div>
</Badge> <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>
<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> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card className="shadow-none"> {/* Status / Dates */}
<CardHeader> <Card className="overflow-hidden shadow-sm">
<CardTitle>Recent Activity</CardTitle> <div className="h-2 bg-gradient-to-r from-brand-500 to-brand-600" />
</CardHeader> <CardContent className="p-6 space-y-3 text-sm text-grayScale-600">
<CardContent className="space-y-4"> <div>
{user.recentActivity.map((a) => ( <div className="text-xs font-semibold text-grayScale-400">Last Login</div>
<div key={a.id} className="flex items-start gap-3"> <div className="font-semibold">{user.last_login ? new Date(user.last_login).toLocaleString() : "-"}</div>
<span </div>
className={cn( <div>
"mt-1 h-2.5 w-2.5 rounded-full", <div className="text-xs font-semibold text-grayScale-400">Updated At</div>
a.dotColor === "brand" ? "bg-brand-500" : "bg-grayScale-300", <div className="font-semibold">{user.updated_at ? new Date(user.updated_at).toLocaleString() : "-"}</div>
)} </div>
/>
<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>
))}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div> </div>
</div> </div>
) );
} }

View File

@ -1,13 +1,16 @@
import { Eye, Search } from "lucide-react" import { Eye, Search } from "lucide-react"
import { useMemo, useState } from "react" import { useEffect } from "react"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import { Badge } from "../../components/ui/badge" import { Badge } from "../../components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import type { SubscriptionType, User } from "../../stores/usersStore" import { getUsers } from "../../api/users.api"
import { useUsersStore } from "../../stores/usersStore" 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) { function subscriptionVariant(sub: SubscriptionType) {
switch (sub) { switch (sub) {
@ -27,74 +30,59 @@ function subscriptionVariant(sub: SubscriptionType) {
} }
export function UsersListPage() { export function UsersListPage() {
const [page, setPage] = useState(1) const {
const search = useUsersStore((s) => s.search) users,
const region = useUsersStore((s) => s.region) total,
const subscription = useUsersStore((s) => s.subscription) page,
const allUsers = useUsersStore((s) => s.users) pageSize,
const setSearch = useUsersStore((s) => s.setSearch) search,
const setRegion = useUsersStore((s) => s.setRegion) setUsers,
const setSubscription = useUsersStore((s) => s.setSubscription) setTotal,
const getFilteredUsers = useUsersStore((s) => s.getFilteredUsers) 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 setUsers(apiUsers.map(mapUserApiToUser))
const pageCount = Math.max(1, Math.ceil(users.length / pageSize)) 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 safePage = Math.min(page, pageCount)
const start = (safePage - 1) * pageSize const pageNumbers = Array.from({ length: pageCount }, (_, i) => i + 1)
const end = safePage * pageSize
const paged = users.slice(start, end)
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 ( return (
<Card className="shadow-none"> <Card className="shadow-none">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle>User Management</CardTitle> <CardTitle>User Management</CardTitle>
<div className="mt-3 flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> <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"> <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" /> <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
<Input <Input
placeholder="Search by name, phone number" placeholder="Search by name or phone number"
className="pl-9" className="pl-9"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
</div> </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> </div>
</CardHeader> </CardHeader>
@ -102,62 +90,114 @@ export function UsersListPage() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <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>Phone Number</TableHead>
<TableHead>Region</TableHead> <TableHead>Region</TableHead>
<TableHead>Country</TableHead>
<TableHead>Last Active</TableHead> <TableHead>Last Active</TableHead>
<TableHead>Subscription</TableHead> <TableHead>Subscription</TableHead>
<TableHead className="w-[56px]" /> <TableHead className="w-[56px]" />
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{paged.map((u: User) => ( {users.length === 0 ? (
<TableRow key={u.id}> <TableRow>
<TableCell className="font-medium text-grayScale-600">{u.fullName}</TableCell> <TableCell colSpan={11} className="text-center text-grayScale-400">
<TableCell className="text-grayScale-500">{u.phone}</TableCell> No users found
<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>
</TableCell> </TableCell>
</TableRow> </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> </TableBody>
</Table> </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 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"> <div className="flex items-center gap-2">
Row Per Page Rows per page
<span className="rounded-md border bg-white px-2 py-1 font-semibold text-grayScale-600">{pageSize}</span> <select
Entries 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>
<div className="flex items-center gap-1"> <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 <button
key={n} key={n}
type="button" type="button"
onClick={() => setPage(n)} onClick={() => setPage(n)}
className={cn( className={cn(
"h-8 w-8 rounded-md border bg-white text-xs font-semibold text-grayScale-500", "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} {n}
</button> </button>
))} ))}
<span className="px-2"></span>
<button type="button" className="h-8 w-10 rounded-md border bg-white font-semibold"> <button
15 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> </button>
</div> </div>
</div> </div>
@ -165,5 +205,3 @@ export function UsersListPage() {
</Card> </Card>
) )
} }

View File

@ -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
View 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;
}

View 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
View 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
View 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 }),
}))