feat: user profile page implemented
This commit is contained in:
parent
a072f1463b
commit
22e649bf67
13 changed files with 348 additions and 21 deletions
4
.env
4
.env
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
30
src/components/layout/PageBreadcrumb.tsx
Normal file
30
src/components/layout/PageBreadcrumb.tsx
Normal 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;
|
||||||
|
|
@ -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(' ');
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
21
src/pages/Profile/ProfileTabs.tsx
Normal file
21
src/pages/Profile/ProfileTabs.tsx
Normal 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;
|
||||||
76
src/pages/Profile/StatCards.tsx
Normal file
76
src/pages/Profile/StatCards.tsx
Normal 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;
|
||||||
64
src/pages/Profile/UserInfoCard.tsx
Normal file
64
src/pages/Profile/UserInfoCard.tsx
Normal 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;
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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
20
src/services/usersApi.ts
Normal 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
22
src/types/user.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue