diff --git a/package-lock.json b/package-lock.json
index 783e882..c36b733 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 941917b..ba7cd5b 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/components/layout/PageBreadcrumb.tsx b/src/components/layout/PageBreadcrumb.tsx
index 8ebf727..b83ebc2 100644
--- a/src/components/layout/PageBreadcrumb.tsx
+++ b/src/components/layout/PageBreadcrumb.tsx
@@ -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 = (
-
داشبورد
);
- const items = [
- { title: isHome ? homeLabel : {homeLabel} },
- ...(!isHome && currentItem ? [{ title: currentItem.label }] : []),
- ];
+ const items: { title: ReactNode }[] = [{ title: isHome ? homeLabel : {homeLabel} }];
+
+ if (exactItem) {
+ items.push({ title: exactItem.label });
+ } else if (parentItem) {
+ items.push({ title: {parentItem.label} });
+ items.push({ title: 'جزئیات' });
+ }
return ;
};
diff --git a/src/components/ui/EmptyValue.tsx b/src/components/ui/EmptyValue.tsx
new file mode 100644
index 0000000..d604e07
--- /dev/null
+++ b/src/components/ui/EmptyValue.tsx
@@ -0,0 +1,8 @@
+import { Typography } from 'antd';
+import { colors } from '@/design/tokens/colors';
+
+const { Text } = Typography;
+
+const EmptyValue = () => —;
+
+export default EmptyValue;
diff --git a/src/components/ui/InfoRow.tsx b/src/components/ui/InfoRow.tsx
new file mode 100644
index 0000000..3b3595c
--- /dev/null
+++ b/src/components/ui/InfoRow.tsx
@@ -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) => (
+
+ {label}
+ {children}
+
+);
+
+export default InfoRow;
diff --git a/src/components/ui/LocationMap.tsx b/src/components/ui/LocationMap.tsx
new file mode 100644
index 0000000..6d5c905
--- /dev/null
+++ b/src/components/ui/LocationMap.tsx
@@ -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(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 ;
+};
+
+export default LocationMap;
diff --git a/src/components/ui/LucideIcon.tsx b/src/components/ui/LucideIcon.tsx
index b484023..e9b3fef 100644
--- a/src/components/ui/LucideIcon.tsx
+++ b/src/components/ui/LucideIcon.tsx
@@ -8,10 +8,12 @@ interface LucideIconProps extends SVGProps {
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,
diff --git a/src/pages/Billboards/AdDetail.tsx b/src/pages/Billboards/AdDetail.tsx
new file mode 100644
index 0000000..c99875a
--- /dev/null
+++ b/src/pages/Billboards/AdDetail.tsx
@@ -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 = {
+ 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 (
+
+ {kind === 'image' && (
+
+ )}
+ {kind === 'video' && (
+
+ )}
+ {kind === 'audio' && (
+
+ )}
+ {(kind === 'document' || !kind) && (
+
+
+ دانلود فایل
+
+ )}
+
+
+ {item.filename}
+
+
+
+ );
+};
+
+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(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 (
+
+
}
+ onClick={() => navigate('/billboards')}
+ style={{ marginBottom: spacing.md, paddingInline: 0 }}
+ >
+ بازگشت به لیست بیلبوردها
+
+
+ {isLoading && (
+
+
+
+ )}
+
+ {hasError &&
}
+
+ {ad && (
+
+ {/* Hero */}
+
+ {ad.refined_banner ? (
+
+ ) : (
+
+ بنری برای این آگهی ثبت نشده است
+
+ )}
+
+
+
+ {ad.title ?? }
+
+
+ {AD_TYPE_LABELS[ad.type] ?? ad.type}
+
+ {AD_STATE_LABELS[ad.state]?.label ?? ad.state}
+
+ {ad.is_active ? 'فعال' : 'غیرفعال'}
+
+
+
+
+
+ {/* General info + reward */}
+
+
+
+
+ {ad.uuid}
+
+
+
+
+ {ad.user}
+
+
+
+ {formatDateTime(ad.created_at)}
+
+
+ {formatDateTime(ad.updated_at)}
+
+
+ {ad.comments_count}
+
+
+ {ad.likes_count}
+
+
+
+
+
+ {formatToman(ad.reward_amount)}
+
+
+ {ad.reward_count}
+
+ {ad.address ? {ad.address} : }
+
+
+
+ {/* Description */}
+
+
+ {ad.description ?? }
+
+
+
+ {/* Tags */}
+
+
+ برچسبها
+
+ }
+ variant="outlined"
+ style={sectionCardStyle}
+ >
+ {ad.tags.length ? (
+
+ {ad.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ ) : (
+
+ )}
+
+
+ {/* Media */}
+
+ {ad.refined_contents.length ? (
+
+ {ad.refined_contents.map((item) => (
+
+ ))}
+
+ ) : (
+
+ )}
+
+
+ {/* Location */}
+
+ {ad.location ? (
+ <>
+ {ad.location_is_approximate && (
+
+ موقعیت مکانی تقریبی است
+
+ )}
+
+
+
+
+ >
+ ) : (
+
+ )}
+
+
+ )}
+
+ );
+};
+
+export default AdDetail;
diff --git a/src/pages/Billboards/Billboards.tsx b/src/pages/Billboards/Billboards.tsx
index bae932c..99a552b 100644
--- a/src/pages/Billboards/Billboards.tsx
+++ b/src/pages/Billboards/Billboards.tsx
@@ -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(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 = [
+ {
+ title: 'شناسه',
+ dataIndex: 'uuid',
+ key: 'uuid',
+ render: (value: string) => (
+
+
+ {truncateUuid(value)}
+
+
+ ),
+ },
+ {
+ title: 'کاربر (ثبتکننده)',
+ dataIndex: 'user',
+ key: 'user',
+ render: (value: string) => (
+
+
+ {truncateUuid(value)}
+
+
+ ),
+ },
+ {
+ title: 'عنوان',
+ dataIndex: 'title',
+ key: 'title',
+ ellipsis: true,
+ render: (value: string | null) => value ?? ,
+ },
+ {
+ title: 'نوع',
+ dataIndex: 'type',
+ key: 'type',
+ render: (value: number) => {AD_TYPE_LABELS[value] ?? value},
+ },
+ {
+ title: 'توضیحات',
+ dataIndex: 'description',
+ key: 'description',
+ render: (value: string | null) =>
+ value ? (
+
+ {truncateText(value)}
+
+ ) : (
+
+ ),
+ },
+ {
+ title: 'برچسبها',
+ dataIndex: 'tags',
+ key: 'tags',
+ render: (tags: string[]) =>
+ tags?.length ? (
+
+ {tags.map((tag) => (
+ {tag}
+ ))}
+
+ ) : (
+
+ ),
+ },
+ { 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 {state?.label ?? value};
+ },
+ },
+ { title: 'تعداد نظرات', dataIndex: 'comments_count', key: 'comments_count' },
+ { title: 'تعداد لایکها', dataIndex: 'likes_count', key: 'likes_count' },
+ {
+ title: 'فعال/غیرفعال',
+ dataIndex: 'is_active',
+ key: 'is_active',
+ render: (value: boolean) => {value ? 'فعال' : 'غیرفعال'},
+ },
+ {
+ title: 'جزئیات',
+ key: 'details',
+ render: (_, record) => (
+
+ ),
+ },
+ ];
+
return (
-
- بیلبوردها
-
- مدیریت جدول بیلبوردها بهزودی اضافه خواهد شد.
-
-
+
);
};
diff --git a/src/pages/Billboards/adLabels.ts b/src/pages/Billboards/adLabels.ts
new file mode 100644
index 0000000..bae5859
--- /dev/null
+++ b/src/pages/Billboards/adLabels.ts
@@ -0,0 +1,27 @@
+export const AD_TYPE_LABELS: Record = {
+ 1: 'بیلبورد',
+ 2: 'بیلبورد کپچر شده',
+ 99: 'تبلیغ/پروموشن',
+ 100: 'poi',
+};
+
+export type AdStateColor = 'default' | 'processing' | 'success' | 'warning' | 'error';
+
+export const AD_STATE_LABELS: Record = {
+ 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;
diff --git a/src/pages/Profile/UserInfoCard.tsx b/src/pages/Profile/UserInfoCard.tsx
index b4582ed..91cf440 100644
--- a/src/pages/Profile/UserInfoCard.tsx
+++ b/src/pages/Profile/UserInfoCard.tsx
@@ -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 }) => (
-
- {label}
- {children}
-
-);
-
const UserInfoCard = ({ user }: UserInfoCardProps) => {
const [avatarFailed, setAvatarFailed] = useState(false);
const fullName = user.name || `${user.first_name} ${user.last_name}`.trim() || 'بدون نام';
diff --git a/src/routes/router.tsx b/src/routes/router.tsx
index 7630829..b553fb5 100644
--- a/src/routes/router.tsx
+++ b/src/routes/router.tsx
@@ -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([
),
},
+ {
+ path: 'billboards/:uuid',
+ element: (
+
+
+
+ ),
+ },
{
path: '*',
element: ,
diff --git a/src/services/adsApi.ts b/src/services/adsApi.ts
new file mode 100644
index 0000000..0dedb55
--- /dev/null
+++ b/src/services/adsApi.ts
@@ -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 => {
+ const response = await axios.get(
+ buildAuthUrl('/advertising/stores/admin-ads/', env.apiBaseUrl),
+ {
+ params: { limit, offset },
+ headers: {
+ Authorization: `Bearer ${getAccessToken()}`,
+ },
+ },
+ );
+
+ return response.data;
+ },
+
+ getAdminAdDetail: async (uuid: string): Promise => {
+ const response = await axios.get(buildAuthUrl(`/advertising/stores/admin-ads/${uuid}/`, env.apiBaseUrl), {
+ headers: {
+ Authorization: `Bearer ${getAccessToken()}`,
+ },
+ });
+
+ return response.data;
+ },
+};
diff --git a/src/types/ad.ts b/src/types/ad.ts
new file mode 100644
index 0000000..3e48774
--- /dev/null
+++ b/src/types/ad.ts
@@ -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;
+ 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[];
+}
diff --git a/src/utils/formatCurrency.ts b/src/utils/formatCurrency.ts
new file mode 100644
index 0000000..4484e53
--- /dev/null
+++ b/src/utils/formatCurrency.ts
@@ -0,0 +1 @@
+export const formatToman = (value: number): string => `${value.toLocaleString('en-US')} تومان`;
diff --git a/src/utils/formatDate.ts b/src/utils/formatDate.ts
new file mode 100644
index 0000000..3586b75
--- /dev/null
+++ b/src/utils/formatDate.ts
@@ -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(',', '');
+};