feat: user profile page implemented

This commit is contained in:
Afra ‌ 2026-08-08 10:37:44 +03:30
parent a072f1463b
commit 22e649bf67
13 changed files with 348 additions and 21 deletions

4
.env
View file

@ -1,4 +1,4 @@
VITE_AUTH_BASE_URL=https://accounts-staging.gooyal.ir VITE_AUTH_BASE_URL=https://admin-backend-staging.winsoo.ir/api
VITE_API_BASE_URL=https://accounts-staging.gooyal.ir VITE_API_BASE_URL=https://admin-backend-staging.winsoo.ir/api
VITE_AUTH_CLIENT_ID=PLWyn000M78oYPP2R7E9ctWeJG9Fta8uKifz7m42 VITE_AUTH_CLIENT_ID=PLWyn000M78oYPP2R7E9ctWeJG9Fta8uKifz7m42
VITE_AUTH_CLIENT_SECRET=PceNvDCoFyUSM4gCO1OSD3Rsvvg5iRAkYD3QhOQfTPFmiOOWqGsBH39o8FtYuyuWXPNhZcNiUegsnBth1am6rw798FHyWBTxquGVgWsF9Y9rfRUlSZRqavMYsPWcIlOF VITE_AUTH_CLIENT_SECRET=PceNvDCoFyUSM4gCO1OSD3Rsvvg5iRAkYD3QhOQfTPFmiOOWqGsBH39o8FtYuyuWXPNhZcNiUegsnBth1am6rw798FHyWBTxquGVgWsF9Y9rfRUlSZRqavMYsPWcIlOF

View file

@ -7,6 +7,7 @@ import { getSidebarMenuItems } from "@/constants/navigation";
import { colors } from "@/design/tokens/colors"; import { colors } from "@/design/tokens/colors";
import { spacing } from "@/design/tokens/spacing"; import { spacing } from "@/design/tokens/spacing";
import LogoutButton from "@/features/auth/LogoutButton"; import LogoutButton from "@/features/auth/LogoutButton";
import PageBreadcrumb from "@/components/layout/PageBreadcrumb";
const { Header, Content, Sider } = Layout; const { Header, Content, Sider } = Layout;
const { Title } = Typography; const { Title } = Typography;
@ -143,6 +144,8 @@ const AdminLayout = ({ children }: AdminLayoutProps) => {
</Header> </Header>
<Content style={{ margin: 24 }}> <Content style={{ margin: 24 }}>
<PageBreadcrumb />
<div <div
style={{ style={{
background: colors.background, background: colors.background,

View file

@ -0,0 +1,30 @@
import { Breadcrumb } from 'antd';
import { Home } from 'lucide';
import { Link, useLocation } from 'react-router-dom';
import LucideIcon from '@/components/ui/LucideIcon';
import { sidebarItems } from '@/constants/navigation';
import { spacing } from '@/design/tokens/spacing';
const HOME_PATH = '/';
const PageBreadcrumb = () => {
const location = useLocation();
const isHome = location.pathname === HOME_PATH;
const currentItem = sidebarItems.find((item) => item.key === location.pathname);
const homeLabel = (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<LucideIcon icon={Home} size={14} />
داشبورد
</span>
);
const items = [
{ title: isHome ? homeLabel : <Link to={HOME_PATH}>{homeLabel}</Link> },
...(!isHome && currentItem ? [{ title: currentItem.label }] : []),
];
return <Breadcrumb items={items} style={{ margin: `${spacing.md}px 0` }} />;
};
export default PageBreadcrumb;

View file

@ -10,4 +10,5 @@ export const AUTH_SCOPE = [
'crm.message:retrieve', 'crm.message:retrieve',
'accounts.profile:list', 'accounts.profile:list',
'accounts.users:list', 'accounts.users:list',
'accounts.profile:detailed_retrieve'
].join(' '); ].join(' ');

View file

@ -1,17 +1,97 @@
import { Card, Typography } from 'antd'; import { App, Empty, Input, Spin } from 'antd';
import { colors } from '@/design/tokens/colors'; import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { spacing } from '@/design/tokens/spacing'; 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 Profile = () => {
const { message } = App.useApp();
const [searchParams, setSearchParams] = useSearchParams();
const uuid = searchParams.get(UUID_PARAM) ?? '';
const [result, setResult] = useState<FetchResult | null>(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 ( return (
<Card bordered={false} style={{ boxShadow: 'none', padding: 0 }}> <div>
<Title level={2}>پروفایل کاربر</Title> <div style={{ marginBottom: spacing.md }}>
<Paragraph style={{ color: colors.textSecondary, marginTop: spacing.sm }}> <Input.Search
جدول اطلاعات کاربر به‌زودی اضافه خواهد شد. placeholder="جستجو بر اساس شناسه (UUID) کاربر"
</Paragraph> defaultValue={uuid}
</Card> onSearch={handleSearch}
enterButton="جستجو"
size="large"
loading={status === 'loading'}
style={{ maxWidth: 480 }}
/>
</div>
{status === 'idle' && (
<Empty description="برای مشاهده اطلاعات، شناسه (UUID) کاربر را جستجو کنید" style={{ padding: spacing.xl }} />
)}
{status === 'loading' && (
<div style={{ display: 'flex', justifyContent: 'center', padding: spacing.xl }}>
<Spin size="large" />
</div>
)}
{status === 'error' && <Empty description="کاربری با این شناسه یافت نشد" style={{ padding: spacing.xl }} />}
{status === 'success' && user && (
<>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: spacing.md }}>
<UserInfoCard user={user} />
<StatCardsRow />
</div>
<ProfileTabs style={{ marginTop: spacing.lg }} />
</>
)}
</div>
); );
}; };

View file

@ -0,0 +1,21 @@
import { Empty, Tabs } from 'antd';
import type { CSSProperties } from 'react';
interface ProfileTabsProps {
style?: CSSProperties;
}
const ProfileTabs = ({ style }: ProfileTabsProps) => (
<Tabs
style={style}
items={[
{
key: 'overview',
label: 'جزئیات',
children: <Empty description="جدول‌های این بخش در گام بعدی اضافه خواهند شد" />,
},
]}
/>
);
export default ProfileTabs;

View file

@ -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<StatCardData, 'key'>) => {
const iconColor = accent ? colors.accent : colors.primary;
return (
<Card
variant="outlined"
style={{ flex: '1 1 160px', borderColor: colors.primaryLight, height: '100%' }}
styles={{
body: {
height: '100%',
padding: spacing.lg,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
gap: spacing.xs,
},
}}
>
<div
style={{
width: 48,
height: 48,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.xs,
}}
>
<LucideIcon icon={icon} size={22} color={iconColor} />
</div>
<Text style={{ color: colors.textSecondary, fontSize: 13 }}>{label}</Text>
<Text style={{ fontSize: 20, fontWeight: 700, color: colors.text }}>{value}</Text>
</Card>
);
};
const StatCardsRow = () => (
<>
{STAT_CARDS.map(({ key, ...rest }) => (
<StatCard key={key} {...rest} />
))}
</>
);
export default StatCardsRow;

View file

@ -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 }) => (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: spacing.sm }}>
<Text style={{ color: colors.textSecondary }}>{label}</Text>
{children}
</div>
);
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 (
<Card
variant="outlined"
style={{ flex: '1 1 320px', borderColor: colors.primaryLight }}
styles={{ body: { padding: spacing.lg } }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: spacing.md, marginBottom: spacing.lg }}>
<Avatar
size={64}
src={showAvatarImage ? user.avatar : undefined}
icon={showAvatarImage ? undefined : <LucideIcon icon={UserRound} size={28} />}
onError={() => {
setAvatarFailed(true);
return false;
}}
style={{ background: colors.primaryLight, color: colors.primary, flexShrink: 0 }}
/>
<Title level={4} style={{ margin: 0, color: colors.text }}>
{fullName}
</Title>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: spacing.sm }}>
<InfoRow label="شناسه کاربر">
<Text copyable={{ text: user.uuid }} style={{ direction: 'ltr' }}>
{user.uuid}
</Text>
</InfoRow>
<InfoRow label="شماره موبایل">
<Text style={{ direction: 'ltr' }}>{user.phone_number}</Text>
</InfoRow>
</div>
</Card>
);
};
export default UserInfoCard;

View file

@ -51,7 +51,7 @@ api.interceptors.response.use(
params.append('refresh_token', refreshToken); params.append('refresh_token', refreshToken);
params.append('scope', AUTH_SCOPE); params.append('scope', AUTH_SCOPE);
const response = await axios.post<TokenResponse>(buildAuthUrl('/oauth2/token/'), params, { const response = await axios.post<TokenResponse>(buildAuthUrl('/accounts/oauth2/token/'), params, {
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
Authorization: createBasicAuthHeader(), Authorization: createBasicAuthHeader(),

View file

@ -13,7 +13,7 @@ export const authApi = {
phone_number: normalizedPhone, phone_number: normalizedPhone,
}; };
const response = await axios.post<RequestOtpResponse>(buildAuthUrl('/users/api/request_otp/'), payload); const response = await axios.post<RequestOtpResponse>(buildAuthUrl('/accounts/users/api/request_otp/'), payload);
return response.data; return response.data;
}, },
@ -26,7 +26,7 @@ export const authApi = {
params.append('scope', AUTH_SCOPE); params.append('scope', AUTH_SCOPE);
params.append('auth_fields', 'phone_number:otp'); params.append('auth_fields', 'phone_number:otp');
const response = await axios.post<TokenResponse>(buildAuthUrl('/oauth2/token/'), params, { const response = await axios.post<TokenResponse>(buildAuthUrl('/accounts/oauth2/token/'), params, {
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
Authorization: createBasicAuthHeader(), Authorization: createBasicAuthHeader(),
@ -48,7 +48,7 @@ export const authApi = {
params.append('refresh_token', refreshToken); params.append('refresh_token', refreshToken);
params.append('scope', AUTH_SCOPE); params.append('scope', AUTH_SCOPE);
const response = await axios.post<TokenResponse>(buildAuthUrl('/oauth2/token/'), params, { const response = await axios.post<TokenResponse>(buildAuthUrl('/accounts/oauth2/token/'), params, {
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
Authorization: createBasicAuthHeader(), Authorization: createBasicAuthHeader(),
@ -69,7 +69,7 @@ export const authApi = {
params.append('token', token); params.append('token', token);
params.append('token_type_hint', 'access_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: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
Authorization: createBasicAuthHeader(), Authorization: createBasicAuthHeader(),

20
src/services/usersApi.ts Normal file
View file

@ -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<UserDetails> => {
const response = await axios.get<UserDetails>(
buildAuthUrl(`/accounts/users/api/users/${uuid}/details`, env.apiBaseUrl, { trailingSlash: false }),
{
headers: {
Authorization: `Bearer ${getAccessToken()}`,
},
},
);
return response.data;
},
};

22
src/types/user.ts Normal file
View file

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

View file

@ -4,9 +4,18 @@ const normalizeBaseUrl = (value: string) => value.trim().replace(/\/+$/, '');
const ensureLeadingSlash = (value: string) => (value.startsWith('/') ? value : `/${value}`); const ensureLeadingSlash = (value: string) => (value.startsWith('/') ? value : `/${value}`);
const ensureTrailingSlash = (value: string) => (value.endsWith('/') ? 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 normalizedBaseUrl = normalizeBaseUrl(baseUrl);
const normalizedPath = ensureTrailingSlash(ensureLeadingSlash(path)); const withLeadingSlash = ensureLeadingSlash(path);
const normalizedPath = trailingSlash ? ensureTrailingSlash(withLeadingSlash) : withLeadingSlash.replace(/\/+$/, '');
if (!normalizedBaseUrl) { if (!normalizedBaseUrl) {
return normalizedPath; return normalizedPath;
@ -15,10 +24,11 @@ export const buildAuthUrl = (path: string, baseUrl = env.authBaseUrl) => {
try { try {
const parsed = new URL(normalizedBaseUrl); const parsed = new URL(normalizedBaseUrl);
const basePath = parsed.pathname === '/' ? '' : parsed.pathname.replace(/\/+$/, ''); 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 { } catch {
return ensureTrailingSlash(`${normalizedBaseUrl}${normalizedPath}`); const joinedPath = `${normalizedBaseUrl}${normalizedPath}`;
return trailingSlash ? ensureTrailingSlash(joinedPath) : joinedPath;
} }
}; };