feature/535 #3

Merged
afra.khalaj merged 3 commits from feature/535 into feature/WNPT-534 2026-08-09 02:27:42 -04:00
16 changed files with 782 additions and 26 deletions

25
package-lock.json generated
View file

@ -11,6 +11,7 @@
"@reduxjs/toolkit": "^2.12.0",
"antd": "^6.5.2",
"axios": "^1.18.1",
"leaflet": "^1.9.4",
"lucide": "^1.27.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
@ -22,6 +23,7 @@
"@eslint/js": "^10.0.1",
"@rolldown/plugin-babel": "^0.2.3",
"@types/babel__core": "^7.20.5",
"@types/leaflet": "^1.9.22",
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
@ -1880,6 +1882,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@ -1887,6 +1896,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/leaflet": {
"version": "1.9.22",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz",
"integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/node": {
"version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
@ -3273,6 +3292,12 @@
"json-buffer": "3.0.1"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",

View file

@ -13,6 +13,7 @@
"@reduxjs/toolkit": "^2.12.0",
"antd": "^6.5.2",
"axios": "^1.18.1",
"leaflet": "^1.9.4",
"lucide": "^1.27.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
@ -24,6 +25,7 @@
"@eslint/js": "^10.0.1",
"@rolldown/plugin-babel": "^0.2.3",
"@types/babel__core": "^7.20.5",
"@types/leaflet": "^1.9.22",
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",

View file

@ -1,7 +1,6 @@
import { Breadcrumb } from 'antd';
import { Home } from 'lucide';
import type { ReactNode } from 'react';
import { Link, useLocation } from 'react-router-dom';
import LucideIcon from '@/components/ui/LucideIcon';
import { sidebarItems } from '@/constants/navigation';
import { spacing } from '@/design/tokens/spacing';
@ -10,19 +9,25 @@ const HOME_PATH = '/';
const PageBreadcrumb = () => {
const location = useLocation();
const isHome = location.pathname === HOME_PATH;
const currentItem = sidebarItems.find((item) => item.key === location.pathname);
const exactItem = sidebarItems.find((item) => item.key === location.pathname);
const parentItem = exactItem
? undefined
: sidebarItems.find((item) => item.key !== HOME_PATH && location.pathname.startsWith(`${item.key}/`));
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 }] : []),
];
const items: { title: ReactNode }[] = [{ title: isHome ? homeLabel : <Link to={HOME_PATH}>{homeLabel}</Link> }];
if (exactItem) {
items.push({ title: exactItem.label });
} else if (parentItem) {
items.push({ title: <Link to={parentItem.key}>{parentItem.label}</Link> });
items.push({ title: 'جزئیات' });
}
return <Breadcrumb items={items} style={{ margin: `${spacing.md}px 0` }} />;
};

View file

@ -0,0 +1,8 @@
import { Typography } from 'antd';
import { colors } from '@/design/tokens/colors';
const { Text } = Typography;
const EmptyValue = () => <Text style={{ color: colors.textSecondary }}>—</Text>;
export default EmptyValue;

View file

@ -0,0 +1,20 @@
import { Typography } from 'antd';
import type { ReactNode } from 'react';
import { colors } from '@/design/tokens/colors';
import { spacing } from '@/design/tokens/spacing';
const { Text } = Typography;
interface InfoRowProps {
label: string;
children: ReactNode;
}
const InfoRow = ({ label, children }: InfoRowProps) => (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: spacing.sm }}>
<Text style={{ color: colors.textSecondary }}>{label}</Text>
{children}
</div>
);
export default InfoRow;

View file

@ -0,0 +1,85 @@
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
import markerIcon from 'leaflet/dist/images/marker-icon.png';
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
import { useEffect, useRef } from 'react';
// Vite-bundled builds break Leaflet's default marker icon lookup unless the
// image URLs are pointed at the bundler-resolved assets explicitly.
delete (L.Icon.Default.prototype as { _getIconUrl?: unknown })._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: markerIcon2x,
iconUrl: markerIcon,
shadowUrl: markerShadow,
});
const TILE_URL = 'https://maps.addwin.ir/styles/basic/{z}/{x}/{y}.png';
interface LocationMapProps {
lat: number;
lng: number;
popupTitle?: string;
popupDescription?: string;
zoom?: number;
height?: number;
}
const buildPopupContent = (title?: string, description?: string) => {
if (!title && !description) {
return null;
}
const container = document.createElement('div');
if (title) {
const titleEl = document.createElement('b');
titleEl.textContent = title;
container.appendChild(titleEl);
}
if (title && description) {
container.appendChild(document.createElement('br'));
}
if (description) {
const descriptionEl = document.createElement('span');
descriptionEl.textContent = description;
container.appendChild(descriptionEl);
}
return container;
};
const LocationMap = ({ lat, lng, popupTitle, popupDescription, zoom = 15, height = 300 }: LocationMapProps) => {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current) {
return;
}
const map = L.map(containerRef.current).setView([lat, lng], zoom);
L.tileLayer(TILE_URL, {
maxZoom: 19,
minZoom: 8,
attribution: '© Addwin Maps',
}).addTo(map);
const marker = L.marker([lat, lng]).addTo(map);
const popupContent = buildPopupContent(popupTitle, popupDescription);
if (popupContent) {
marker.bindPopup(popupContent).openPopup();
}
return () => {
map.remove();
};
}, [lat, lng, zoom, popupTitle, popupDescription]);
return <div ref={containerRef} style={{ width: '100%', height, borderRadius: 8 }} />;
};
export default LocationMap;

View file

@ -8,10 +8,12 @@ interface LucideIconProps extends SVGProps<SVGSVGElement> {
const toReactPropName = (name: string) => name.replace(/-([a-z])/g, (_, char) => char.toUpperCase());
const LucideIcon = ({ icon, ...props }: LucideIconProps): ReactElement => {
const LucideIcon = ({ icon, size, ...props }: LucideIconProps): ReactElement => {
const svgProps = {
...props,
xmlns: 'http://www.w3.org/2000/svg',
width: size,
height: size,
fill: 'none',
stroke: 'currentColor',
strokeWidth: 2,

View file

@ -0,0 +1,333 @@
import { App, Button, Card, Empty, Image, Spin, Tag, Tooltip, Typography } from 'antd';
import { ArrowRight, FileText, Tag as TagIcon } from 'lucide';
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import LucideIcon from '@/components/ui/LucideIcon';
import InfoRow from '@/components/ui/InfoRow';
import EmptyValue from '@/components/ui/EmptyValue';
import LocationMap from '@/components/ui/LocationMap';
import { colors } from '@/design/tokens/colors';
import { spacing } from '@/design/tokens/spacing';
import { formatDateTime } from '@/utils/formatDate';
import { formatToman } from '@/utils/formatCurrency';
import { adsApi } from '@/services/adsApi';
import { AD_STATE_LABELS, AD_TYPE_LABELS } from '@/pages/Billboards/adLabels';
import type { Ad, AdMediaAsset } from '@/types/ad';
const { Title, Text, Paragraph } = Typography;
const MEDIA_KIND: Record<number, 'image' | 'audio' | 'video' | 'document'> = {
1: 'image',
2: 'audio',
3: 'video',
4: 'document',
};
const buildGoogleMapsUrl = (lat: number, lng: number) => `https://www.google.com/maps?q=${lat},${lng}`;
const sectionCardStyle = { borderColor: colors.primaryLight };
const MediaCard = ({ item }: { item: AdMediaAsset }) => {
const kind = MEDIA_KIND[item.content_type];
return (
<div style={{ width: 220 }}>
{kind === 'image' && (
<Image
src={item.url}
alt={item.filename}
style={{ width: 220, height: 160, objectFit: 'cover', borderRadius: 8 }}
/>
)}
{kind === 'video' && (
<video
controls
src={item.url}
style={{ width: '100%', height: 160, borderRadius: 8, background: '#000' }}
/>
)}
{kind === 'audio' && (
<div
style={{
height: 160,
borderRadius: 8,
background: colors.primaryLight,
display: 'flex',
alignItems: 'center',
padding: spacing.sm,
}}
>
<audio controls src={item.url} style={{ width: '100%' }} />
</div>
)}
{(kind === 'document' || !kind) && (
<a
href={item.url}
target="_blank"
rel="noreferrer"
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
height: 160,
borderRadius: 8,
background: colors.primaryLight,
color: colors.primary,
textDecoration: 'none',
}}
>
<LucideIcon icon={FileText} size={32} />
<span style={{ fontSize: 12 }}>دانلود فایل</span>
</a>
)}
<Tooltip title={item.filename}>
<Text
ellipsis
style={{ display: 'block', marginTop: spacing.xs, fontSize: 12, color: colors.textSecondary }}
>
{item.filename}
</Text>
</Tooltip>
</div>
);
};
interface FetchResult {
uuid: string;
status: 'success' | 'error';
ad: Ad | null;
}
const AdDetail = () => {
const { uuid = '' } = useParams<{ uuid: string }>();
const navigate = useNavigate();
const { message } = App.useApp();
const [result, setResult] = useState<FetchResult | null>(null);
useEffect(() => {
adsApi
.getAdminAdDetail(uuid)
.then((ad) => {
setResult({ uuid, status: 'success', ad });
})
.catch(() => {
setResult({ uuid, status: 'error', ad: null });
message.error('دریافت اطلاعات آگهی با خطا مواجه شد');
});
}, [uuid, message]);
const isCurrent = result?.uuid === uuid;
const isLoading = !isCurrent;
const hasError = isCurrent && result.status === 'error';
const ad = isCurrent && result.status === 'success' ? result.ad : null;
return (
<div>
<Button
type="text"
icon={<LucideIcon icon={ArrowRight} size={16} />}
onClick={() => navigate('/billboards')}
style={{ marginBottom: spacing.md, paddingInline: 0 }}
>
بازگشت به لیست بیلبوردها
</Button>
{isLoading && (
<div style={{ display: 'flex', justifyContent: 'center', padding: spacing.xl }}>
<Spin size="large" />
</div>
)}
{hasError && <Empty description="آگهی مورد نظر یافت نشد" style={{ padding: spacing.xl }} />}
{ad && (
<div style={{ display: 'flex', flexDirection: 'column', gap: spacing.md }}>
{/* Hero */}
<Card variant="outlined" style={sectionCardStyle} styles={{ body: { padding: 0 } }}>
{ad.refined_banner ? (
<Image
src={ad.refined_banner.url}
alt={ad.title ?? 'بنر'}
style={{ width: '100%', maxHeight: 320, objectFit: 'cover' }}
styles={{ root: { display: 'block', lineHeight: 0 } }}
/>
) : (
<div
style={{
height: 160,
background: colors.primaryLight,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text style={{ color: colors.textSecondary }}>بنری برای این آگهی ثبت نشده است</Text>
</div>
)}
<div style={{ padding: spacing.lg }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: spacing.sm,
}}
>
<Title level={3} style={{ margin: 0 }}>
{ad.title ?? <EmptyValue />}
</Title>
<div style={{ display: 'flex', gap: spacing.xs, flexWrap: 'wrap' }}>
<Tag color={colors.primary}>{AD_TYPE_LABELS[ad.type] ?? ad.type}</Tag>
<Tag color={AD_STATE_LABELS[ad.state]?.color ?? 'default'}>
{AD_STATE_LABELS[ad.state]?.label ?? ad.state}
</Tag>
<Tag color={ad.is_active ? 'success' : 'default'}>{ad.is_active ? 'فعال' : 'غیرفعال'}</Tag>
</div>
</div>
</div>
</Card>
{/* General info + reward */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: spacing.md }}>
<Card
title="اطلاعات کلی"
variant="outlined"
style={{ ...sectionCardStyle, flex: '1 1 320px' }}
styles={{ body: { display: 'flex', flexDirection: 'column', gap: spacing.sm } }}
>
<InfoRow label="شناسه">
<Text copyable={{ text: ad.uuid }} style={{ direction: 'ltr', fontSize: 12 }}>
{ad.uuid}
</Text>
</InfoRow>
<InfoRow label="کاربر (ثبت‌کننده)">
<Link to={`/profile?uuid=${ad.user}`} style={{ direction: 'ltr', fontSize: 12 }}>
{ad.user}
</Link>
</InfoRow>
<InfoRow label="تاریخ ایجاد">
<Text>{formatDateTime(ad.created_at)}</Text>
</InfoRow>
<InfoRow label="تاریخ آخرین ویرایش">
<Text>{formatDateTime(ad.updated_at)}</Text>
</InfoRow>
<InfoRow label="تعداد نظرات">
<Text>{ad.comments_count}</Text>
</InfoRow>
<InfoRow label="تعداد لایک‌ها">
<Text>{ad.likes_count}</Text>
</InfoRow>
</Card>
<Card
title="جایزه و آدرس"
variant="outlined"
style={{ ...sectionCardStyle, flex: '1 1 280px' }}
styles={{ body: { display: 'flex', flexDirection: 'column', gap: spacing.sm } }}
>
<InfoRow label="مبلغ جایزه">
<Text>{formatToman(ad.reward_amount)}</Text>
</InfoRow>
<InfoRow label="تعداد جایزه">
<Text>{ad.reward_count}</Text>
</InfoRow>
<InfoRow label="آدرس">{ad.address ? <Text>{ad.address}</Text> : <EmptyValue />}</InfoRow>
</Card>
</div>
{/* Description */}
<Card title="توضیحات" variant="outlined" style={sectionCardStyle}>
<Paragraph style={{ color: colors.text, whiteSpace: 'pre-wrap', margin: 0 }}>
{ad.description ?? <EmptyValue />}
</Paragraph>
</Card>
{/* Tags */}
<Card
title={
<span style={{ display: 'inline-flex', alignItems: 'center', gap: spacing.xs }}>
<LucideIcon icon={TagIcon} size={14} color={colors.textSecondary} />
برچسب‌ها
</span>
}
variant="outlined"
style={sectionCardStyle}
>
{ad.tags.length ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: spacing.xs }}>
{ad.tags.map((tag) => (
<Tag key={tag} color={colors.primaryLight} style={{ color: colors.primary, fontSize: 13 }}>
{tag}
</Tag>
))}
</div>
) : (
<EmptyValue />
)}
</Card>
{/* Media */}
<Card title="محتوا" variant="outlined" style={sectionCardStyle}>
{ad.refined_contents.length ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: spacing.md }}>
{ad.refined_contents.map((item) => (
<MediaCard key={item.uuid} item={item} />
))}
</div>
) : (
<EmptyValue />
)}
</Card>
{/* Location */}
<Card title="موقعیت مکانی" variant="outlined" style={sectionCardStyle}>
{ad.location ? (
<>
{ad.location_is_approximate && (
<Tag color="warning" style={{ marginBottom: spacing.sm }}>
موقعیت مکانی تقریبی است
</Tag>
)}
<div style={{ borderRadius: 8, overflow: 'hidden', border: `1px solid ${colors.primaryLight}` }}>
<LocationMap
lat={ad.location.coordinates[1]}
lng={ad.location.coordinates[0]}
popupTitle={ad.title ?? undefined}
popupDescription={ad.address || undefined}
/>
</div>
<div
style={{
marginTop: spacing.sm,
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: spacing.sm,
}}
>
<Text style={{ direction: 'ltr', color: colors.textSecondary }}>
{ad.location.coordinates[1]}, {ad.location.coordinates[0]}
</Text>
<a
href={buildGoogleMapsUrl(ad.location.coordinates[1], ad.location.coordinates[0])}
target="_blank"
rel="noreferrer"
>
مشاهده در Google Maps
</a>
</div>
</>
) : (
<EmptyValue />
)}
</Card>
</div>
)}
</div>
);
};
export default AdDetail;

View file

@ -1,17 +1,159 @@
import { Card, Typography } from 'antd';
import { App, Button, Table, Tag, Tooltip, Typography } from 'antd';
import type { TableColumnsType } from 'antd';
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { colors } from '@/design/tokens/colors';
import { spacing } from '@/design/tokens/spacing';
import { formatDateTime } from '@/utils/formatDate';
import { adsApi } from '@/services/adsApi';
import EmptyValue from '@/components/ui/EmptyValue';
import { AD_STATE_LABELS, AD_TYPE_LABELS, truncateText, truncateUuid } from '@/pages/Billboards/adLabels';
import type { Ad } from '@/types/ad';
const { Title, Paragraph } = Typography;
const { Text } = Typography;
const PAGE_SIZE = 10;
interface FetchResult {
page: number;
status: 'success' | 'error';
ads: Ad[];
total: number;
}
const Billboards = () => {
const { message } = App.useApp();
const navigate = useNavigate();
const [page, setPage] = useState(1);
const [result, setResult] = useState<FetchResult | null>(null);
useEffect(() => {
adsApi
.getAdminAds({ limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE })
.then(({ results, count }) => {
setResult({ page, status: 'success', ads: results, total: count });
})
.catch(() => {
setResult({ page, status: 'error', ads: [], total: 0 });
message.error('دریافت اطلاعات بیلبوردها با خطا مواجه شد');
});
}, [page, message]);
const isCurrent = result?.page === page;
const isLoading = !isCurrent;
const ads = isCurrent ? result.ads : [];
const total = isCurrent ? result.total : 0;
const columns: TableColumnsType<Ad> = [
{
title: 'شناسه',
dataIndex: 'uuid',
key: 'uuid',
render: (value: string) => (
<Tooltip title={value}>
<Text copyable={{ text: value }} style={{ direction: 'ltr', fontSize: 12 }}>
{truncateUuid(value)}
</Text>
</Tooltip>
),
},
{
title: 'کاربر (ثبت‌کننده)',
dataIndex: 'user',
key: 'user',
render: (value: string) => (
<Tooltip title={value}>
<Link to={`/profile?uuid=${value}`} style={{ direction: 'ltr', fontSize: 12 }}>
{truncateUuid(value)}
</Link>
</Tooltip>
),
},
{
title: 'عنوان',
dataIndex: 'title',
key: 'title',
ellipsis: true,
render: (value: string | null) => value ?? <EmptyValue />,
},
{
title: 'نوع',
dataIndex: 'type',
key: 'type',
render: (value: number) => <Tag color={colors.primary}>{AD_TYPE_LABELS[value] ?? value}</Tag>,
},
{
title: 'توضیحات',
dataIndex: 'description',
key: 'description',
render: (value: string | null) =>
value ? (
<Tooltip title={value}>
<span>{truncateText(value)}</span>
</Tooltip>
) : (
<EmptyValue />
),
},
{
title: 'برچسب‌ها',
dataIndex: 'tags',
key: 'tags',
render: (tags: string[]) =>
tags?.length ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{tags.map((tag) => (
<Tag key={tag}>{tag}</Tag>
))}
</div>
) : (
<EmptyValue />
),
},
{ title: 'تاریخ ایجاد', dataIndex: 'created_at', key: 'created_at', render: formatDateTime },
{ title: 'تاریخ آخرین ویرایش', dataIndex: 'updated_at', key: 'updated_at', render: formatDateTime },
{
title: 'وضعیت',
dataIndex: 'state',
key: 'state',
render: (value: number) => {
const state = AD_STATE_LABELS[value];
return <Tag color={state?.color ?? 'default'}>{state?.label ?? value}</Tag>;
},
},
{ title: 'تعداد نظرات', dataIndex: 'comments_count', key: 'comments_count' },
{ title: 'تعداد لایک‌ها', dataIndex: 'likes_count', key: 'likes_count' },
{
title: 'فعال/غیرفعال',
dataIndex: 'is_active',
key: 'is_active',
render: (value: boolean) => <Tag color={value ? 'success' : 'default'}>{value ? 'فعال' : 'غیرفعال'}</Tag>,
},
{
title: 'جزئیات',
key: 'details',
render: (_, record) => (
<Button size="small" onClick={() => navigate(`/billboards/${record.uuid}`)}>
مشاهده جزئیات
</Button>
),
},
];
return (
<Card bordered={false} style={{ boxShadow: 'none', padding: 0 }}>
<Title level={2}>بیلبوردها</Title>
<Paragraph style={{ color: colors.textSecondary, marginTop: spacing.sm }}>
مدیریت جدول بیلبوردها به‌زودی اضافه خواهد شد.
</Paragraph>
</Card>
<Table
rowKey="uuid"
columns={columns}
dataSource={ads}
loading={isLoading}
scroll={{ x: 'max-content' }}
pagination={{
current: page,
pageSize: PAGE_SIZE,
total,
showSizeChanger: false,
onChange: setPage,
}}
/>
);
};

View file

@ -0,0 +1,27 @@
export const AD_TYPE_LABELS: Record<number, string> = {
1: 'بیلبورد',
2: 'بیلبورد کپچر شده',
99: 'تبلیغ/پروموشن',
100: 'poi',
};
export type AdStateColor = 'default' | 'processing' | 'success' | 'warning' | 'error';
export const AD_STATE_LABELS: Record<number, { label: string; color: AdStateColor }> = {
0: { label: 'پیش‌نویس', color: 'default' },
2: { label: 'در حال بررسی', color: 'processing' },
9: { label: 'رد شده', color: 'error' },
18: { label: 'متوقف‌شده (توسط کاربر)', color: 'warning' },
19: { label: 'لغو انتشار شده', color: 'warning' },
20: { label: 'منتشر شده', color: 'success' },
100: { label: 'حذف شده', color: 'error' },
200: { label: 'در حال اجرا', color: 'processing' },
400: { label: 'پایان‌یافته', color: 'success' },
500: { label: 'غیرفعال', color: 'default' },
};
export const truncateUuid = (value: string) => `${value.slice(0, 8)}…`;
const DESCRIPTION_MAX_LENGTH = 40;
export const truncateText = (value: string, maxLength = DESCRIPTION_MAX_LENGTH) =>
value.length > maxLength ? `${value.slice(0, maxLength)}…` : value;

View file

@ -1,8 +1,8 @@
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 InfoRow from '@/components/ui/InfoRow';
import { colors } from '@/design/tokens/colors';
import { spacing } from '@/design/tokens/spacing';
import type { UserDetails } from '@/types/user';
@ -13,13 +13,6 @@ 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() || 'بدون نام';

View file

@ -5,6 +5,7 @@ import VerifyOtp from '@/pages/VerifyOtp/VerifyOtp';
import Home from '@/pages/Home/Home';
import Profile from '@/pages/Profile/Profile';
import Billboards from '@/pages/Billboards/Billboards';
import AdDetail from '@/pages/Billboards/AdDetail';
import ProtectedRoute from '@/components/ProtectedRoute';
export const router = createBrowserRouter([
@ -44,6 +45,14 @@ export const router = createBrowserRouter([
</ProtectedRoute>
),
},
{
path: 'billboards/:uuid',
element: (
<ProtectedRoute>
<AdDetail />
</ProtectedRoute>
),
},
{
path: '*',
element: <Navigate to="/" replace />,

36
src/services/adsApi.ts Normal file
View file

@ -0,0 +1,36 @@
import axios from 'axios';
import { env } from '@/config/env';
import { getAccessToken } from '@/utils/authToken';
import { buildAuthUrl } from '@/utils/authUrl';
import type { Ad, AdminAdsResponse } from '@/types/ad';
interface GetAdminAdsParams {
limit: number;
offset: number;
}
export const adsApi = {
getAdminAds: async ({ limit, offset }: GetAdminAdsParams): Promise<AdminAdsResponse> => {
const response = await axios.get<AdminAdsResponse>(
buildAuthUrl('/advertising/stores/admin-ads/', env.apiBaseUrl),
{
params: { limit, offset },
headers: {
Authorization: `Bearer ${getAccessToken()}`,
},
},
);
return response.data;
},
getAdminAdDetail: async (uuid: string): Promise<Ad> => {
const response = await axios.get<Ad>(buildAuthUrl(`/advertising/stores/admin-ads/${uuid}/`, env.apiBaseUrl), {
headers: {
Authorization: `Bearer ${getAccessToken()}`,
},
});
return response.data;
},
};

51
src/types/ad.ts Normal file
View file

@ -0,0 +1,51 @@
export interface AdLocation {
type: string;
coordinates: [number, number];
}
export interface AdMediaAsset {
uuid: string;
object_key: string;
content_type: number;
filename: string;
state: number;
url: string;
}
export interface Ad {
uuid: string;
campaign: string | null;
title: string | null;
type: number;
details: Record<string, unknown>;
content: string;
contents: unknown[];
refined_contents: AdMediaAsset[];
description: string | null;
location: AdLocation | null;
location_is_approximate: boolean;
banner: string | null;
refined_banner: AdMediaAsset | null;
reward_amount: number;
reward_count: number;
address: string | null;
url: string | null;
tags: string[];
created_at: string;
updated_at: string;
display_time: number;
state: number;
status: string;
is_active: boolean;
user: string;
user_uuid: string;
comments_count: number;
likes_count: number;
}
export interface AdminAdsResponse {
count: number;
next: string | null;
previous: string | null;
results: Ad[];
}

View file

@ -0,0 +1 @@
export const formatToman = (value: number): string => `${value.toLocaleString('en-US')} تومان`;

17
src/utils/formatDate.ts Normal file
View file

@ -0,0 +1,17 @@
export const formatDateTime = (value: string): string => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date
.toLocaleString('en-GB', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
.replace(',', '');
};