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' && ( + {item.filename} + )} + {kind === 'video' && ( +
+ ); +}; + +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 ( +
+ + + {isLoading && ( +
+ +
+ )} + + {hasError && } + + {ad && ( +
+ {/* Hero */} + + {ad.refined_banner ? ( + {ad.title + ) : ( +
+ بنری برای این آگهی ثبت نشده است +
+ )} +
+
+ + {ad.title ?? <EmptyValue />} + +
+ {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 && ( + + موقعیت مکانی تقریبی است + + )} +
+ +
+
+ + {ad.location.coordinates[1]}, {ad.location.coordinates[0]} + + + مشاهده در Google Maps + +
+ + ) : ( + + )} +
+
+ )} +
+ ); +}; + +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(',', ''); +};