feature/535 #3
13 changed files with 520 additions and 47 deletions
25
package-lock.json
generated
25
package-lock.json
generated
|
|
@ -11,6 +11,7 @@
|
||||||
"@reduxjs/toolkit": "^2.12.0",
|
"@reduxjs/toolkit": "^2.12.0",
|
||||||
"antd": "^6.5.2",
|
"antd": "^6.5.2",
|
||||||
"axios": "^1.18.1",
|
"axios": "^1.18.1",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"lucide": "^1.27.0",
|
"lucide": "^1.27.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
|
|
@ -22,6 +23,7 @@
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@rolldown/plugin-babel": "^0.2.3",
|
"@rolldown/plugin-babel": "^0.2.3",
|
||||||
"@types/babel__core": "^7.20.5",
|
"@types/babel__core": "^7.20.5",
|
||||||
|
"@types/leaflet": "^1.9.22",
|
||||||
"@types/node": "^24.13.2",
|
"@types/node": "^24.13.2",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|
@ -1880,6 +1882,13 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
|
|
@ -1887,6 +1896,16 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.13.3",
|
"version": "24.13.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||||
|
|
@ -3273,6 +3292,12 @@
|
||||||
"json-buffer": "3.0.1"
|
"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": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
"@reduxjs/toolkit": "^2.12.0",
|
"@reduxjs/toolkit": "^2.12.0",
|
||||||
"antd": "^6.5.2",
|
"antd": "^6.5.2",
|
||||||
"axios": "^1.18.1",
|
"axios": "^1.18.1",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"lucide": "^1.27.0",
|
"lucide": "^1.27.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
|
|
@ -24,6 +25,7 @@
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@rolldown/plugin-babel": "^0.2.3",
|
"@rolldown/plugin-babel": "^0.2.3",
|
||||||
"@types/babel__core": "^7.20.5",
|
"@types/babel__core": "^7.20.5",
|
||||||
|
"@types/leaflet": "^1.9.22",
|
||||||
"@types/node": "^24.13.2",
|
"@types/node": "^24.13.2",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Breadcrumb } from 'antd';
|
import { Breadcrumb } from 'antd';
|
||||||
import { Home } from 'lucide';
|
import { Home } from 'lucide';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import LucideIcon from '@/components/ui/LucideIcon';
|
import LucideIcon from '@/components/ui/LucideIcon';
|
||||||
import { sidebarItems } from '@/constants/navigation';
|
import { sidebarItems } from '@/constants/navigation';
|
||||||
|
|
@ -10,7 +11,10 @@ const HOME_PATH = '/';
|
||||||
const PageBreadcrumb = () => {
|
const PageBreadcrumb = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isHome = location.pathname === HOME_PATH;
|
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 = (
|
const homeLabel = (
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
|
@ -19,10 +23,14 @@ const PageBreadcrumb = () => {
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
const items = [
|
const items: { title: ReactNode }[] = [{ title: isHome ? homeLabel : <Link to={HOME_PATH}>{homeLabel}</Link> }];
|
||||||
{ title: isHome ? homeLabel : <Link to={HOME_PATH}>{homeLabel}</Link> },
|
|
||||||
...(!isHome && currentItem ? [{ title: currentItem.label }] : []),
|
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` }} />;
|
return <Breadcrumb items={items} style={{ margin: `${spacing.md}px 0` }} />;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
8
src/components/ui/EmptyValue.tsx
Normal file
8
src/components/ui/EmptyValue.tsx
Normal 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;
|
||||||
20
src/components/ui/InfoRow.tsx
Normal file
20
src/components/ui/InfoRow.tsx
Normal 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;
|
||||||
85
src/components/ui/LocationMap.tsx
Normal file
85
src/components/ui/LocationMap.tsx
Normal 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;
|
||||||
312
src/pages/Billboards/AdDetail.tsx
Normal file
312
src/pages/Billboards/AdDetail.tsx
Normal file
|
|
@ -0,0 +1,312 @@
|
||||||
|
import { App, Button, Card, Empty, Image, Spin, Tag, Tooltip, Typography } from 'antd';
|
||||||
|
import { ArrowRight, FileText } 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 + tags */}
|
||||||
|
<Card title="توضیحات" variant="outlined" style={sectionCardStyle}>
|
||||||
|
<Paragraph style={{ color: colors.text, whiteSpace: 'pre-wrap', marginBottom: spacing.md }}>
|
||||||
|
{ad.description ?? <EmptyValue />}
|
||||||
|
</Paragraph>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||||
|
{ad.tags.length ? ad.tags.map((tag) => <Tag key={tag}>{tag}</Tag>) : <EmptyValue />}
|
||||||
|
</div>
|
||||||
|
</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;
|
||||||
|
|
@ -1,46 +1,18 @@
|
||||||
import { App, Button, Table, Tag, Tooltip, Typography } from 'antd';
|
import { App, Button, Table, Tag, Tooltip, Typography } from 'antd';
|
||||||
import type { TableColumnsType } from 'antd';
|
import type { TableColumnsType } from 'antd';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { colors } from '@/design/tokens/colors';
|
import { colors } from '@/design/tokens/colors';
|
||||||
import { formatDateTime } from '@/utils/formatDate';
|
import { formatDateTime } from '@/utils/formatDate';
|
||||||
import { adsApi } from '@/services/adsApi';
|
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';
|
import type { Ad } from '@/types/ad';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
const AD_TYPE_LABELS: Record<number, string> = {
|
|
||||||
1: 'بیلبورد',
|
|
||||||
2: 'بیلبورد کپچر شده',
|
|
||||||
99: 'تبلیغ/پروموشن',
|
|
||||||
100: 'poi',
|
|
||||||
};
|
|
||||||
|
|
||||||
type AdStateColor = 'default' | 'processing' | 'success' | 'warning' | 'error';
|
|
||||||
|
|
||||||
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' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const truncateUuid = (value: string) => `${value.slice(0, 8)}…`;
|
|
||||||
|
|
||||||
const DESCRIPTION_MAX_LENGTH = 40;
|
|
||||||
const truncateText = (value: string, maxLength = DESCRIPTION_MAX_LENGTH) =>
|
|
||||||
value.length > maxLength ? `${value.slice(0, maxLength)}…` : value;
|
|
||||||
|
|
||||||
const EmptyValue = () => <Text style={{ color: colors.textSecondary }}>—</Text>;
|
|
||||||
|
|
||||||
interface FetchResult {
|
interface FetchResult {
|
||||||
page: number;
|
page: number;
|
||||||
status: 'success' | 'error';
|
status: 'success' | 'error';
|
||||||
|
|
@ -50,6 +22,7 @@ interface FetchResult {
|
||||||
|
|
||||||
const Billboards = () => {
|
const Billboards = () => {
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [result, setResult] = useState<FetchResult | null>(null);
|
const [result, setResult] = useState<FetchResult | null>(null);
|
||||||
|
|
||||||
|
|
@ -158,8 +131,8 @@ const Billboards = () => {
|
||||||
{
|
{
|
||||||
title: 'جزئیات',
|
title: 'جزئیات',
|
||||||
key: 'details',
|
key: 'details',
|
||||||
render: () => (
|
render: (_, record) => (
|
||||||
<Button size="small" disabled>
|
<Button size="small" onClick={() => navigate(`/billboards/${record.uuid}`)}>
|
||||||
مشاهده جزئیات
|
مشاهده جزئیات
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
|
|
|
||||||
27
src/pages/Billboards/adLabels.ts
Normal file
27
src/pages/Billboards/adLabels.ts
Normal 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;
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { Avatar, Card, Typography } from 'antd';
|
import { Avatar, Card, Typography } from 'antd';
|
||||||
import { UserRound } from 'lucide';
|
import { UserRound } from 'lucide';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
import LucideIcon from '@/components/ui/LucideIcon';
|
import LucideIcon from '@/components/ui/LucideIcon';
|
||||||
|
import InfoRow from '@/components/ui/InfoRow';
|
||||||
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 type { UserDetails } from '@/types/user';
|
import type { UserDetails } from '@/types/user';
|
||||||
|
|
@ -13,13 +13,6 @@ interface UserInfoCardProps {
|
||||||
user: UserDetails;
|
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 UserInfoCard = ({ user }: UserInfoCardProps) => {
|
||||||
const [avatarFailed, setAvatarFailed] = useState(false);
|
const [avatarFailed, setAvatarFailed] = useState(false);
|
||||||
const fullName = user.name || `${user.first_name} ${user.last_name}`.trim() || 'بدون نام';
|
const fullName = user.name || `${user.first_name} ${user.last_name}`.trim() || 'بدون نام';
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import VerifyOtp from '@/pages/VerifyOtp/VerifyOtp';
|
||||||
import Home from '@/pages/Home/Home';
|
import Home from '@/pages/Home/Home';
|
||||||
import Profile from '@/pages/Profile/Profile';
|
import Profile from '@/pages/Profile/Profile';
|
||||||
import Billboards from '@/pages/Billboards/Billboards';
|
import Billboards from '@/pages/Billboards/Billboards';
|
||||||
|
import AdDetail from '@/pages/Billboards/AdDetail';
|
||||||
import ProtectedRoute from '@/components/ProtectedRoute';
|
import ProtectedRoute from '@/components/ProtectedRoute';
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
|
|
@ -44,6 +45,14 @@ export const router = createBrowserRouter([
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'billboards/:uuid',
|
||||||
|
element: (
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AdDetail />
|
||||||
|
</ProtectedRoute>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '*',
|
path: '*',
|
||||||
element: <Navigate to="/" replace />,
|
element: <Navigate to="/" replace />,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import axios from 'axios';
|
||||||
import { env } from '@/config/env';
|
import { env } from '@/config/env';
|
||||||
import { getAccessToken } from '@/utils/authToken';
|
import { getAccessToken } from '@/utils/authToken';
|
||||||
import { buildAuthUrl } from '@/utils/authUrl';
|
import { buildAuthUrl } from '@/utils/authUrl';
|
||||||
import type { AdminAdsResponse } from '@/types/ad';
|
import type { Ad, AdminAdsResponse } from '@/types/ad';
|
||||||
|
|
||||||
interface GetAdminAdsParams {
|
interface GetAdminAdsParams {
|
||||||
limit: number;
|
limit: number;
|
||||||
|
|
@ -23,4 +23,14 @@ export const adsApi = {
|
||||||
|
|
||||||
return response.data;
|
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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
1
src/utils/formatCurrency.ts
Normal file
1
src/utils/formatCurrency.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export const formatToman = (value: number): string => `${value.toLocaleString('en-US')} تومان`;
|
||||||
Loading…
Add table
Reference in a new issue