From 3e1641d28183e7332678c2e13a33f741c556ed69 Mon Sep 17 00:00:00 2001 From: CycroftX Date: Tue, 10 Feb 2026 15:06:58 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20Ad=20Control=20module=20=E2=80=94=20sur?= =?UTF-8?q?faces,=20placements,=20drag-and-drop=20reorder,=20event=20picke?= =?UTF-8?q?r,=20targeting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 9 + src/components/layout/Sidebar.tsx | 24 +- .../components/EventPickerModal.tsx | 167 +++++++++++ .../components/PlacementConfigDrawer.tsx | 282 ++++++++++++++++++ .../ad-control/components/PlacementList.tsx | 257 ++++++++++++++++ .../ad-control/components/SurfaceTabs.tsx | 74 +++++ src/features/ad-control/data/mockAdData.ts | 269 +++++++++++++++++ src/lib/actions/ad-control.ts | 280 +++++++++++++++++ src/lib/audit/placement-audit.ts | 50 ++++ src/lib/types/ad-control.ts | 110 +++++++ src/pages/AdControl.tsx | 244 +++++++++++++++ 11 files changed, 1755 insertions(+), 11 deletions(-) create mode 100644 src/features/ad-control/components/EventPickerModal.tsx create mode 100644 src/features/ad-control/components/PlacementConfigDrawer.tsx create mode 100644 src/features/ad-control/components/PlacementList.tsx create mode 100644 src/features/ad-control/components/SurfaceTabs.tsx create mode 100644 src/features/ad-control/data/mockAdData.ts create mode 100644 src/lib/actions/ad-control.ts create mode 100644 src/lib/audit/placement-audit.ts create mode 100644 src/lib/types/ad-control.ts create mode 100644 src/pages/AdControl.tsx diff --git a/src/App.tsx b/src/App.tsx index 58a79a8..35761c9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ import PartnerDirectory from "./features/partners/PartnerDirectory"; import PartnerProfile from "./features/partners/PartnerProfile"; import Events from "./pages/Events"; import Users from "./pages/Users"; +import AdControl from "./pages/AdControl"; import Financials from "./pages/Financials"; import Settings from "./pages/Settings"; import NotFound from "./pages/NotFound"; @@ -68,6 +69,14 @@ const App = () => ( } /> + + + + } + /> {navItems.map((item) => { - const isActive = location.pathname === item.href || + const isActive = location.pathname === item.href || (item.href !== '/' && location.pathname.startsWith(item.href)); - + return ( diff --git a/src/features/ad-control/components/EventPickerModal.tsx b/src/features/ad-control/components/EventPickerModal.tsx new file mode 100644 index 0000000..83dfcf3 --- /dev/null +++ b/src/features/ad-control/components/EventPickerModal.tsx @@ -0,0 +1,167 @@ +import { useState, useMemo } from 'react'; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Search, MapPin, Calendar, AlertTriangle, CheckCircle2, + ImageOff, Clock, Users, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { PickerEvent } from '@/lib/types/ad-control'; + +interface EventPickerModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + events: PickerEvent[]; + onSelectEvent: (event: PickerEvent) => void; + alreadyPlacedEventIds: string[]; +} + +export function EventPickerModal({ open, onOpenChange, events, onSelectEvent, alreadyPlacedEventIds }: EventPickerModalProps) { + const [query, setQuery] = useState(''); + + const now = new Date(); + + const filtered = useMemo(() => { + if (!query.trim()) return events; + const q = query.toLowerCase(); + return events.filter(e => + e.title.toLowerCase().includes(q) || + e.id.toLowerCase().includes(q) || + e.organizer.toLowerCase().includes(q) || + e.city.toLowerCase().includes(q) || + e.category.toLowerCase().includes(q) + ); + }, [events, query]); + + const getWarnings = (event: PickerEvent): { label: string; severity: 'warning' | 'error' }[] => { + const warns: { label: string; severity: 'warning' | 'error' }[] = []; + if (event.approvalStatus === 'PENDING') warns.push({ label: 'Pending Approval', severity: 'warning' }); + if (event.approvalStatus === 'REJECTED') warns.push({ label: 'Rejected', severity: 'error' }); + if (new Date(event.endDate) < now) warns.push({ label: 'Event Ended', severity: 'error' }); + if (!event.coverImage) warns.push({ label: 'No Cover Image', severity: 'warning' }); + return warns; + }; + + const handleSelect = (event: PickerEvent) => { + const warnings = getWarnings(event); + const hasErrors = warnings.some(w => w.severity === 'error'); + if (hasErrors) { + const msg = warnings.filter(w => w.severity === 'error').map(w => w.label).join(', '); + if (!confirm(`This event has issues: ${msg}. Proceed anyway?`)) return; + } + onSelectEvent(event); + onOpenChange(false); + setQuery(''); + }; + + return ( + + + + Select Event + Search for an event to place on this surface. + + + {/* Search */} +
+ + setQuery(e.target.value)} + placeholder="Search by name, ID, organizer, city, or category..." + className="pl-10" + autoFocus + /> +
+ + {/* Event List */} +
+ {filtered.length === 0 && ( +
+ No events found matching "{query}" +
+ )} + + {filtered.map(event => { + const warnings = getWarnings(event); + const isPlaced = alreadyPlacedEventIds.includes(event.id); + const fillPercent = Math.round((event.ticketsSold / event.capacity) * 100); + + return ( + + ); + })} +
+
+
+ ); +} diff --git a/src/features/ad-control/components/PlacementConfigDrawer.tsx b/src/features/ad-control/components/PlacementConfigDrawer.tsx new file mode 100644 index 0000000..153b781 --- /dev/null +++ b/src/features/ad-control/components/PlacementConfigDrawer.tsx @@ -0,0 +1,282 @@ +import { useState, useEffect } from 'react'; +import { + Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, +} from '@/components/ui/sheet'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Loader2, ExternalLink, X, Plus } from 'lucide-react'; +import { toast } from 'sonner'; +import { cn } from '@/lib/utils'; +import { createPlacement, updatePlacement, publishPlacement } from '@/lib/actions/ad-control'; +import { MOCK_CITIES, MOCK_CATEGORIES } from '../data/mockAdData'; +import type { PickerEvent, PlacementWithEvent, PlacementPriority, PlacementConfigData } from '@/lib/types/ad-control'; + +interface PlacementConfigDrawerProps { + open: boolean; + onOpenChange: (open: boolean) => void; + event: PickerEvent | null; + surfaceId: string; + editingPlacement: PlacementWithEvent | null; // null = create mode + onComplete: () => void; +} + +export function PlacementConfigDrawer({ + open, onOpenChange, event, surfaceId, editingPlacement, onComplete, +}: PlacementConfigDrawerProps) { + const isEdit = !!editingPlacement; + const displayEvent = editingPlacement?.event || event; + + const [startAt, setStartAt] = useState(''); + const [endAt, setEndAt] = useState(''); + const [selectedCities, setSelectedCities] = useState([]); + const [selectedCategories, setSelectedCategories] = useState([]); + const [boostLabel, setBoostLabel] = useState('none'); + const [priority, setPriority] = useState('MANUAL'); + const [notes, setNotes] = useState(''); + const [loading, setLoading] = useState<'draft' | 'publish' | null>(null); + + // Populate from editing placement + useEffect(() => { + if (editingPlacement) { + setStartAt(editingPlacement.startAt ? editingPlacement.startAt.slice(0, 16) : ''); + setEndAt(editingPlacement.endAt ? editingPlacement.endAt.slice(0, 16) : ''); + setSelectedCities(editingPlacement.targeting.cityIds); + setSelectedCategories(editingPlacement.targeting.categoryIds); + setBoostLabel(editingPlacement.boostLabel || 'none'); + setPriority(editingPlacement.priority); + setNotes(editingPlacement.notes || ''); + } else { + setStartAt(''); + setEndAt(''); + setSelectedCities([]); + setSelectedCategories([]); + setBoostLabel('none'); + setPriority('MANUAL'); + setNotes(''); + } + }, [editingPlacement, open]); + + const buildConfig = (): PlacementConfigData => ({ + startAt: startAt ? new Date(startAt).toISOString() : null, + endAt: endAt ? new Date(endAt).toISOString() : null, + targeting: { + cityIds: selectedCities, + categoryIds: selectedCategories, + countryCodes: ['IN'], + }, + boostLabel: boostLabel === 'none' ? null : boostLabel, + priority, + notes: notes.trim() || null, + }); + + const handleSaveDraft = async () => { + setLoading('draft'); + try { + if (isEdit) { + const res = await updatePlacement(editingPlacement!.id, buildConfig()); + res.success ? toast.success('Placement updated') : toast.error(res.message); + } else { + const res = await createPlacement(surfaceId, displayEvent!.id, buildConfig()); + res.success ? toast.success(res.message) : toast.error(res.message); + } + onOpenChange(false); + onComplete(); + } catch { toast.error('Save failed'); } + finally { setLoading(null); } + }; + + const handlePublish = async () => { + if (!confirm('Publish this placement? It will become visible on the public app.')) return; + setLoading('publish'); + try { + if (isEdit) { + // Update first, then publish + await updatePlacement(editingPlacement!.id, buildConfig()); + const res = await publishPlacement(editingPlacement!.id); + res.success ? toast.success(res.message) : toast.error(res.message); + } else { + const createRes = await createPlacement(surfaceId, displayEvent!.id, buildConfig()); + if (createRes.success && createRes.data) { + const pubRes = await publishPlacement(createRes.data.id); + pubRes.success ? toast.success(pubRes.message) : toast.error(pubRes.message); + } else { + toast.error(createRes.message); + setLoading(null); + return; + } + } + onOpenChange(false); + onComplete(); + } catch { toast.error('Publish failed'); } + finally { setLoading(null); } + }; + + const toggleCity = (id: string) => { + setSelectedCities(prev => prev.includes(id) ? prev.filter(c => c !== id) : [...prev, id]); + }; + const toggleCategory = (id: string) => { + setSelectedCategories(prev => prev.includes(id) ? prev.filter(c => c !== id) : [...prev, id]); + }; + + return ( + + + + {isEdit ? 'Edit Placement' : 'Configure Placement'} + + {isEdit ? 'Update schedule, targeting, and settings.' : 'Set up schedule, targeting, and publish.'} + + + + {/* Event Preview */} + {displayEvent && ( +
+ {displayEvent.coverImage ? ( + + ) : ( +
+ )} +
+

{displayEvent.title}

+

{displayEvent.city} · {displayEvent.organizer}

+

+ {new Date(displayEvent.date).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' })} +

+
+
+ )} + +
+ {/* Schedule */} +
+ +
+
+ + setStartAt(e.target.value)} /> +
+
+ + setEndAt(e.target.value)} /> +
+
+

Leave empty for no schedule constraints (always active when published).

+
+ + {/* Targeting — Cities */} +
+ +
+ {MOCK_CITIES.map(city => ( + toggleCity(city.id)} + > + {selectedCities.includes(city.id) && '✓ '} + {city.name} + + ))} +
+

No selection = all cities (nationwide).

+
+ + {/* Targeting — Categories */} +
+ +
+ {MOCK_CATEGORIES.map(cat => ( + toggleCategory(cat.id)} + > + {selectedCategories.includes(cat.id) && '✓ '} + {cat.name} + + ))} +
+
+ + {/* Boost Label + Priority */} +
+
+ + +
+
+ + +
+
+ + {/* Notes */} +
+ +