diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..dea7c50 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,160 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +// ===== ENUMS ===== + +enum CampaignStatus { + DRAFT + IN_REVIEW + ACTIVE + PAUSED + ENDED + REJECTED +} + +enum BillingModel { + FIXED + CPM + CPC +} + +enum CampaignObjective { + AWARENESS + SALES +} + +enum SurfaceKey { + HOME_FEATURED_CAROUSEL + HOME_TOP_EVENTS + CATEGORY_FEATURED + CITY_TRENDING + SEARCH_BOOSTED +} + +enum TrackingEventType { + IMPRESSION + CLICK +} + +// ===== MODELS ===== + +model Campaign { + id String @id @default(uuid()) + partnerId String + name String + objective CampaignObjective + status CampaignStatus @default(DRAFT) + + startAt DateTime + endAt DateTime + + billingModel BillingModel + totalBudget Decimal @db.Decimal(10, 2) + dailyCap Decimal? @db.Decimal(10, 2) + spent Decimal @default(0) @db.Decimal(10, 2) + + // Targeting (stored as JSON) + targeting Json // { cityIds: [], categoryIds: [], countryCodes: [] } + frequencyCap Int @default(0) // 0 = unlimited + + // Relations + placements SponsoredPlacement[] + events CampaignEvent[] // Many-to-many via join table or array of IDs + + approvedBy String? + rejectedReason String? + createdBy String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + auditLogs CampaignAuditLog[] +} + +model SponsoredPlacement { + id String @id @default(uuid()) + campaignId String + campaign Campaign @relation(fields: [campaignId], references: [id]) + + eventId String + surfaceKey SurfaceKey + priority String @default("SPONSORED") + + bid Decimal @db.Decimal(10, 2) + status String @default("ACTIVE") // ACTIVE, PAUSED + rank Int @default(0) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([campaignId, eventId, surfaceKey]) +} + +model CampaignEvent { + id String @id @default(uuid()) + campaignId String + campaign Campaign @relation(fields: [campaignId], references: [id]) + eventId String // Reference to Event table (not shown here) + + @@unique([campaignId, eventId]) +} + +model AdTrackingEvent { + id String @id @default(uuid()) + type TrackingEventType + + placementId String + campaignId String + surfaceKey SurfaceKey + eventId String + + userId String? + anonId String + sessionId String + + timestamp DateTime @default(now()) + device String? + cityId String? + + @@index([campaignId, type, timestamp]) + @@index([anonId, timestamp]) // For frequency capping queries +} + +model PlacementDailyStats { + id String @id @default(uuid()) + campaignId String + placementId String + surfaceKey SurfaceKey + + date DateTime @db.Date + + impressions Int @default(0) + clicks Int @default(0) + spend Decimal @default(0) @db.Decimal(10, 2) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([campaignId, placementId, surfaceKey, date]) +} + +model CampaignAuditLog { + id String @id @default(uuid()) + campaignId String + campaign Campaign @relation(fields: [campaignId], references: [id]) + + actorId String + action String // CREATED, UPDATED, SUBMITTED, APPROVED, REJECTED, PAUSED, RESUMED + details Json? // Changed fields, reason, etc. + + createdAt DateTime @default(now()) +} diff --git a/src/App.tsx b/src/App.tsx index 35761c9..e9f7a4b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,9 @@ import PartnerProfile from "./features/partners/PartnerProfile"; import Events from "./pages/Events"; import Users from "./pages/Users"; import AdControl from "./pages/AdControl"; +import SponsoredAds from "./pages/SponsoredAds"; +import NewCampaign from "./pages/NewCampaign"; +import CampaignReport from "./pages/CampaignReport"; import Financials from "./pages/Financials"; import Settings from "./pages/Settings"; import NotFound from "./pages/NotFound"; @@ -93,6 +96,30 @@ const App = () => ( } /> + + + + } + /> + + + + } + /> + + + + } + /> {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} } /> diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index b367463..3ee4873 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -7,7 +7,8 @@ import { DollarSign, Settings, Ticket, - Megaphone + Megaphone, + Zap } from 'lucide-react'; import { cn } from '@/lib/utils'; @@ -16,6 +17,7 @@ const navItems = [ { title: 'Partner Management', href: '/partners', icon: Users }, { title: 'Events', href: '/events', icon: Calendar }, { title: 'Ad Control', href: '/ad-control', icon: Megaphone }, + { title: 'Sponsored Ads', href: '/ad-control/sponsored', icon: Zap }, { title: 'Users', href: '/users', icon: User }, { title: 'Financials', href: '/financials', icon: DollarSign }, { title: 'Settings', href: '/settings', icon: Settings }, diff --git a/src/features/ad-control/components/sponsored/CampaignReportPage.tsx b/src/features/ad-control/components/sponsored/CampaignReportPage.tsx new file mode 100644 index 0000000..e37854b --- /dev/null +++ b/src/features/ad-control/components/sponsored/CampaignReportPage.tsx @@ -0,0 +1,230 @@ +import { useState, useEffect, useMemo } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + ArrowLeft, Download, Loader2, IndianRupee, Eye, + MousePointerClick, Percent, TrendingUp, Calendar, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { toast } from 'sonner'; +import { getCampaignReport, exportCampaignCSV } from '@/lib/actions/ads'; +import type { CampaignReport } from '@/lib/types/ads'; + +const STATUS_COLORS: Record = { + ACTIVE: 'bg-emerald-50 text-emerald-700 border-emerald-200', + IN_REVIEW: 'bg-amber-50 text-amber-700 border-amber-200', + PAUSED: 'bg-orange-50 text-orange-600 border-orange-200', + ENDED: 'bg-zinc-50 text-zinc-500 border-zinc-200', + REJECTED: 'bg-red-50 text-red-600 border-red-200', + DRAFT: 'bg-slate-50 text-slate-600 border-slate-200', +}; + +export function CampaignReportPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [report, setReport] = useState(null); + const [loading, setLoading] = useState(true); + const [exporting, setExporting] = useState(false); + + useEffect(() => { + if (!id) return; + (async () => { + setLoading(true); + const res = await getCampaignReport(id); + if (res.success && res.data) setReport(res.data); + else toast.error(res.message); + setLoading(false); + })(); + }, [id]); + + const handleExport = async () => { + if (!id) return; + setExporting(true); + try { + const res = await exportCampaignCSV(id); + if (res.success && res.csv) { + const blob = new Blob([res.csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `campaign-${id}-report.csv`; + a.click(); + URL.revokeObjectURL(url); + toast.success('CSV downloaded'); + } else { toast.error(res.message); } + } catch { toast.error('Export failed'); } + finally { setExporting(false); } + }; + + // Aggregate daily stats by date for the chart + const chartData = useMemo(() => { + if (!report) return []; + const byDate: Record = {}; + for (const stat of report.dailyStats) { + if (!byDate[stat.date]) byDate[stat.date] = { date: stat.date, impressions: 0, clicks: 0 }; + byDate[stat.date].impressions += stat.impressions; + byDate[stat.date].clicks += stat.clicks; + } + return Object.values(byDate).sort((a, b) => a.date.localeCompare(b.date)); + }, [report]); + + const maxImpressions = useMemo(() => Math.max(1, ...chartData.map(d => d.impressions)), [chartData]); + + if (loading) { + return ( +
+ +
+ ); + } + + if (!report) { + return ( +
+

Campaign not found

+ +
+ ); + } + + const { campaign, totals, bySurface } = report; + const spendPct = campaign.totalBudget > 0 ? (totals.spend / campaign.totalBudget) * 100 : 0; + + const summaryCards = [ + { label: 'Impressions', value: totals.impressions.toLocaleString(), icon: Eye, color: 'text-violet-600', bg: 'bg-violet-50' }, + { label: 'Clicks', value: totals.clicks.toLocaleString(), icon: MousePointerClick, color: 'text-amber-600', bg: 'bg-amber-50' }, + { label: 'CTR', value: `${(totals.ctr * 100).toFixed(2)}%`, icon: Percent, color: 'text-pink-600', bg: 'bg-pink-50' }, + { label: 'Spend', value: `₹${totals.spend.toLocaleString()}`, icon: IndianRupee, color: 'text-blue-600', bg: 'bg-blue-50' }, + { label: 'Remaining', value: `₹${totals.remaining.toLocaleString()}`, icon: TrendingUp, color: 'text-emerald-600', bg: 'bg-emerald-50' }, + ]; + + return ( +
+ {/* Header */} +
+
+ +
+
+

{campaign.name}

+ + {campaign.status} + +
+

+ {campaign.partnerName} · {campaign.billingModel} · {new Date(campaign.startAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })} → {new Date(campaign.endAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })} +

+
+
+ +
+ + {/* Budget Progress */} +
+
+ Budget Utilization + ₹{totals.spend.toLocaleString()} / ₹{campaign.totalBudget.toLocaleString()} ({spendPct.toFixed(1)}%) +
+
+
90 ? 'bg-red-500' : spendPct > 60 ? 'bg-amber-500' : 'bg-emerald-500')} + style={{ width: `${Math.min(100, spendPct)}%` }} + /> +
+
+ + {/* Summary Cards */} +
+ {summaryCards.map(s => ( +
+
+ +
+
+

{s.value}

+

{s.label}

+
+
+ ))} +
+ + {/* Chart — CSS Bar Chart */} +
+

Daily Performance

+
+ {chartData.map((d) => { + const impHeight = (d.impressions / maxImpressions) * 100; + const clkHeight = maxImpressions > 0 ? (d.clicks / maxImpressions) * 100 : 0; + return ( +
+ {/* Tooltip */} +
+ {d.date}: {d.impressions} imp, {d.clicks} clk +
+
+
0 ? '2px' : '0' }} + /> +
0 ? '2px' : '0' }} + /> +
+ {new Date(d.date + 'T00:00:00').toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })} +
+ ); + })} +
+
+
Impressions
+
Clicks
+
+
+ + {/* Surface Breakdown Table */} +
+
+

Performance by Surface

+
+ + + + + + + + + + + + {bySurface.map(s => ( + + + + + + + + ))} + + + + + + + + +
SurfaceImpressionsClicksCTRSpend
{s.surfaceName}{s.impressions.toLocaleString()}{s.clicks.toLocaleString()}{(s.ctr * 100).toFixed(2)}%₹{s.spend.toLocaleString()}
Total{totals.impressions.toLocaleString()}{totals.clicks.toLocaleString()}{(totals.ctr * 100).toFixed(2)}%₹{totals.spend.toLocaleString()}
+
+
+ ); +} diff --git a/src/features/ad-control/components/sponsored/CampaignWizard.tsx b/src/features/ad-control/components/sponsored/CampaignWizard.tsx new file mode 100644 index 0000000..cd4d3ae --- /dev/null +++ b/src/features/ad-control/components/sponsored/CampaignWizard.tsx @@ -0,0 +1,426 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Slider } from '@/components/ui/slider'; +import { + ArrowLeft, ArrowRight, Check, Loader2, Sparkles, + IndianRupee, Target, Calendar, Layers, Send, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { toast } from 'sonner'; +import { createCampaign, submitCampaign } from '@/lib/actions/ads'; +import { EventPickerModal } from '@/features/ad-control/components/EventPickerModal'; +import { MOCK_PICKER_EVENTS, MOCK_CITIES, MOCK_CATEGORIES } from '@/features/ad-control/data/mockAdData'; +import type { CampaignFormData, BillingModel, CampaignObjective } from '@/lib/types/ads'; +import type { SurfaceKey, PickerEvent } from '@/lib/types/ad-control'; + +const STEPS = [ + { id: 1, title: 'Basics', icon: Sparkles }, + { id: 2, title: 'Placement', icon: Layers }, + { id: 3, title: 'Targeting', icon: Target }, + { id: 4, title: 'Budget', icon: IndianRupee }, + { id: 5, title: 'Review', icon: Send }, +]; + +const SURFACE_OPTIONS: { key: SurfaceKey; label: string }[] = [ + { key: 'HOME_FEATURED_CAROUSEL', label: 'Home Featured Carousel' }, + { key: 'HOME_TOP_EVENTS', label: 'Home Top Events' }, + { key: 'CATEGORY_FEATURED', label: 'Category Featured' }, + { key: 'CITY_TRENDING', label: 'City Trending' }, + { key: 'SEARCH_BOOSTED', label: 'Search Boosted' }, +]; + +export function CampaignWizard() { + const navigate = useNavigate(); + const [step, setStep] = useState(1); + const [loading, setLoading] = useState(false); + const [pickerOpen, setPickerOpen] = useState(false); + + // Step 1: Basics + const [partnerName, setPartnerName] = useState(''); + const [campaignName, setCampaignName] = useState(''); + const [objective, setObjective] = useState('AWARENESS'); + const [startAt, setStartAt] = useState(''); + const [endAt, setEndAt] = useState(''); + + // Step 2: Placement + const [surfaceKeys, setSurfaceKeys] = useState([]); + const [selectedEvents, setSelectedEvents] = useState([]); + + // Step 3: Targeting + const [selectedCities, setSelectedCities] = useState([]); + const [selectedCategories, setSelectedCategories] = useState([]); + + // Step 4: Budget + const [billingModel, setBillingModel] = useState('CPM'); + const [totalBudget, setTotalBudget] = useState(10000); + const [dailyCap, setDailyCap] = useState(null); + const [frequencyCap, setFrequencyCap] = useState(5); + + const toggleSurface = (key: SurfaceKey) => { + setSurfaceKeys(prev => prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]); + }; + 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]); + + const handleEventSelected = (event: PickerEvent) => { + if (!selectedEvents.find(e => e.id === event.id)) { + setSelectedEvents(prev => [...prev, event]); + } + }; + const removeEvent = (id: string) => setSelectedEvents(prev => prev.filter(e => e.id !== id)); + + // --- Validation --- + const stepValid = (s: number): boolean => { + switch (s) { + case 1: return !!partnerName.trim() && !!campaignName.trim() && !!startAt && !!endAt; + case 2: return surfaceKeys.length > 0 && selectedEvents.length > 0; + case 3: return true; // targeting is optional + case 4: return totalBudget > 0; + case 5: return true; + default: return false; + } + }; + + const handleSubmit = async () => { + setLoading(true); + try { + const data: CampaignFormData = { + partnerName, + name: campaignName, + objective, + startAt: new Date(startAt).toISOString(), + endAt: new Date(endAt).toISOString(), + surfaceKeys, + eventIds: selectedEvents.map(e => e.id), + targeting: { cityIds: selectedCities, categoryIds: selectedCategories, countryCodes: ['IN'] }, + billingModel, + totalBudget, + dailyCap, + frequencyCap, + }; + + const res = await createCampaign(data); + if (!res.success || !res.data) { toast.error(res.message); setLoading(false); return; } + + // Auto-submit for review + const submitRes = await submitCampaign(res.data.id); + submitRes.success + ? toast.success('Campaign submitted for review!') + : toast.error(submitRes.message); + + navigate('/ad-control/sponsored'); + } catch { toast.error('Failed to create campaign'); } + finally { setLoading(false); } + }; + + return ( +
+ {/* Step Indicator */} +
+ {STEPS.map((s, i) => ( +
+ + {i < STEPS.length - 1 && ( +
s.id ? 'bg-emerald-400' : 'bg-muted')} /> + )} +
+ ))} +
+ + {/* Step Content */} +
+ {/* STEP 1: Basics */} + {step === 1 && ( +
+

Campaign Basics

+
+
+ + setPartnerName(e.target.value)} placeholder="e.g. SoundWave Productions" /> +
+
+ + +
+
+
+ + setCampaignName(e.target.value)} placeholder="e.g. Mumbai Music Festival – Premium Push" /> +
+
+
+ + setStartAt(e.target.value)} /> +
+
+ + setEndAt(e.target.value)} /> +
+
+
+ )} + + {/* STEP 2: Placement */} + {step === 2 && ( +
+

Placement Surfaces & Events

+ +
+ +
+ {SURFACE_OPTIONS.map(s => ( + + ))} +
+
+ +
+
+ + +
+ {selectedEvents.length === 0 && ( +

No events selected. Click "Add Event" to begin.

+ )} +
+ {selectedEvents.map(event => ( +
+ {event.coverImage && } +
+

{event.title}

+

{event.city} · {event.organizer}

+
+ +
+ ))} +
+
+
+ )} + + {/* STEP 3: Targeting */} + {step === 3 && ( +
+

Audience Targeting

+

Leave empty for nationwide, all-category targeting.

+ +
+ +
+ {MOCK_CITIES.map(city => ( + toggleCity(city.id)} + > + {selectedCities.includes(city.id) && '✓ '}{city.name} + + ))} +
+
+ +
+ +
+ {MOCK_CATEGORIES.map(cat => ( + toggleCategory(cat.id)} + > + {selectedCategories.includes(cat.id) && '✓ '}{cat.name} + + ))} +
+
+
+ )} + + {/* STEP 4: Budget & Pricing */} + {step === 4 && ( +
+

Budget & Pricing

+ +
+ +
+ {(['FIXED', 'CPM', 'CPC'] as BillingModel[]).map(model => ( + + ))} +
+
+ +
+
+ + setTotalBudget(Number(e.target.value))} + min={100} + /> +
+
+ + setDailyCap(e.target.value ? Number(e.target.value) : null)} + placeholder="No daily limit" + /> +
+
+ +
+
+ + {frequencyCap === 0 ? 'Unlimited' : frequencyCap} +
+ setFrequencyCap(v)} + min={0} + max={20} + step={1} + /> +

Set to 0 for unlimited. Recommended: 3-5 per day.

+
+
+ )} + + {/* STEP 5: Review */} + {step === 5 && ( +
+

Review & Submit

+ +
+
+

Campaign

+
Partner: {partnerName}
+
Name: {campaignName}
+
Objective: {objective}
+
Period: {startAt ? new Date(startAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) : '—'} → {endAt ? new Date(endAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) : '—'}
+
+
+

Budget

+
Model: {billingModel === 'FIXED' ? 'Fixed Fee' : billingModel}
+
Total: ₹{totalBudget.toLocaleString()}
+
Daily Cap: {dailyCap ? `₹${dailyCap.toLocaleString()}` : 'None'}
+
Frequency: {frequencyCap || 'Unlimited'}/user/day
+
+
+ +
+

Placement

+
+ {surfaceKeys.map(sk => ( + {sk.replace(/_/g, ' ')} + ))} +
+
+ {selectedEvents.map(e => ( + {e.title} + ))} +
+
+ +
+

Targeting

+
+ Cities:{' '} + {selectedCities.length > 0 ? selectedCities.map(c => MOCK_CITIES.find(m => m.id === c)?.name).join(', ') : 'All (Nationwide)'} +
+
+ Categories:{' '} + {selectedCategories.length > 0 ? selectedCategories.map(c => MOCK_CATEGORIES.find(m => m.id === c)?.name).join(', ') : 'All'} +
+
+
+ )} +
+ + {/* Navigation */} +
+ + + {step < 5 ? ( + + ) : ( + + )} +
+ + {/* Event Picker */} + e.id)} + /> +
+ ); +} diff --git a/src/features/ad-control/components/sponsored/SponsoredDashboard.tsx b/src/features/ad-control/components/sponsored/SponsoredDashboard.tsx new file mode 100644 index 0000000..7ae3a93 --- /dev/null +++ b/src/features/ad-control/components/sponsored/SponsoredDashboard.tsx @@ -0,0 +1,271 @@ +import { useState, useEffect, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, +} from '@/components/ui/dialog'; +import { Textarea } from '@/components/ui/textarea'; +import { Label } from '@/components/ui/label'; +import { + Plus, Search, MoreHorizontal, BarChart3, Pause, Play, + CheckCircle2, XCircle, Loader2, IndianRupee, Eye, MousePointerClick, + Percent, TrendingUp, Megaphone, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { toast } from 'sonner'; +import { getCampaigns, getSponsoredStats, approveCampaign, rejectCampaign, pauseCampaign, resumeCampaign } from '@/lib/actions/ads'; +import type { CampaignWithEvents, CampaignStatus } from '@/lib/types/ads'; + +const STATUS_CONFIG: Record = { + ACTIVE: { label: 'Active', color: 'bg-emerald-50 text-emerald-700 border-emerald-200', dot: 'bg-emerald-500' }, + IN_REVIEW: { label: 'In Review', color: 'bg-amber-50 text-amber-700 border-amber-200', dot: 'bg-amber-500' }, + PAUSED: { label: 'Paused', color: 'bg-orange-50 text-orange-600 border-orange-200', dot: 'bg-orange-400' }, + DRAFT: { label: 'Draft', color: 'bg-slate-50 text-slate-600 border-slate-200', dot: 'bg-slate-400' }, + ENDED: { label: 'Ended', color: 'bg-zinc-50 text-zinc-500 border-zinc-200', dot: 'bg-zinc-400' }, + REJECTED: { label: 'Rejected', color: 'bg-red-50 text-red-600 border-red-200', dot: 'bg-red-400' }, +}; + +const BILLING_LABELS: Record = { FIXED: 'Fixed Fee', CPM: 'CPM', CPC: 'CPC' }; + +type StatusFilter = 'ALL' | CampaignStatus; + +export function SponsoredDashboard() { + const navigate = useNavigate(); + const [campaigns, setCampaigns] = useState([]); + const [stats, setStats] = useState({ activeCampaigns: 0, todaySpend: 0, impressions24h: 0, clicks24h: 0, ctr24h: 0 }); + const [statusFilter, setStatusFilter] = useState('ALL'); + const [query, setQuery] = useState(''); + const [loading, setLoading] = useState(true); + const [actionLoading, setActionLoading] = useState(null); + const [rejectDialogOpen, setRejectDialogOpen] = useState(false); + const [rejectTarget, setRejectTarget] = useState(null); + const [rejectReason, setRejectReason] = useState(''); + + const load = async () => { + setLoading(true); + const [campRes, statRes] = await Promise.all([getCampaigns(statusFilter === 'ALL' ? undefined : statusFilter), getSponsoredStats()]); + if (campRes.success) setCampaigns(campRes.data); + if (statRes.success) setStats(statRes.data); + setLoading(false); + }; + + useEffect(() => { load(); }, [statusFilter]); + + const filtered = useMemo(() => { + if (!query.trim()) return campaigns; + const q = query.toLowerCase(); + return campaigns.filter(c => + c.name.toLowerCase().includes(q) || + c.partnerName.toLowerCase().includes(q) || + c.id.toLowerCase().includes(q) + ); + }, [campaigns, query]); + + const doAction = async (id: string, action: () => Promise<{ success: boolean; message: string }>, successMsg?: string) => { + setActionLoading(id); + try { + const res = await action(); + res.success ? toast.success(successMsg || res.message) : toast.error(res.message); + await load(); + } catch { toast.error('Action failed'); } + finally { setActionLoading(null); } + }; + + const handleReject = async () => { + if (!rejectTarget || !rejectReason.trim()) { toast.error('Reason is required'); return; } + await doAction(rejectTarget, () => rejectCampaign(rejectTarget, rejectReason)); + setRejectDialogOpen(false); + setRejectTarget(null); + setRejectReason(''); + }; + + const statCards = [ + { label: 'Active Campaigns', value: stats.activeCampaigns, icon: Megaphone, color: 'text-emerald-600', bg: 'bg-emerald-50' }, + { label: 'Spend Today', value: `₹${stats.todaySpend.toLocaleString()}`, icon: IndianRupee, color: 'text-blue-600', bg: 'bg-blue-50' }, + { label: 'Impressions (24h)', value: stats.impressions24h.toLocaleString(), icon: Eye, color: 'text-violet-600', bg: 'bg-violet-50' }, + { label: 'Clicks (24h)', value: stats.clicks24h.toLocaleString(), icon: MousePointerClick, color: 'text-amber-600', bg: 'bg-amber-50' }, + { label: 'CTR (24h)', value: `${(stats.ctr24h * 100).toFixed(2)}%`, icon: Percent, color: 'text-pink-600', bg: 'bg-pink-50' }, + ]; + + return ( +
+ {/* Stat Cards */} +
+ {statCards.map((s) => ( +
+
+ +
+
+

{s.value}

+

{s.label}

+
+
+ ))} +
+ + {/* Toolbar */} +
+
+ + setQuery(e.target.value)} placeholder="Search campaigns..." className="pl-10" /> +
+ setStatusFilter(v as StatusFilter)}> + + All + Active + In Review + Paused + Ended + + + +
+ + {/* Campaign Table */} +
+ + + + + + + + + + + + + + + {loading && ( + + )} + {!loading && filtered.length === 0 && ( + + )} + {!loading && filtered.map(c => { + const statusCfg = STATUS_CONFIG[c.status]; + const spendPct = c.totalBudget > 0 ? Math.min(100, (c.spent / c.totalBudget) * 100) : 0; + const isLoading = actionLoading === c.id; + + return ( + + + + + + + + + + + ); + })} + +
PartnerCampaignStatusModelBudgetSurfacesDatesActions
No campaigns found
+

{c.partnerName}

+
+

{c.name}

+

+ {c.events.map(e => e.title).join(', ') || 'No events'} +

+
+ + + {statusCfg.label} + + + {BILLING_LABELS[c.billingModel]} + +
+

₹{c.totalBudget.toLocaleString()}

+
+
90 ? 'bg-red-500' : spendPct > 60 ? 'bg-amber-500' : 'bg-emerald-500')} + style={{ width: `${spendPct}%` }} + /> +
+

{spendPct.toFixed(0)}% spent

+
+
+
+ {c.surfaceKeys.slice(0, 2).map(sk => ( + + {sk.replace(/_/g, ' ').replace('HOME ', '')} + + ))} + {c.surfaceKeys.length > 2 && ( + +{c.surfaceKeys.length - 2} + )} +
+
+
+
{new Date(c.startAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })}
+
→ {new Date(c.endAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })}
+
+
+ + + + + + navigate(`/ad-control/sponsored/${c.id}/report`)}> + View Report + + {c.status === 'IN_REVIEW' && ( + <> + doAction(c.id, () => approveCampaign(c.id))}> + Approve + + { setRejectTarget(c.id); setRejectDialogOpen(true); }}> + Reject + + + )} + {c.status === 'ACTIVE' && ( + doAction(c.id, () => pauseCampaign(c.id))}> + Pause + + )} + {c.status === 'PAUSED' && ( + doAction(c.id, () => resumeCampaign(c.id))}> + Resume + + )} + + +
+
+ + {/* Reject Dialog */} + + + + Reject Campaign + Provide a reason for rejection. This will be visible to the partner. + +
+ +