From 22e649bf67ade9151cd15313a01263147dbb958c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Afra=20=E2=80=8C?= Date: Sat, 8 Aug 2026 10:37:44 +0330 Subject: [PATCH] feat: user profile page implemented --- .env | 4 +- src/components/layout/AdminLayout.tsx | 3 + src/components/layout/PageBreadcrumb.tsx | 30 ++++++++ src/constants/auth.ts | 1 + src/pages/Profile/Profile.tsx | 98 +++++++++++++++++++++--- src/pages/Profile/ProfileTabs.tsx | 21 +++++ src/pages/Profile/StatCards.tsx | 76 ++++++++++++++++++ src/pages/Profile/UserInfoCard.tsx | 64 ++++++++++++++++ src/services/api.ts | 2 +- src/services/authApi.ts | 8 +- src/services/usersApi.ts | 20 +++++ src/types/user.ts | 22 ++++++ src/utils/authUrl.ts | 20 +++-- 13 files changed, 348 insertions(+), 21 deletions(-) create mode 100644 src/components/layout/PageBreadcrumb.tsx create mode 100644 src/pages/Profile/ProfileTabs.tsx create mode 100644 src/pages/Profile/StatCards.tsx create mode 100644 src/pages/Profile/UserInfoCard.tsx create mode 100644 src/services/usersApi.ts create mode 100644 src/types/user.ts diff --git a/.env b/.env index cf10d2e..f543f6d 100644 --- a/.env +++ b/.env @@ -1,4 +1,4 @@ -VITE_AUTH_BASE_URL=https://accounts-staging.gooyal.ir -VITE_API_BASE_URL=https://accounts-staging.gooyal.ir +VITE_AUTH_BASE_URL=https://admin-backend-staging.winsoo.ir/api +VITE_API_BASE_URL=https://admin-backend-staging.winsoo.ir/api VITE_AUTH_CLIENT_ID=PLWyn000M78oYPP2R7E9ctWeJG9Fta8uKifz7m42 VITE_AUTH_CLIENT_SECRET=PceNvDCoFyUSM4gCO1OSD3Rsvvg5iRAkYD3QhOQfTPFmiOOWqGsBH39o8FtYuyuWXPNhZcNiUegsnBth1am6rw798FHyWBTxquGVgWsF9Y9rfRUlSZRqavMYsPWcIlOF diff --git a/src/components/layout/AdminLayout.tsx b/src/components/layout/AdminLayout.tsx index f8b4bd2..90c2076 100644 --- a/src/components/layout/AdminLayout.tsx +++ b/src/components/layout/AdminLayout.tsx @@ -7,6 +7,7 @@ import { getSidebarMenuItems } from "@/constants/navigation"; import { colors } from "@/design/tokens/colors"; import { spacing } from "@/design/tokens/spacing"; import LogoutButton from "@/features/auth/LogoutButton"; +import PageBreadcrumb from "@/components/layout/PageBreadcrumb"; const { Header, Content, Sider } = Layout; const { Title } = Typography; @@ -143,6 +144,8 @@ const AdminLayout = ({ children }: AdminLayoutProps) => { + +
{ + const location = useLocation(); + const isHome = location.pathname === HOME_PATH; + const currentItem = sidebarItems.find((item) => item.key === location.pathname); + + const homeLabel = ( + + + داشبورد + + ); + + const items = [ + { title: isHome ? homeLabel : {homeLabel} }, + ...(!isHome && currentItem ? [{ title: currentItem.label }] : []), + ]; + + return ; +}; + +export default PageBreadcrumb; diff --git a/src/constants/auth.ts b/src/constants/auth.ts index 7a9f661..f1767e0 100644 --- a/src/constants/auth.ts +++ b/src/constants/auth.ts @@ -10,4 +10,5 @@ export const AUTH_SCOPE = [ 'crm.message:retrieve', 'accounts.profile:list', 'accounts.users:list', + 'accounts.profile:detailed_retrieve' ].join(' '); diff --git a/src/pages/Profile/Profile.tsx b/src/pages/Profile/Profile.tsx index 3f205bc..221fc61 100644 --- a/src/pages/Profile/Profile.tsx +++ b/src/pages/Profile/Profile.tsx @@ -1,17 +1,97 @@ -import { Card, Typography } from 'antd'; -import { colors } from '@/design/tokens/colors'; +import { App, Empty, Input, Spin } from 'antd'; +import { useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { spacing } from '@/design/tokens/spacing'; +import { usersApi } from '@/services/usersApi'; +import type { UserDetails } from '@/types/user'; +import UserInfoCard from '@/pages/Profile/UserInfoCard'; +import StatCardsRow from '@/pages/Profile/StatCards'; +import ProfileTabs from '@/pages/Profile/ProfileTabs'; -const { Title, Paragraph } = Typography; +type SearchStatus = 'idle' | 'loading' | 'error' | 'success'; + +interface FetchResult { + uuid: string; + status: 'success' | 'error'; + user: UserDetails | null; +} + +const UUID_PARAM = 'uuid'; const Profile = () => { + const { message } = App.useApp(); + const [searchParams, setSearchParams] = useSearchParams(); + const uuid = searchParams.get(UUID_PARAM) ?? ''; + const [result, setResult] = useState(null); + + const handleSearch = (value: string) => { + const trimmed = value.trim(); + + if (!trimmed) { + message.warning('شناسه (UUID) کاربر را وارد کنید'); + return; + } + + setSearchParams({ [UUID_PARAM]: trimmed }); + }; + + useEffect(() => { + if (!uuid) { + return; + } + + usersApi + .getUserDetails(uuid) + .then((details) => { + setResult({ uuid, status: 'success', user: details }); + }) + .catch(() => { + setResult({ uuid, status: 'error', user: null }); + message.error('کاربری با این شناسه یافت نشد'); + }); + }, [uuid, message]); + + const isCurrent = result?.uuid === uuid; + const status: SearchStatus = !uuid ? 'idle' : isCurrent ? result.status : 'loading'; + const user = isCurrent ? result.user : null; + return ( - - پروفایل کاربر - - جدول اطلاعات کاربر به‌زودی اضافه خواهد شد. - - +
+
+ +
+ + {status === 'idle' && ( + + )} + + {status === 'loading' && ( +
+ +
+ )} + + {status === 'error' && } + + {status === 'success' && user && ( + <> +
+ + +
+ + + + )} +
); }; diff --git a/src/pages/Profile/ProfileTabs.tsx b/src/pages/Profile/ProfileTabs.tsx new file mode 100644 index 0000000..cfb10e1 --- /dev/null +++ b/src/pages/Profile/ProfileTabs.tsx @@ -0,0 +1,21 @@ +import { Empty, Tabs } from 'antd'; +import type { CSSProperties } from 'react'; + +interface ProfileTabsProps { + style?: CSSProperties; +} + +const ProfileTabs = ({ style }: ProfileTabsProps) => ( + , + }, + ]} + /> +); + +export default ProfileTabs; diff --git a/src/pages/Profile/StatCards.tsx b/src/pages/Profile/StatCards.tsx new file mode 100644 index 0000000..3d8a3dd --- /dev/null +++ b/src/pages/Profile/StatCards.tsx @@ -0,0 +1,76 @@ +import { Card, Typography } from 'antd'; +import { ArrowDownToLine, ArrowUpFromLine, Eye, Megaphone, MonitorPlay } from 'lucide'; +import type { IconNode } from 'lucide'; +import LucideIcon from '@/components/ui/LucideIcon'; +import { colors } from '@/design/tokens/colors'; +import { spacing } from '@/design/tokens/spacing'; + +const { Text } = Typography; + +interface StatCardData { + key: string; + label: string; + value: string; + icon: IconNode; + accent?: boolean; +} + +const formatCount = (value: number) => value.toLocaleString('en-US'); +const formatToman = (value: number) => `${value.toLocaleString('en-US')} ت`; + +const STAT_CARDS: StatCardData[] = [ + { key: 'billboards', label: 'بیلبوردها', value: formatCount(12), icon: MonitorPlay }, + { key: 'views', label: 'بازدیدها', value: formatCount(4320), icon: Eye }, + { key: 'withdrawals', label: 'برداشت‌ها', value: formatToman(2000000), icon: ArrowUpFromLine, accent: true }, + { key: 'deposits', label: 'واریزها', value: formatToman(8500000), icon: ArrowDownToLine, accent: true }, + { key: 'promotions', label: 'پروموشن‌ها', value: formatToman(2300000), icon: Megaphone, accent: true }, +]; + +const StatCard = ({ label, value, icon, accent }: Omit) => { + const iconColor = accent ? colors.accent : colors.primary; + + return ( + +
+ +
+ {label} + {value} +
+ ); +}; + +const StatCardsRow = () => ( + <> + {STAT_CARDS.map(({ key, ...rest }) => ( + + ))} + +); + +export default StatCardsRow; diff --git a/src/pages/Profile/UserInfoCard.tsx b/src/pages/Profile/UserInfoCard.tsx new file mode 100644 index 0000000..b4582ed --- /dev/null +++ b/src/pages/Profile/UserInfoCard.tsx @@ -0,0 +1,64 @@ +import { Avatar, Card, Typography } from 'antd'; +import { UserRound } from 'lucide'; +import { useState } from 'react'; +import type { ReactNode } from 'react'; +import LucideIcon from '@/components/ui/LucideIcon'; +import { colors } from '@/design/tokens/colors'; +import { spacing } from '@/design/tokens/spacing'; +import type { UserDetails } from '@/types/user'; + +const { Title, Text } = Typography; + +interface UserInfoCardProps { + user: UserDetails; +} + +const InfoRow = ({ label, children }: { label: string; children: ReactNode }) => ( +
+ {label} + {children} +
+); + +const UserInfoCard = ({ user }: UserInfoCardProps) => { + const [avatarFailed, setAvatarFailed] = useState(false); + const fullName = user.name || `${user.first_name} ${user.last_name}`.trim() || 'بدون نام'; + const showAvatarImage = Boolean(user.avatar) && !avatarFailed; + + return ( + +
+ } + onError={() => { + setAvatarFailed(true); + return false; + }} + style={{ background: colors.primaryLight, color: colors.primary, flexShrink: 0 }} + /> + + {fullName} + +
+ +
+ + + {user.uuid} + + + + {user.phone_number} + +
+
+ ); +}; + +export default UserInfoCard; diff --git a/src/services/api.ts b/src/services/api.ts index 004dc10..f50c3c9 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -51,7 +51,7 @@ api.interceptors.response.use( params.append('refresh_token', refreshToken); params.append('scope', AUTH_SCOPE); - const response = await axios.post(buildAuthUrl('/oauth2/token/'), params, { + const response = await axios.post(buildAuthUrl('/accounts/oauth2/token/'), params, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: createBasicAuthHeader(), diff --git a/src/services/authApi.ts b/src/services/authApi.ts index 015bd39..4f52c68 100644 --- a/src/services/authApi.ts +++ b/src/services/authApi.ts @@ -13,7 +13,7 @@ export const authApi = { phone_number: normalizedPhone, }; - const response = await axios.post(buildAuthUrl('/users/api/request_otp/'), payload); + const response = await axios.post(buildAuthUrl('/accounts/users/api/request_otp/'), payload); return response.data; }, @@ -26,7 +26,7 @@ export const authApi = { params.append('scope', AUTH_SCOPE); params.append('auth_fields', 'phone_number:otp'); - const response = await axios.post(buildAuthUrl('/oauth2/token/'), params, { + const response = await axios.post(buildAuthUrl('/accounts/oauth2/token/'), params, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: createBasicAuthHeader(), @@ -48,7 +48,7 @@ export const authApi = { params.append('refresh_token', refreshToken); params.append('scope', AUTH_SCOPE); - const response = await axios.post(buildAuthUrl('/oauth2/token/'), params, { + const response = await axios.post(buildAuthUrl('/accounts/oauth2/token/'), params, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: createBasicAuthHeader(), @@ -69,7 +69,7 @@ export const authApi = { params.append('token', token); params.append('token_type_hint', 'access_token'); - await axios.post(buildAuthUrl('/oauth2/revoke_token/'), params, { + await axios.post(buildAuthUrl('/accounts/oauth2/revoke_token/'), params, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: createBasicAuthHeader(), diff --git a/src/services/usersApi.ts b/src/services/usersApi.ts new file mode 100644 index 0000000..e2ae29e --- /dev/null +++ b/src/services/usersApi.ts @@ -0,0 +1,20 @@ +import axios from 'axios'; +import { env } from '@/config/env'; +import { getAccessToken } from '@/utils/authToken'; +import { buildAuthUrl } from '@/utils/authUrl'; +import type { UserDetails } from '@/types/user'; + +export const usersApi = { + getUserDetails: async (uuid: string): Promise => { + const response = await axios.get( + buildAuthUrl(`/accounts/users/api/users/${uuid}/details`, env.apiBaseUrl, { trailingSlash: false }), + { + headers: { + Authorization: `Bearer ${getAccessToken()}`, + }, + }, + ); + + return response.data; + }, +}; diff --git a/src/types/user.ts b/src/types/user.ts new file mode 100644 index 0000000..03f2ea3 --- /dev/null +++ b/src/types/user.ts @@ -0,0 +1,22 @@ +export interface UserDetails { + uuid: string; + username: string; + email: string; + phone_number: string; + first_name: string; + last_name: string; + name: string; + avatar: string; + gender: string; + education_degree: string; + education_field: string; + birthdate: string; + iban: string; + iban_verified: boolean; + province: number; + province_name: string; + city: number; + city_name: string; + postal_code: string; + address: string; +} diff --git a/src/utils/authUrl.ts b/src/utils/authUrl.ts index 2946870..c146a39 100644 --- a/src/utils/authUrl.ts +++ b/src/utils/authUrl.ts @@ -4,9 +4,18 @@ const normalizeBaseUrl = (value: string) => value.trim().replace(/\/+$/, ''); const ensureLeadingSlash = (value: string) => (value.startsWith('/') ? value : `/${value}`); const ensureTrailingSlash = (value: string) => (value.endsWith('/') ? value : `${value}/`); -export const buildAuthUrl = (path: string, baseUrl = env.authBaseUrl) => { +interface BuildAuthUrlOptions { + trailingSlash?: boolean; +} + +export const buildAuthUrl = ( + path: string, + baseUrl = env.authBaseUrl, + { trailingSlash = true }: BuildAuthUrlOptions = {}, +) => { const normalizedBaseUrl = normalizeBaseUrl(baseUrl); - const normalizedPath = ensureTrailingSlash(ensureLeadingSlash(path)); + const withLeadingSlash = ensureLeadingSlash(path); + const normalizedPath = trailingSlash ? ensureTrailingSlash(withLeadingSlash) : withLeadingSlash.replace(/\/+$/, ''); if (!normalizedBaseUrl) { return normalizedPath; @@ -15,10 +24,11 @@ export const buildAuthUrl = (path: string, baseUrl = env.authBaseUrl) => { try { const parsed = new URL(normalizedBaseUrl); const basePath = parsed.pathname === '/' ? '' : parsed.pathname.replace(/\/+$/, ''); - const joinedPath = ensureTrailingSlash(`${basePath}${normalizedPath}`); + const joinedPath = `${basePath}${normalizedPath}`; - return `${parsed.origin}${joinedPath}`; + return `${parsed.origin}${trailingSlash ? ensureTrailingSlash(joinedPath) : joinedPath}`; } catch { - return ensureTrailingSlash(`${normalizedBaseUrl}${normalizedPath}`); + const joinedPath = `${normalizedBaseUrl}${normalizedPath}`; + return trailingSlash ? ensureTrailingSlash(joinedPath) : joinedPath; } };