feat: implement sponsored ads module end-to-end

This commit is contained in:
CycroftX
2026-02-10 15:44:35 +05:30
parent 3e1641d281
commit 04e2db6571
17 changed files with 2368 additions and 2 deletions

160
prisma/schema.prisma Normal file
View File

@@ -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())
}

View File

@@ -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 = () => (
</ProtectedRoute>
}
/>
<Route
path="/ad-control/sponsored"
element={
<ProtectedRoute>
<SponsoredAds />
</ProtectedRoute>
}
/>
<Route
path="/ad-control/sponsored/new"
element={
<ProtectedRoute>
<NewCampaign />
</ProtectedRoute>
}
/>
<Route
path="/ad-control/sponsored/:id/report"
element={
<ProtectedRoute>
<CampaignReport />
</ProtectedRoute>
}
/>
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} />
</Routes>

View File

@@ -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 },

View File

@@ -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<string, string> = {
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<CampaignReport | null>(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<string, { date: string; impressions: number; clicks: number }> = {};
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 (
<div className="flex items-center justify-center py-32">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (!report) {
return (
<div className="text-center py-20">
<p className="text-muted-foreground">Campaign not found</p>
<Button variant="ghost" onClick={() => navigate('/ad-control/sponsored')} className="mt-4 gap-2">
<ArrowLeft className="h-4 w-4" /> Back
</Button>
</div>
);
}
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 (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" onClick={() => navigate('/ad-control/sponsored')} className="gap-2">
<ArrowLeft className="h-4 w-4" /> Back
</Button>
<div>
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold">{campaign.name}</h2>
<Badge variant="outline" className={cn('text-xs', STATUS_COLORS[campaign.status])}>
{campaign.status}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
{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' })}
</p>
</div>
</div>
<Button variant="outline" onClick={handleExport} disabled={exporting} className="gap-2">
{exporting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
Export CSV
</Button>
</div>
{/* Budget Progress */}
<div className="bg-card border rounded-xl p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">Budget Utilization</span>
<span className="text-sm font-mono">{totals.spend.toLocaleString()} / {campaign.totalBudget.toLocaleString()} ({spendPct.toFixed(1)}%)</span>
</div>
<div className="w-full h-3 bg-muted rounded-full overflow-hidden">
<div
className={cn('h-full rounded-full transition-all', spendPct > 90 ? 'bg-red-500' : spendPct > 60 ? 'bg-amber-500' : 'bg-emerald-500')}
style={{ width: `${Math.min(100, spendPct)}%` }}
/>
</div>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-5 gap-4">
{summaryCards.map(s => (
<div key={s.label} className="rounded-xl border bg-card p-4 flex items-center gap-3">
<div className={cn('h-10 w-10 rounded-lg flex items-center justify-center', s.bg)}>
<s.icon className={cn('h-5 w-5', s.color)} />
</div>
<div>
<p className="text-xl font-bold">{s.value}</p>
<p className="text-xs text-muted-foreground">{s.label}</p>
</div>
</div>
))}
</div>
{/* Chart — CSS Bar Chart */}
<div className="bg-card border rounded-xl p-6">
<h3 className="text-sm font-semibold mb-4">Daily Performance</h3>
<div className="flex items-end gap-1.5 h-48">
{chartData.map((d) => {
const impHeight = (d.impressions / maxImpressions) * 100;
const clkHeight = maxImpressions > 0 ? (d.clicks / maxImpressions) * 100 : 0;
return (
<div key={d.date} className="flex-1 flex flex-col items-center gap-0.5 group relative">
{/* Tooltip */}
<div className="absolute -top-16 left-1/2 -translate-x-1/2 bg-foreground text-background text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-10 pointer-events-none">
{d.date}: {d.impressions} imp, {d.clicks} clk
</div>
<div className="w-full flex items-end gap-0.5 flex-1">
<div
className="flex-1 bg-violet-400/70 rounded-t transition-all hover:bg-violet-500"
style={{ height: `${impHeight}%`, minHeight: d.impressions > 0 ? '2px' : '0' }}
/>
<div
className="flex-1 bg-amber-400/70 rounded-t transition-all hover:bg-amber-500"
style={{ height: `${clkHeight}%`, minHeight: d.clicks > 0 ? '2px' : '0' }}
/>
</div>
<span className="text-[9px] text-muted-foreground mt-1">{new Date(d.date + 'T00:00:00').toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })}</span>
</div>
);
})}
</div>
<div className="flex items-center gap-4 mt-3 justify-center">
<div className="flex items-center gap-1.5"><div className="h-2.5 w-2.5 rounded-sm bg-violet-400" /><span className="text-xs text-muted-foreground">Impressions</span></div>
<div className="flex items-center gap-1.5"><div className="h-2.5 w-2.5 rounded-sm bg-amber-400" /><span className="text-xs text-muted-foreground">Clicks</span></div>
</div>
</div>
{/* Surface Breakdown Table */}
<div className="bg-card border rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b bg-muted/30">
<h3 className="text-sm font-semibold">Performance by Surface</h3>
</div>
<table className="w-full">
<thead>
<tr className="border-b text-left">
<th className="px-4 py-2.5 text-xs font-semibold text-muted-foreground">Surface</th>
<th className="px-4 py-2.5 text-xs font-semibold text-muted-foreground text-right">Impressions</th>
<th className="px-4 py-2.5 text-xs font-semibold text-muted-foreground text-right">Clicks</th>
<th className="px-4 py-2.5 text-xs font-semibold text-muted-foreground text-right">CTR</th>
<th className="px-4 py-2.5 text-xs font-semibold text-muted-foreground text-right">Spend</th>
</tr>
</thead>
<tbody>
{bySurface.map(s => (
<tr key={s.surfaceKey} className="border-b last:border-0 hover:bg-muted/20">
<td className="px-4 py-3 text-sm font-medium">{s.surfaceName}</td>
<td className="px-4 py-3 text-sm text-right font-mono">{s.impressions.toLocaleString()}</td>
<td className="px-4 py-3 text-sm text-right font-mono">{s.clicks.toLocaleString()}</td>
<td className="px-4 py-3 text-sm text-right font-mono">{(s.ctr * 100).toFixed(2)}%</td>
<td className="px-4 py-3 text-sm text-right font-mono">{s.spend.toLocaleString()}</td>
</tr>
))}
<tr className="bg-muted/30 font-semibold">
<td className="px-4 py-3 text-sm">Total</td>
<td className="px-4 py-3 text-sm text-right font-mono">{totals.impressions.toLocaleString()}</td>
<td className="px-4 py-3 text-sm text-right font-mono">{totals.clicks.toLocaleString()}</td>
<td className="px-4 py-3 text-sm text-right font-mono">{(totals.ctr * 100).toFixed(2)}%</td>
<td className="px-4 py-3 text-sm text-right font-mono">{totals.spend.toLocaleString()}</td>
</tr>
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -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<CampaignObjective>('AWARENESS');
const [startAt, setStartAt] = useState('');
const [endAt, setEndAt] = useState('');
// Step 2: Placement
const [surfaceKeys, setSurfaceKeys] = useState<SurfaceKey[]>([]);
const [selectedEvents, setSelectedEvents] = useState<PickerEvent[]>([]);
// Step 3: Targeting
const [selectedCities, setSelectedCities] = useState<string[]>([]);
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
// Step 4: Budget
const [billingModel, setBillingModel] = useState<BillingModel>('CPM');
const [totalBudget, setTotalBudget] = useState(10000);
const [dailyCap, setDailyCap] = useState<number | null>(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 (
<div className="max-w-3xl mx-auto">
{/* Step Indicator */}
<div className="flex items-center justify-between mb-8">
{STEPS.map((s, i) => (
<div key={s.id} className="flex items-center">
<button
onClick={() => s.id <= step && setStep(s.id)}
className={cn(
'flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-all',
step === s.id ? 'bg-primary text-primary-foreground' :
step > s.id ? 'bg-emerald-50 text-emerald-700' : 'bg-muted text-muted-foreground',
)}
>
{step > s.id ? <Check className="h-4 w-4" /> : <s.icon className="h-4 w-4" />}
{s.title}
</button>
{i < STEPS.length - 1 && (
<div className={cn('w-8 h-0.5 mx-1', step > s.id ? 'bg-emerald-400' : 'bg-muted')} />
)}
</div>
))}
</div>
{/* Step Content */}
<div className="bg-card border rounded-xl p-6 min-h-[400px]">
{/* STEP 1: Basics */}
{step === 1 && (
<div className="space-y-5">
<h3 className="text-lg font-bold">Campaign Basics</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs">Partner / Organizer Name</Label>
<Input value={partnerName} onChange={e => setPartnerName(e.target.value)} placeholder="e.g. SoundWave Productions" />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Objective</Label>
<Select value={objective} onValueChange={v => setObjective(v as CampaignObjective)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="AWARENESS">Awareness Maximize reach</SelectItem>
<SelectItem value="SALES">Sales Drive ticket purchases</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Campaign Name</Label>
<Input value={campaignName} onChange={e => setCampaignName(e.target.value)} placeholder="e.g. Mumbai Music Festival Premium Push" />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs">Start Date</Label>
<Input type="datetime-local" value={startAt} onChange={e => setStartAt(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label className="text-xs">End Date</Label>
<Input type="datetime-local" value={endAt} onChange={e => setEndAt(e.target.value)} />
</div>
</div>
</div>
)}
{/* STEP 2: Placement */}
{step === 2 && (
<div className="space-y-5">
<h3 className="text-lg font-bold">Placement Surfaces & Events</h3>
<div className="space-y-3">
<Label className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">Surfaces</Label>
<div className="grid grid-cols-2 gap-2">
{SURFACE_OPTIONS.map(s => (
<label
key={s.key}
className={cn(
'flex items-center gap-3 rounded-lg border p-3 cursor-pointer transition-all',
surfaceKeys.includes(s.key) ? 'border-primary bg-primary/5' : 'hover:bg-muted/30',
)}
>
<Checkbox
checked={surfaceKeys.includes(s.key)}
onCheckedChange={() => toggleSurface(s.key)}
/>
<span className="text-sm">{s.label}</span>
</label>
))}
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">Events</Label>
<Button variant="outline" size="sm" onClick={() => setPickerOpen(true)} className="gap-1">
<Sparkles className="h-3.5 w-3.5" /> Add Event
</Button>
</div>
{selectedEvents.length === 0 && (
<p className="text-sm text-muted-foreground italic">No events selected. Click "Add Event" to begin.</p>
)}
<div className="space-y-2">
{selectedEvents.map(event => (
<div key={event.id} className="flex items-center gap-3 rounded-lg border p-2">
{event.coverImage && <img src={event.coverImage} alt="" className="h-10 w-16 rounded object-cover" />}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{event.title}</p>
<p className="text-xs text-muted-foreground">{event.city} · {event.organizer}</p>
</div>
<Button variant="ghost" size="sm" onClick={() => removeEvent(event.id)} className="text-red-500 hover:text-red-700 text-xs">Remove</Button>
</div>
))}
</div>
</div>
</div>
)}
{/* STEP 3: Targeting */}
{step === 3 && (
<div className="space-y-5">
<h3 className="text-lg font-bold">Audience Targeting</h3>
<p className="text-sm text-muted-foreground">Leave empty for nationwide, all-category targeting.</p>
<div className="space-y-3">
<Label className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">Cities</Label>
<div className="flex flex-wrap gap-1.5">
{MOCK_CITIES.map(city => (
<Badge
key={city.id}
variant="outline"
className={cn('cursor-pointer text-xs py-1 px-2 transition-all', selectedCities.includes(city.id) && 'bg-primary text-primary-foreground border-primary')}
onClick={() => toggleCity(city.id)}
>
{selectedCities.includes(city.id) && '✓ '}{city.name}
</Badge>
))}
</div>
</div>
<div className="space-y-3">
<Label className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">Categories</Label>
<div className="flex flex-wrap gap-1.5">
{MOCK_CATEGORIES.map(cat => (
<Badge
key={cat.id}
variant="outline"
className={cn('cursor-pointer text-xs py-1 px-2 transition-all', selectedCategories.includes(cat.id) && 'bg-primary text-primary-foreground border-primary')}
onClick={() => toggleCategory(cat.id)}
>
{selectedCategories.includes(cat.id) && '✓ '}{cat.name}
</Badge>
))}
</div>
</div>
</div>
)}
{/* STEP 4: Budget & Pricing */}
{step === 4 && (
<div className="space-y-5">
<h3 className="text-lg font-bold">Budget & Pricing</h3>
<div className="space-y-3">
<Label className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">Billing Model</Label>
<div className="grid grid-cols-3 gap-3">
{(['FIXED', 'CPM', 'CPC'] as BillingModel[]).map(model => (
<button
key={model}
onClick={() => setBillingModel(model)}
className={cn(
'rounded-xl border p-4 text-left transition-all',
billingModel === model ? 'border-primary bg-primary/5 ring-1 ring-primary' : 'hover:bg-muted/30',
)}
>
<p className="font-semibold text-sm">{model === 'FIXED' ? 'Fixed Fee' : model}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{model === 'FIXED' ? 'Flat amount for date range' :
model === 'CPM' ? 'Cost per 1,000 impressions' :
'Cost per click'}
</p>
</button>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs">Total Budget ()</Label>
<Input
type="number"
value={totalBudget}
onChange={e => setTotalBudget(Number(e.target.value))}
min={100}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Daily Cap (, optional)</Label>
<Input
type="number"
value={dailyCap ?? ''}
onChange={e => setDailyCap(e.target.value ? Number(e.target.value) : null)}
placeholder="No daily limit"
/>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs">Frequency Cap (impressions / user / day)</Label>
<span className="text-sm font-mono font-bold">{frequencyCap === 0 ? 'Unlimited' : frequencyCap}</span>
</div>
<Slider
value={[frequencyCap]}
onValueChange={([v]) => setFrequencyCap(v)}
min={0}
max={20}
step={1}
/>
<p className="text-[11px] text-muted-foreground">Set to 0 for unlimited. Recommended: 3-5 per day.</p>
</div>
</div>
)}
{/* STEP 5: Review */}
{step === 5 && (
<div className="space-y-5">
<h3 className="text-lg font-bold">Review & Submit</h3>
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="space-y-3 p-4 bg-muted/20 rounded-xl">
<h4 className="font-semibold text-xs uppercase tracking-wider text-muted-foreground">Campaign</h4>
<div><span className="text-muted-foreground">Partner:</span> <strong>{partnerName}</strong></div>
<div><span className="text-muted-foreground">Name:</span> <strong>{campaignName}</strong></div>
<div><span className="text-muted-foreground">Objective:</span> <strong>{objective}</strong></div>
<div><span className="text-muted-foreground">Period:</span> <strong>{startAt ? new Date(startAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) : '—'} {endAt ? new Date(endAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) : '—'}</strong></div>
</div>
<div className="space-y-3 p-4 bg-muted/20 rounded-xl">
<h4 className="font-semibold text-xs uppercase tracking-wider text-muted-foreground">Budget</h4>
<div><span className="text-muted-foreground">Model:</span> <strong>{billingModel === 'FIXED' ? 'Fixed Fee' : billingModel}</strong></div>
<div><span className="text-muted-foreground">Total:</span> <strong>{totalBudget.toLocaleString()}</strong></div>
<div><span className="text-muted-foreground">Daily Cap:</span> <strong>{dailyCap ? `${dailyCap.toLocaleString()}` : 'None'}</strong></div>
<div><span className="text-muted-foreground">Frequency:</span> <strong>{frequencyCap || 'Unlimited'}/user/day</strong></div>
</div>
</div>
<div className="p-4 bg-muted/20 rounded-xl space-y-2">
<h4 className="font-semibold text-xs uppercase tracking-wider text-muted-foreground">Placement</h4>
<div className="flex flex-wrap gap-1.5">
{surfaceKeys.map(sk => (
<Badge key={sk} variant="secondary" className="text-xs">{sk.replace(/_/g, ' ')}</Badge>
))}
</div>
<div className="flex flex-wrap gap-1.5 mt-2">
{selectedEvents.map(e => (
<Badge key={e.id} variant="outline" className="text-xs">{e.title}</Badge>
))}
</div>
</div>
<div className="p-4 bg-muted/20 rounded-xl space-y-2">
<h4 className="font-semibold text-xs uppercase tracking-wider text-muted-foreground">Targeting</h4>
<div className="text-sm">
<span className="text-muted-foreground">Cities:</span>{' '}
{selectedCities.length > 0 ? selectedCities.map(c => MOCK_CITIES.find(m => m.id === c)?.name).join(', ') : 'All (Nationwide)'}
</div>
<div className="text-sm">
<span className="text-muted-foreground">Categories:</span>{' '}
{selectedCategories.length > 0 ? selectedCategories.map(c => MOCK_CATEGORIES.find(m => m.id === c)?.name).join(', ') : 'All'}
</div>
</div>
</div>
)}
</div>
{/* Navigation */}
<div className="flex items-center justify-between mt-6">
<Button
variant="ghost"
onClick={() => step === 1 ? navigate('/ad-control/sponsored') : setStep(step - 1)}
className="gap-2"
>
<ArrowLeft className="h-4 w-4" />
{step === 1 ? 'Cancel' : 'Back'}
</Button>
{step < 5 ? (
<Button onClick={() => setStep(step + 1)} disabled={!stepValid(step)} className="gap-2">
Next <ArrowRight className="h-4 w-4" />
</Button>
) : (
<Button onClick={handleSubmit} disabled={loading} className="gap-2 bg-emerald-600 hover:bg-emerald-700">
{loading ? <><Loader2 className="h-4 w-4 animate-spin" /> Submitting...</> : <><Send className="h-4 w-4" /> Submit for Approval</>}
</Button>
)}
</div>
{/* Event Picker */}
<EventPickerModal
open={pickerOpen}
onOpenChange={setPickerOpen}
events={MOCK_PICKER_EVENTS}
onSelectEvent={handleEventSelected}
alreadyPlacedEventIds={selectedEvents.map(e => e.id)}
/>
</div>
);
}

View File

@@ -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<CampaignStatus, { label: string; color: string; dot: string }> = {
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<string, string> = { FIXED: 'Fixed Fee', CPM: 'CPM', CPC: 'CPC' };
type StatusFilter = 'ALL' | CampaignStatus;
export function SponsoredDashboard() {
const navigate = useNavigate();
const [campaigns, setCampaigns] = useState<CampaignWithEvents[]>([]);
const [stats, setStats] = useState({ activeCampaigns: 0, todaySpend: 0, impressions24h: 0, clicks24h: 0, ctr24h: 0 });
const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');
const [query, setQuery] = useState('');
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [rejectDialogOpen, setRejectDialogOpen] = useState(false);
const [rejectTarget, setRejectTarget] = useState<string | null>(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 (
<div className="space-y-6">
{/* Stat Cards */}
<div className="grid grid-cols-5 gap-4">
{statCards.map((s) => (
<div key={s.label} className="rounded-xl border bg-card p-4 flex items-center gap-3">
<div className={cn('h-10 w-10 rounded-lg flex items-center justify-center', s.bg)}>
<s.icon className={cn('h-5 w-5', s.color)} />
</div>
<div>
<p className="text-2xl font-bold">{s.value}</p>
<p className="text-xs text-muted-foreground">{s.label}</p>
</div>
</div>
))}
</div>
{/* Toolbar */}
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input value={query} onChange={e => setQuery(e.target.value)} placeholder="Search campaigns..." className="pl-10" />
</div>
<Tabs value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
<TabsList className="h-9">
<TabsTrigger value="ALL" className="text-xs px-3">All</TabsTrigger>
<TabsTrigger value="ACTIVE" className="text-xs px-3">Active</TabsTrigger>
<TabsTrigger value="IN_REVIEW" className="text-xs px-3">In Review</TabsTrigger>
<TabsTrigger value="PAUSED" className="text-xs px-3">Paused</TabsTrigger>
<TabsTrigger value="ENDED" className="text-xs px-3">Ended</TabsTrigger>
</TabsList>
</Tabs>
<Button onClick={() => navigate('/ad-control/sponsored/new')} className="gap-2 ml-auto">
<Plus className="h-4 w-4" /> New Campaign
</Button>
</div>
{/* Campaign Table */}
<div className="border rounded-xl overflow-hidden">
<table className="w-full">
<thead>
<tr className="bg-muted/30 border-b text-left">
<th className="px-4 py-3 text-xs font-semibold text-muted-foreground">Partner</th>
<th className="px-4 py-3 text-xs font-semibold text-muted-foreground">Campaign</th>
<th className="px-4 py-3 text-xs font-semibold text-muted-foreground">Status</th>
<th className="px-4 py-3 text-xs font-semibold text-muted-foreground">Model</th>
<th className="px-4 py-3 text-xs font-semibold text-muted-foreground">Budget</th>
<th className="px-4 py-3 text-xs font-semibold text-muted-foreground">Surfaces</th>
<th className="px-4 py-3 text-xs font-semibold text-muted-foreground">Dates</th>
<th className="px-4 py-3 text-xs font-semibold text-muted-foreground text-right">Actions</th>
</tr>
</thead>
<tbody>
{loading && (
<tr><td colSpan={8} className="text-center py-12"><Loader2 className="h-6 w-6 animate-spin mx-auto text-muted-foreground" /></td></tr>
)}
{!loading && filtered.length === 0 && (
<tr><td colSpan={8} className="text-center py-12 text-muted-foreground">No campaigns found</td></tr>
)}
{!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 (
<tr key={c.id} className={cn('border-b hover:bg-muted/20 transition-colors', isLoading && 'opacity-50')}>
<td className="px-4 py-3">
<p className="text-sm font-medium">{c.partnerName}</p>
</td>
<td className="px-4 py-3">
<p className="text-sm font-semibold">{c.name}</p>
<p className="text-xs text-muted-foreground">
{c.events.map(e => e.title).join(', ') || 'No events'}
</p>
</td>
<td className="px-4 py-3">
<Badge variant="outline" className={cn('text-[10px] gap-1', statusCfg.color)}>
<span className={cn('h-1.5 w-1.5 rounded-full', statusCfg.dot)} />
{statusCfg.label}
</Badge>
</td>
<td className="px-4 py-3">
<span className="text-xs text-muted-foreground">{BILLING_LABELS[c.billingModel]}</span>
</td>
<td className="px-4 py-3">
<div className="space-y-1">
<p className="text-sm font-mono">{c.totalBudget.toLocaleString()}</p>
<div className="w-20 h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn('h-full rounded-full transition-all', spendPct > 90 ? 'bg-red-500' : spendPct > 60 ? 'bg-amber-500' : 'bg-emerald-500')}
style={{ width: `${spendPct}%` }}
/>
</div>
<p className="text-[10px] text-muted-foreground">{spendPct.toFixed(0)}% spent</p>
</div>
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{c.surfaceKeys.slice(0, 2).map(sk => (
<Badge key={sk} variant="secondary" className="text-[9px] py-0 px-1.5">
{sk.replace(/_/g, ' ').replace('HOME ', '')}
</Badge>
))}
{c.surfaceKeys.length > 2 && (
<Badge variant="secondary" className="text-[9px] py-0 px-1.5">+{c.surfaceKeys.length - 2}</Badge>
)}
</div>
</td>
<td className="px-4 py-3">
<div className="text-xs text-muted-foreground leading-tight">
<div>{new Date(c.startAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })}</div>
<div> {new Date(c.endAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })}</div>
</div>
</td>
<td className="px-4 py-3 text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={() => navigate(`/ad-control/sponsored/${c.id}/report`)}>
<BarChart3 className="mr-2 h-4 w-4" /> View Report
</DropdownMenuItem>
{c.status === 'IN_REVIEW' && (
<>
<DropdownMenuItem onClick={() => doAction(c.id, () => approveCampaign(c.id))}>
<CheckCircle2 className="mr-2 h-4 w-4 text-emerald-600" /> Approve
</DropdownMenuItem>
<DropdownMenuItem onClick={() => { setRejectTarget(c.id); setRejectDialogOpen(true); }}>
<XCircle className="mr-2 h-4 w-4 text-red-600" /> Reject
</DropdownMenuItem>
</>
)}
{c.status === 'ACTIVE' && (
<DropdownMenuItem onClick={() => doAction(c.id, () => pauseCampaign(c.id))}>
<Pause className="mr-2 h-4 w-4 text-orange-600" /> Pause
</DropdownMenuItem>
)}
{c.status === 'PAUSED' && (
<DropdownMenuItem onClick={() => doAction(c.id, () => resumeCampaign(c.id))}>
<Play className="mr-2 h-4 w-4 text-emerald-600" /> Resume
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Reject Dialog */}
<Dialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reject Campaign</DialogTitle>
<DialogDescription>Provide a reason for rejection. This will be visible to the partner.</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label>Reason</Label>
<Textarea value={rejectReason} onChange={e => setRejectReason(e.target.value)} placeholder="e.g. Event not approved, budget too low..." rows={3} />
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setRejectDialogOpen(false)}>Cancel</Button>
<Button variant="destructive" onClick={handleReject} disabled={!rejectReason.trim()}>Reject Campaign</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,193 @@
// Sponsored Ads — Mock Data: Campaigns, Tracking Events, Daily Stats
import type { Campaign, AdTrackingEvent, PlacementDailyStats } from '@/lib/types/ads';
// ===== MOCK CAMPAIGNS =====
export const MOCK_CAMPAIGNS: Campaign[] = [
{
id: 'camp-001',
partnerId: 'partner-sw',
partnerName: 'SoundWave Productions',
name: 'Mumbai Music Festival Premium Push',
objective: 'AWARENESS',
status: 'ACTIVE',
startAt: '2026-02-01T00:00:00Z',
endAt: '2026-03-15T23:59:59Z',
billingModel: 'CPM',
totalBudget: 50000,
dailyCap: 2500,
spent: 18750,
targeting: { cityIds: ['mumbai', 'pune'], categoryIds: ['music'], countryCodes: ['IN'] },
surfaceKeys: ['HOME_FEATURED_CAROUSEL', 'CITY_TRENDING'],
eventIds: ['evt-101'],
frequencyCap: 5,
approvedBy: 'admin-1',
rejectedReason: null,
createdBy: 'admin-1',
createdAt: '2026-01-25T10:00:00Z',
updatedAt: '2026-02-10T08:00:00Z',
},
{
id: 'camp-002',
partnerId: 'partner-sb',
partnerName: 'Sunburn Events',
name: 'Goa Sunburn Early Bird Blitz',
objective: 'SALES',
status: 'IN_REVIEW',
startAt: '2026-03-01T00:00:00Z',
endAt: '2026-04-25T23:59:59Z',
billingModel: 'CPC',
totalBudget: 75000,
dailyCap: 5000,
spent: 0,
targeting: { cityIds: [], categoryIds: ['music', 'nightlife'], countryCodes: ['IN'] },
surfaceKeys: ['HOME_FEATURED_CAROUSEL', 'HOME_TOP_EVENTS', 'SEARCH_BOOSTED'],
eventIds: ['evt-107'],
frequencyCap: 3,
approvedBy: null,
rejectedReason: null,
createdBy: 'admin-1',
createdAt: '2026-02-08T14:00:00Z',
updatedAt: '2026-02-08T14:00:00Z',
},
{
id: 'camp-003',
partnerId: 'partner-tc',
partnerName: 'TechConf India',
name: 'Delhi Tech Summit Sponsor Package',
objective: 'AWARENESS',
status: 'DRAFT',
startAt: '2026-03-10T00:00:00Z',
endAt: '2026-03-21T23:59:59Z',
billingModel: 'FIXED',
totalBudget: 30000,
dailyCap: null,
spent: 0,
targeting: { cityIds: ['delhi'], categoryIds: ['technology', 'business'], countryCodes: ['IN'] },
surfaceKeys: ['HOME_TOP_EVENTS', 'CATEGORY_FEATURED'],
eventIds: ['evt-102'],
frequencyCap: 0,
approvedBy: null,
rejectedReason: null,
createdBy: 'admin-1',
createdAt: '2026-02-09T11:00:00Z',
updatedAt: '2026-02-09T11:00:00Z',
},
{
id: 'camp-004',
partnerId: 'partner-ri',
partnerName: 'RunIndia',
name: 'Pune Marathon Registration Drive',
objective: 'SALES',
status: 'ENDED',
startAt: '2026-01-15T00:00:00Z',
endAt: '2026-02-05T23:59:59Z',
billingModel: 'CPM',
totalBudget: 20000,
dailyCap: 1500,
spent: 19800,
targeting: { cityIds: ['pune', 'mumbai'], categoryIds: ['sports'], countryCodes: ['IN'] },
surfaceKeys: ['HOME_TOP_EVENTS', 'CITY_TRENDING'],
eventIds: ['evt-106'],
frequencyCap: 4,
approvedBy: 'admin-1',
rejectedReason: null,
createdBy: 'admin-1',
createdAt: '2026-01-10T09:00:00Z',
updatedAt: '2026-02-05T23:59:59Z',
},
];
// ===== MOCK TRACKING EVENTS (for camp-001) =====
function genTrackingEvents(): AdTrackingEvent[] {
const events: AdTrackingEvent[] = [];
const surfaces = ['HOME_FEATURED_CAROUSEL', 'CITY_TRENDING'] as const;
const devices = ['mobile-ios', 'mobile-android', 'web-desktop', 'web-mobile'];
const cities = ['mumbai', 'pune'];
const baseTime = new Date('2026-02-03T00:00:00Z');
for (let day = 0; day < 7; day++) {
const impressionsPerDay = 80 + Math.floor(Math.random() * 40);
for (let i = 0; i < impressionsPerDay; i++) {
const ts = new Date(baseTime.getTime() + day * 86400000 + Math.floor(Math.random() * 86400000));
const surface = surfaces[Math.floor(Math.random() * surfaces.length)];
events.push({
id: `te-imp-${day}-${i}`,
type: 'IMPRESSION',
placementId: `splc-camp001-${surface}`,
campaignId: 'camp-001',
surfaceKey: surface,
eventId: 'evt-101',
userId: Math.random() > 0.4 ? `user-${100 + Math.floor(Math.random() * 50)}` : null,
anonId: `anon-${Math.floor(Math.random() * 200)}`,
sessionId: `sess-${day}-${Math.floor(Math.random() * 100)}`,
timestamp: ts.toISOString(),
device: devices[Math.floor(Math.random() * devices.length)],
cityId: cities[Math.floor(Math.random() * cities.length)],
});
}
// Clicks (~8-15% of impressions)
const clicksPerDay = Math.floor(impressionsPerDay * (0.08 + Math.random() * 0.07));
for (let c = 0; c < clicksPerDay; c++) {
const ts = new Date(baseTime.getTime() + day * 86400000 + Math.floor(Math.random() * 86400000));
const surface = surfaces[Math.floor(Math.random() * surfaces.length)];
events.push({
id: `te-clk-${day}-${c}`,
type: 'CLICK',
placementId: `splc-camp001-${surface}`,
campaignId: 'camp-001',
surfaceKey: surface,
eventId: 'evt-101',
userId: Math.random() > 0.3 ? `user-${100 + Math.floor(Math.random() * 50)}` : null,
anonId: `anon-${Math.floor(Math.random() * 200)}`,
sessionId: `sess-${day}-${Math.floor(Math.random() * 100)}`,
timestamp: ts.toISOString(),
device: devices[Math.floor(Math.random() * devices.length)],
cityId: cities[Math.floor(Math.random() * cities.length)],
});
}
}
return events;
}
export const MOCK_TRACKING_EVENTS = genTrackingEvents();
// ===== MOCK DAILY STATS =====
export function generateMockDailyStats(): PlacementDailyStats[] {
const stats: PlacementDailyStats[] = [];
const baseDate = new Date('2026-02-03');
const cpmRate = 50000 / (7 * 100); // simplified: budget / (days * avg impressions per batch)
for (let day = 0; day < 7; day++) {
const date = new Date(baseDate.getTime() + day * 86400000).toISOString().slice(0, 10);
const surfaces = ['HOME_FEATURED_CAROUSEL', 'CITY_TRENDING'] as const;
for (const surface of surfaces) {
const impressions = 40 + Math.floor(Math.random() * 25);
const clicks = Math.floor(impressions * (0.08 + Math.random() * 0.07));
const ctr = impressions > 0 ? Number((clicks / impressions).toFixed(4)) : 0;
const spend = Number(((impressions / 1000) * 250).toFixed(2)); // ₹250 CPM
stats.push({
id: `ds-${day}-${surface}`,
campaignId: 'camp-001',
placementId: `splc-camp001-${surface}`,
surfaceKey: surface,
date,
impressions,
clicks,
ctr,
spend,
});
}
}
return stats;
}
export const MOCK_DAILY_STATS = generateMockDailyStats();

388
src/lib/actions/ads.ts Normal file
View File

@@ -0,0 +1,388 @@
'use server';
// Sponsored Ads — Server Actions (localStorage-persisted mock)
import type {
Campaign, CampaignWithEvents, CampaignFormData,
CampaignReport, CampaignStatus, PlacementDailyStats,
} from '@/lib/types/ads';
import type { SurfaceKey } from '@/lib/types/ad-control';
import { MOCK_CAMPAIGNS, MOCK_DAILY_STATS, MOCK_TRACKING_EVENTS } from '@/features/ad-control/data/mockAdsData';
import { MOCK_PICKER_EVENTS, MOCK_SURFACES } from '@/features/ad-control/data/mockAdData';
import { computeDailyStats, getTrackingEvents, recordImpression, recordClick } from '@/lib/ads/tracking';
const CAMPAIGNS_KEY = 'ad_campaigns';
const CAMPAIGN_AUDIT_KEY = 'ad_campaign_audit';
// ===== Persistence =====
function getCampaignStore(): Campaign[] {
if (typeof window === 'undefined') return MOCK_CAMPAIGNS;
try {
const raw = localStorage.getItem(CAMPAIGNS_KEY);
return raw ? JSON.parse(raw) : MOCK_CAMPAIGNS;
} catch { return MOCK_CAMPAIGNS; }
}
function saveCampaignStore(campaigns: Campaign[]) {
if (typeof window !== 'undefined') {
localStorage.setItem(CAMPAIGNS_KEY, JSON.stringify(campaigns));
}
}
function logCampaignAudit(campaignId: string, action: string, details?: Record<string, any>) {
if (typeof window === 'undefined') return;
try {
const entries = JSON.parse(localStorage.getItem(CAMPAIGN_AUDIT_KEY) || '[]');
entries.push({
id: `ca-${Date.now()}`,
campaignId,
actorId: 'admin-1',
action,
details: details || null,
createdAt: new Date().toISOString(),
});
localStorage.setItem(CAMPAIGN_AUDIT_KEY, JSON.stringify(entries.slice(-500)));
} catch { }
}
// ===== Resolve Events =====
function resolveEvents(campaign: Campaign): CampaignWithEvents {
const events = MOCK_PICKER_EVENTS.filter(e => campaign.eventIds.includes(e.id));
return { ...campaign, events };
}
// ===== Auto-end expired campaigns =====
function autoEndCampaigns(campaigns: Campaign[]): Campaign[] {
const now = new Date().toISOString();
return campaigns.map(c => {
if (c.status === 'ACTIVE' && (c.endAt < now || c.spent >= c.totalBudget)) {
return { ...c, status: 'ENDED' as CampaignStatus, updatedAt: now };
}
return c;
});
}
// ===== QUERIES =====
export async function getCampaigns(
status?: CampaignStatus | 'ALL',
): Promise<{ success: boolean; data: CampaignWithEvents[] }> {
let campaigns = autoEndCampaigns(getCampaignStore());
saveCampaignStore(campaigns);
if (status && status !== 'ALL') {
campaigns = campaigns.filter(c => c.status === status);
}
// Sort: IN_REVIEW first, then ACTIVE, then by updatedAt desc
const statusOrder: Record<string, number> = { IN_REVIEW: 0, ACTIVE: 1, PAUSED: 2, DRAFT: 3, ENDED: 4, REJECTED: 5 };
campaigns.sort((a, b) => (statusOrder[a.status] ?? 9) - (statusOrder[b.status] ?? 9) || b.updatedAt.localeCompare(a.updatedAt));
return { success: true, data: campaigns.map(resolveEvents) };
}
export async function getCampaign(id: string): Promise<{ success: boolean; data?: CampaignWithEvents; message: string }> {
const campaigns = getCampaignStore();
const campaign = campaigns.find(c => c.id === id);
if (!campaign) return { success: false, message: 'Campaign not found' };
return { success: true, data: resolveEvents(campaign), message: 'OK' };
}
// ===== MUTATIONS =====
export async function createCampaign(
data: CampaignFormData,
): Promise<{ success: boolean; message: string; data?: Campaign }> {
const campaigns = getCampaignStore();
const now = new Date().toISOString();
const id = `camp-${Date.now().toString(36)}`;
const newCampaign: Campaign = {
id,
partnerId: `partner-${data.partnerName.toLowerCase().replace(/\s+/g, '-').slice(0, 10)}`,
partnerName: data.partnerName,
name: data.name,
objective: data.objective,
status: 'DRAFT',
startAt: data.startAt,
endAt: data.endAt,
billingModel: data.billingModel,
totalBudget: data.totalBudget,
dailyCap: data.dailyCap,
spent: 0,
targeting: data.targeting,
surfaceKeys: data.surfaceKeys,
eventIds: data.eventIds,
frequencyCap: data.frequencyCap,
approvedBy: null,
rejectedReason: null,
createdBy: 'admin-1',
createdAt: now,
updatedAt: now,
};
campaigns.push(newCampaign);
saveCampaignStore(campaigns);
logCampaignAudit(id, 'CREATED', { name: data.name });
return { success: true, message: 'Campaign created as draft', data: newCampaign };
}
export async function updateCampaign(
id: string,
patch: Partial<CampaignFormData>,
): Promise<{ success: boolean; message: string }> {
const campaigns = getCampaignStore();
const idx = campaigns.findIndex(c => c.id === id);
if (idx === -1) return { success: false, message: 'Campaign not found' };
const campaign = campaigns[idx];
if (campaign.status !== 'DRAFT' && campaign.status !== 'PAUSED') {
return { success: false, message: 'Can only edit draft or paused campaigns' };
}
const updated: Campaign = {
...campaign,
...patch,
updatedAt: new Date().toISOString(),
} as Campaign;
campaigns[idx] = updated;
saveCampaignStore(campaigns);
logCampaignAudit(id, 'UPDATED', patch);
return { success: true, message: 'Campaign updated' };
}
export async function submitCampaign(
id: string,
): Promise<{ success: boolean; message: string }> {
const campaigns = getCampaignStore();
const idx = campaigns.findIndex(c => c.id === id);
if (idx === -1) return { success: false, message: 'Campaign not found' };
if (campaigns[idx].status !== 'DRAFT') return { success: false, message: 'Only draft campaigns can be submitted' };
// Validation checks
const c = campaigns[idx];
if (c.eventIds.length === 0) return { success: false, message: 'At least one event is required' };
if (c.surfaceKeys.length === 0) return { success: false, message: 'At least one surface is required' };
if (c.totalBudget <= 0) return { success: false, message: 'Budget must be positive' };
if (!c.startAt || !c.endAt) return { success: false, message: 'Schedule dates are required' };
campaigns[idx] = { ...c, status: 'IN_REVIEW', updatedAt: new Date().toISOString() };
saveCampaignStore(campaigns);
logCampaignAudit(id, 'SUBMITTED');
return { success: true, message: 'Campaign submitted for review' };
}
export async function approveCampaign(
id: string,
): Promise<{ success: boolean; message: string }> {
const campaigns = getCampaignStore();
const idx = campaigns.findIndex(c => c.id === id);
if (idx === -1) return { success: false, message: 'Campaign not found' };
if (campaigns[idx].status !== 'IN_REVIEW') return { success: false, message: 'Campaign is not in review' };
// Eligibility checks
const c = campaigns[idx];
for (const eventId of c.eventIds) {
const event = MOCK_PICKER_EVENTS.find(e => e.id === eventId);
if (!event) return { success: false, message: `Event ${eventId} not found` };
if (event.approvalStatus !== 'APPROVED') return { success: false, message: `Event "${event.title}" is not approved` };
if (new Date(event.endDate) < new Date()) return { success: false, message: `Event "${event.title}" has ended` };
}
campaigns[idx] = { ...c, status: 'ACTIVE', approvedBy: 'admin-1', updatedAt: new Date().toISOString() };
saveCampaignStore(campaigns);
logCampaignAudit(id, 'APPROVED');
return { success: true, message: 'Campaign approved and activated' };
}
export async function rejectCampaign(
id: string,
reason: string,
): Promise<{ success: boolean; message: string }> {
const campaigns = getCampaignStore();
const idx = campaigns.findIndex(c => c.id === id);
if (idx === -1) return { success: false, message: 'Campaign not found' };
if (campaigns[idx].status !== 'IN_REVIEW') return { success: false, message: 'Campaign is not in review' };
campaigns[idx] = { ...campaigns[idx], status: 'REJECTED', rejectedReason: reason, updatedAt: new Date().toISOString() };
saveCampaignStore(campaigns);
logCampaignAudit(id, 'REJECTED', { reason });
return { success: true, message: 'Campaign rejected' };
}
export async function pauseCampaign(
id: string,
): Promise<{ success: boolean; message: string }> {
const campaigns = getCampaignStore();
const idx = campaigns.findIndex(c => c.id === id);
if (idx === -1) return { success: false, message: 'Campaign not found' };
if (campaigns[idx].status !== 'ACTIVE') return { success: false, message: 'Only active campaigns can be paused' };
campaigns[idx] = { ...campaigns[idx], status: 'PAUSED', updatedAt: new Date().toISOString() };
saveCampaignStore(campaigns);
logCampaignAudit(id, 'PAUSED');
return { success: true, message: 'Campaign paused' };
}
export async function resumeCampaign(
id: string,
): Promise<{ success: boolean; message: string }> {
const campaigns = getCampaignStore();
const idx = campaigns.findIndex(c => c.id === id);
if (idx === -1) return { success: false, message: 'Campaign not found' };
if (campaigns[idx].status !== 'PAUSED') return { success: false, message: 'Only paused campaigns can be resumed' };
campaigns[idx] = { ...campaigns[idx], status: 'ACTIVE', updatedAt: new Date().toISOString() };
saveCampaignStore(campaigns);
logCampaignAudit(id, 'RESUMED');
return { success: true, message: 'Campaign resumed' };
}
// ===== REPORTING =====
export async function getCampaignReport(
id: string,
): Promise<{ success: boolean; data?: CampaignReport; message: string }> {
const campaigns = getCampaignStore();
const campaign = campaigns.find(c => c.id === id);
if (!campaign) return { success: false, message: 'Campaign not found' };
// Get or compute daily stats
let dailyStats: PlacementDailyStats[];
if (campaign.id === 'camp-001') {
// Use pre-computed mock data for demo campaign
dailyStats = MOCK_DAILY_STATS;
} else {
dailyStats = computeDailyStats(
campaign.id,
campaign.billingModel,
campaign.totalBudget / (campaign.billingModel === 'CPM' ? 1000 : campaign.billingModel === 'CPC' ? 500 : 1),
);
}
// Totals
const totalImpressions = dailyStats.reduce((s, d) => s + d.impressions, 0);
const totalClicks = dailyStats.reduce((s, d) => s + d.clicks, 0);
const totalSpend = dailyStats.reduce((s, d) => s + d.spend, 0);
// By surface
const surfaceMap = new Map<string, { impressions: number; clicks: number; spend: number }>();
for (const stat of dailyStats) {
const existing = surfaceMap.get(stat.surfaceKey) || { impressions: 0, clicks: 0, spend: 0 };
existing.impressions += stat.impressions;
existing.clicks += stat.clicks;
existing.spend += stat.spend;
surfaceMap.set(stat.surfaceKey, existing);
}
const bySurface = Array.from(surfaceMap.entries()).map(([surfaceKey, data]) => {
const surface = MOCK_SURFACES.find(s => s.key === surfaceKey);
return {
surfaceKey: surfaceKey as SurfaceKey,
surfaceName: surface?.name || surfaceKey,
impressions: data.impressions,
clicks: data.clicks,
ctr: data.impressions > 0 ? Number((data.clicks / data.impressions).toFixed(4)) : 0,
spend: Number(data.spend.toFixed(2)),
};
});
const report: CampaignReport = {
campaign: resolveEvents(campaign),
totals: {
impressions: totalImpressions,
clicks: totalClicks,
ctr: totalImpressions > 0 ? Number((totalClicks / totalImpressions).toFixed(4)) : 0,
spend: Number(totalSpend.toFixed(2)),
remaining: Number((campaign.totalBudget - totalSpend).toFixed(2)),
},
dailyStats,
bySurface,
};
return { success: true, data: report, message: 'OK' };
}
// ===== CSV EXPORT =====
export async function exportCampaignCSV(id: string): Promise<{ success: boolean; csv?: string; message: string }> {
const res = await getCampaignReport(id);
if (!res.success || !res.data) return { success: false, message: res.message };
const { campaign, totals, dailyStats, bySurface } = res.data;
const lines: string[] = [
`Campaign Report: ${campaign.name}`,
`Partner: ${campaign.partnerName}`,
`Status: ${campaign.status}`,
`Period: ${campaign.startAt.slice(0, 10)} to ${campaign.endAt.slice(0, 10)}`,
`Billing: ${campaign.billingModel}`,
`Budget: ₹${campaign.totalBudget.toLocaleString()}`,
`Spent: ₹${totals.spend.toLocaleString()}`,
``,
`Date,Surface,Impressions,Clicks,CTR,Spend`,
...dailyStats.map(d =>
`${d.date},${d.surfaceKey},${d.impressions},${d.clicks},${(d.ctr * 100).toFixed(2)}%,₹${d.spend.toFixed(2)}`
),
``,
`Surface Summary`,
`Surface,Impressions,Clicks,CTR,Spend`,
...bySurface.map(s =>
`${s.surfaceName},${s.impressions},${s.clicks},${(s.ctr * 100).toFixed(2)}%,₹${s.spend.toFixed(2)}`
),
``,
`Totals,${totals.impressions},${totals.clicks},${(totals.ctr * 100).toFixed(2)}%,₹${totals.spend.toFixed(2)}`,
];
return { success: true, csv: lines.join('\n'), message: 'OK' };
}
// ===== DASHBOARD STATS =====
export async function getSponsoredStats(): Promise<{
success: boolean;
data: {
activeCampaigns: number;
todaySpend: number;
impressions24h: number;
clicks24h: number;
ctr24h: number;
};
}> {
const campaigns = autoEndCampaigns(getCampaignStore());
const activeCampaigns = campaigns.filter(c => c.status === 'ACTIVE').length;
// Last 24h stats from tracking events
const cutoff24h = new Date(Date.now() - 86400000).toISOString();
// For pre-seeded data, use mock stats; for real-time, use tracking
let impressions24h = 0;
let clicks24h = 0;
let todaySpend = 0;
// Check real tracking events first
const allEvents = getTrackingEvents();
const recent = allEvents.filter(e => e.timestamp > cutoff24h);
if (recent.length > 0) {
impressions24h = recent.filter(e => e.type === 'IMPRESSION').length;
clicks24h = recent.filter(e => e.type === 'CLICK').length;
todaySpend = Number(((impressions24h / 1000) * 250).toFixed(2)); // ₹250 CPM assumed
} else {
// Fallback to mock daily stats (last day)
const lastDayStats = MOCK_DAILY_STATS.filter(d => d.date === '2026-02-09');
impressions24h = lastDayStats.reduce((s, d) => s + d.impressions, 0);
clicks24h = lastDayStats.reduce((s, d) => s + d.clicks, 0);
todaySpend = lastDayStats.reduce((s, d) => s + d.spend, 0);
}
const ctr24h = impressions24h > 0 ? Number((clicks24h / impressions24h).toFixed(4)) : 0;
return {
success: true,
data: { activeCampaigns, todaySpend, impressions24h, clicks24h, ctr24h },
};
}

115
src/lib/ads/serving.ts Normal file
View File

@@ -0,0 +1,115 @@
// Ad Serving Engine — Ranking, frequency caps, budget pacing
import type { Campaign, SponsoredPlacement } from '@/lib/types/ads';
import type { SurfaceKey } from '@/lib/types/ad-control';
import { getUserImpressionCount } from './tracking';
import { MOCK_PICKER_EVENTS } from '@/features/ad-control/data/mockAdData';
// --- Get eligible sponsored placements for a surface request ---
export function getSponsoredForSurface(
campaigns: Campaign[],
surfaceKey: SurfaceKey,
city?: string,
category?: string,
anonId?: string,
): SponsoredPlacement[] {
const now = new Date().toISOString();
const today = now.slice(0, 10);
const eligible: { campaign: Campaign; placement: SponsoredPlacement }[] = [];
for (const camp of campaigns) {
// 1. Must be ACTIVE
if (camp.status !== 'ACTIVE') continue;
// 2. Within schedule window
if (camp.startAt > now || camp.endAt < now) continue;
// 3. Must target this surface
if (!camp.surfaceKeys.includes(surfaceKey)) continue;
// 4. Targeting match (empty = all)
if (city && camp.targeting.cityIds.length > 0 && !camp.targeting.cityIds.includes(city)) continue;
if (category && camp.targeting.categoryIds.length > 0 && !camp.targeting.categoryIds.includes(category)) continue;
// 5. Budget check: total
if (camp.spent >= camp.totalBudget) continue;
// 6. Frequency cap check
if (anonId && camp.frequencyCap > 0) {
const todayCount = getUserImpressionCount(anonId, camp.id, today);
if (todayCount >= camp.frequencyCap) continue;
}
// Create sponsored placements for each event in campaign
for (const eventId of camp.eventIds) {
eligible.push({
campaign: camp,
placement: {
id: `splc-${camp.id}-${surfaceKey}-${eventId}`,
campaignId: camp.id,
eventId,
surfaceKey,
priority: 'SPONSORED',
bid: calculateBid(camp),
status: 'ACTIVE',
rank: 0,
},
});
}
}
// 7. Rank by eCPM (descending)
eligible.sort((a, b) => calculateECPM(b.campaign) - calculateECPM(a.campaign));
return eligible.map(e => e.placement);
}
// --- Bid calculation ---
function calculateBid(campaign: Campaign): number {
switch (campaign.billingModel) {
case 'CPM': return campaign.totalBudget / 1000; // simplified
case 'CPC': return campaign.totalBudget / 500; // simplified
case 'FIXED': return campaign.totalBudget;
default: return 0;
}
}
// --- eCPM calculation for ranking ---
function calculateECPM(campaign: Campaign): number {
switch (campaign.billingModel) {
case 'CPM': return calculateBid(campaign) * 1000;
case 'CPC': return calculateBid(campaign) * 100; // estimated CTR 10%
case 'FIXED': return campaign.totalBudget / 30; // estimate daily value
default: return 0;
}
}
// --- Spend calculation ---
export function calculateSpend(
billingModel: string,
bid: number,
impressions: number,
clicks: number,
): number {
switch (billingModel) {
case 'CPM': return Number(((impressions / 1000) * bid).toFixed(2));
case 'CPC': return Number((clicks * bid).toFixed(2));
case 'FIXED': return bid; // already allocated
default: return 0;
}
}
// --- Check if campaign should be auto-ended ---
export function shouldAutoEnd(campaign: Campaign): boolean {
const now = new Date().toISOString();
return (
campaign.status === 'ACTIVE' &&
(campaign.endAt < now || campaign.spent >= campaign.totalBudget)
);
}

194
src/lib/ads/tracking.ts Normal file
View File

@@ -0,0 +1,194 @@
// Ad Tracking Engine — Impression/Click recording, deduplication, rollups
import type { AdTrackingEvent, PlacementDailyStats } from '@/lib/types/ads';
import type { SurfaceKey } from '@/lib/types/ad-control';
const TRACKING_KEY = 'ad_tracking_events';
const DAILY_STATS_KEY = 'ad_daily_stats';
const DEDUPE_WINDOW_MS = 30_000; // 30 seconds
// --- Persistence ---
function getTrackingStore(): AdTrackingEvent[] {
if (typeof window === 'undefined') return [];
try { return JSON.parse(localStorage.getItem(TRACKING_KEY) || '[]'); }
catch { return []; }
}
function saveTrackingStore(events: AdTrackingEvent[]) {
if (typeof window === 'undefined') return;
// Cap at 5000 to avoid localStorage limits
localStorage.setItem(TRACKING_KEY, JSON.stringify(events.slice(-5000)));
}
function getDailyStatsStore(): PlacementDailyStats[] {
if (typeof window === 'undefined') return [];
try { return JSON.parse(localStorage.getItem(DAILY_STATS_KEY) || '[]'); }
catch { return []; }
}
function saveDailyStatsStore(stats: PlacementDailyStats[]) {
if (typeof window === 'undefined') return;
localStorage.setItem(DAILY_STATS_KEY, JSON.stringify(stats));
}
// --- Deduplication ---
function isDuplicate(events: AdTrackingEvent[], newEvent: Omit<AdTrackingEvent, 'id'>): boolean {
const cutoff = new Date(new Date(newEvent.timestamp).getTime() - DEDUPE_WINDOW_MS).toISOString();
return events.some(e =>
e.type === newEvent.type &&
e.placementId === newEvent.placementId &&
e.anonId === newEvent.anonId &&
e.timestamp > cutoff
);
}
// --- Record Events ---
export function recordImpression(data: {
placementId: string;
campaignId: string;
surfaceKey: SurfaceKey;
eventId: string;
userId?: string | null;
anonId: string;
sessionId: string;
device?: string;
cityId?: string;
}): { success: boolean; deduplicated?: boolean } {
const store = getTrackingStore();
const now = new Date().toISOString();
const event: Omit<AdTrackingEvent, 'id'> = {
type: 'IMPRESSION',
placementId: data.placementId,
campaignId: data.campaignId,
surfaceKey: data.surfaceKey,
eventId: data.eventId,
userId: data.userId || null,
anonId: data.anonId,
sessionId: data.sessionId,
timestamp: now,
device: data.device || 'unknown',
cityId: data.cityId || 'unknown',
};
if (isDuplicate(store, event)) {
return { success: true, deduplicated: true };
}
const fullEvent: AdTrackingEvent = {
...event,
id: `te-imp-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
};
store.push(fullEvent);
saveTrackingStore(store);
return { success: true };
}
export function recordClick(data: {
placementId: string;
campaignId: string;
surfaceKey: SurfaceKey;
eventId: string;
userId?: string | null;
anonId: string;
sessionId: string;
device?: string;
cityId?: string;
}): { success: boolean } {
const store = getTrackingStore();
const now = new Date().toISOString();
const fullEvent: AdTrackingEvent = {
id: `te-clk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
type: 'CLICK',
placementId: data.placementId,
campaignId: data.campaignId,
surfaceKey: data.surfaceKey,
eventId: data.eventId,
userId: data.userId || null,
anonId: data.anonId,
sessionId: data.sessionId,
timestamp: now,
device: data.device || 'unknown',
cityId: data.cityId || 'unknown',
};
store.push(fullEvent);
saveTrackingStore(store);
return { success: true };
}
// --- Queries ---
export function getTrackingEvents(campaignId?: string, type?: 'IMPRESSION' | 'CLICK'): AdTrackingEvent[] {
let events = getTrackingStore();
if (campaignId) events = events.filter(e => e.campaignId === campaignId);
if (type) events = events.filter(e => e.type === type);
return events;
}
export function getUserImpressionCount(anonId: string, campaignId: string, date: string): number {
const events = getTrackingStore();
return events.filter(e =>
e.type === 'IMPRESSION' &&
e.anonId === anonId &&
e.campaignId === campaignId &&
e.timestamp.startsWith(date)
).length;
}
export function getTodaySpend(campaignId: string, cpmRate: number): number {
const today = new Date().toISOString().slice(0, 10);
const impressions = getTrackingStore().filter(e =>
e.type === 'IMPRESSION' &&
e.campaignId === campaignId &&
e.timestamp.startsWith(today)
).length;
return Number(((impressions / 1000) * cpmRate).toFixed(2));
}
// --- Daily Stats Rollup ---
export function computeDailyStats(campaignId: string, billingModel: string, bid: number): PlacementDailyStats[] {
const events = getTrackingStore().filter(e => e.campaignId === campaignId);
// Group by date + surfaceKey
const buckets: Record<string, { impressions: number; clicks: number; surfaceKey: SurfaceKey; placementId: string }> = {};
for (const e of events) {
const date = e.timestamp.slice(0, 10);
const key = `${date}|${e.surfaceKey}`;
if (!buckets[key]) {
buckets[key] = { impressions: 0, clicks: 0, surfaceKey: e.surfaceKey, placementId: e.placementId };
}
if (e.type === 'IMPRESSION') buckets[key].impressions++;
else buckets[key].clicks++;
}
return Object.entries(buckets).map(([key, data]) => {
const [date] = key.split('|');
let spend = 0;
if (billingModel === 'CPM') spend = (data.impressions / 1000) * bid;
else if (billingModel === 'CPC') spend = data.clicks * bid;
// FIXED: spread evenly (simplified)
else spend = bid / 30; // assume 30-day campaign
const ctr = data.impressions > 0 ? data.clicks / data.impressions : 0;
return {
id: `ds-${key}`,
campaignId,
placementId: data.placementId,
surfaceKey: data.surfaceKey,
date,
impressions: data.impressions,
clicks: data.clicks,
ctr: Number(ctr.toFixed(4)),
spend: Number(spend.toFixed(2)),
};
}).sort((a, b) => a.date.localeCompare(b.date));
}

175
src/lib/api/public-ads.ts Normal file
View File

@@ -0,0 +1,175 @@
// Public Ads API Client (Simulation)
// This module simulates the public API endpoints requested.
import type { PlacementWithEvent } from '@/lib/types/ad-control';
import type { SurfaceKey } from '@/lib/types/ad-control';
import { getCampaigns } from '@/lib/actions/ads';
import { getPublicPlacements } from '@/lib/actions/ad-control';
import { getSponsoredForSurface } from '@/lib/ads/serving';
import { recordImpression, recordClick } from '@/lib/ads/tracking';
// --- Types ---
export interface AdRequest {
surfaceKey: SurfaceKey;
cityId?: string;
categoryId?: string;
anonId?: string; // For frequency capping
limit?: number;
}
export interface AdResponse {
placements: PlacementWithEvent[];
meta: {
sponsoredCount: number;
manualCount: number;
totalCount: number;
};
}
// --- API Simulation ---
/**
* GET /api/public/placements
* Merges Sponsored (high priority) + Manual (medium) + Algo (low - future)
*/
export async function fetchPlacements(req: AdRequest): Promise<AdResponse> {
// Simulate network latency
await new Promise(r => setTimeout(r, 150));
// 1. Fetch Sponsored
const allCampaignsRes = await getCampaigns('ACTIVE');
const sponsoredItems = getSponsoredForSurface(
allCampaignsRes.data,
req.surfaceKey,
req.cityId,
req.categoryId,
req.anonId
);
// Resolving sponsored items to full PlacementWithEvent structure
// In a real backend, this would happen via JOINs. Here we mock it by finding the event in our mock data.
// We import MOCK_PICKER_EVENTS dynamically to avoid circular deps if possible, or just use the action.
// Actually, getCampaigns already resolves events, so we can use that.
// We need to map SponsoredPlacement -> PlacementWithEvent
// The `getSponsoredForSurface` returns `SponsoredPlacement` objects.
// We need to hydrate them with event details.
// optimizing: getCampaigns returns CampaignWithEvents, which has the event details.
const campaigns = allCampaignsRes.data;
const hydratedSponsored: PlacementWithEvent[] = sponsoredItems.map(sp => {
const campaign = campaigns.find(c => c.id === sp.campaignId);
const event = campaign?.events.find(e => e.id === sp.eventId);
return {
id: sp.id,
surfaceId: sp.surfaceKey, // mapping key to ID for compatibility
itemType: 'EVENT',
eventId: sp.eventId,
status: 'ACTIVE',
priority: 'SPONSORED',
rank: sp.rank, // 0 for sponsored usually, or ranked by eCPM
startAt: campaign?.startAt,
endAt: campaign?.endAt,
targeting: campaign?.targeting || { cityIds: [], categoryIds: [], countryCodes: [] },
event: event,
// Extra metadata for frontend to know it's sponsored
notes: `Sponsored by ${campaign?.partnerName}`,
boostLabel: 'Sponsored',
createdAt: campaign?.createdAt || new Date().toISOString(),
updatedAt: campaign?.updatedAt || new Date().toISOString(),
createdBy: 'system',
updatedBy: 'system',
};
}).filter(p => p.event); // Ensure event exists
// 2. Fetch Manual (Organic/Featured)
const manualRes = await getPublicPlacements(req.surfaceKey, req.cityId, req.categoryId);
const manualItems = manualRes.data;
// 3. Merge & Deduplicate
// If an event is both Sponsored and Manual, show Sponsored (higher priority)
const seenEventIds = new Set<string>();
const merged: PlacementWithEvent[] = [];
// Add Sponsored
for (const item of hydratedSponsored) {
if (!seenEventIds.has(item.eventId!)) {
merged.push(item);
seenEventIds.add(item.eventId!);
}
}
// Add Manual (if not already added)
for (const item of manualItems) {
if (!item.eventId || !seenEventIds.has(item.eventId)) {
merged.push(item);
if (item.eventId) seenEventIds.add(item.eventId);
}
}
// 4. Limit
const limit = req.limit || 10;
const final = merged.slice(0, limit);
return {
placements: final,
meta: {
sponsoredCount: hydratedSponsored.length,
manualCount: manualItems.length,
totalCount: final.length,
},
};
}
/**
* POST /api/public/track/impression
*/
export async function trackImpression(data: {
placementId: string;
campaignId?: string;
surfaceKey: string;
eventId: string;
anonId: string;
userId?: string;
}): Promise<{ success: boolean }> {
// Fire & Forget in a real app, but here we await slightly
await new Promise(r => setTimeout(r, 50));
if (data.campaignId) {
// Only track for sponsored campaigns for now, or track all for analytics
recordImpression({
...data,
surfaceKey: data.surfaceKey as SurfaceKey, // cast for safety
sessionId: 'sess-simulated', // simplified
});
}
return { success: true };
}
/**
* POST /api/public/track/click
*/
export async function trackClick(data: {
placementId: string;
campaignId?: string;
surfaceKey: string;
eventId: string;
anonId: string;
userId?: string;
}): Promise<{ success: boolean; url: string }> {
await new Promise(r => setTimeout(r, 50));
if (data.campaignId) {
recordClick({
...data,
surfaceKey: data.surfaceKey as SurfaceKey,
sessionId: 'sess-simulated',
});
}
// Return the destination URL (e.g. event details page)
return { success: true, url: `/events/${data.eventId}` };
}

129
src/lib/types/ads.ts Normal file
View File

@@ -0,0 +1,129 @@
// Sponsored Ads Module — Types & Interfaces
import type { SurfaceKey, PlacementTargeting, PickerEvent } from './ad-control';
// ===== Enums =====
export type CampaignStatus = 'DRAFT' | 'IN_REVIEW' | 'ACTIVE' | 'PAUSED' | 'ENDED' | 'REJECTED';
export type BillingModel = 'FIXED' | 'CPM' | 'CPC';
export type CampaignObjective = 'AWARENESS' | 'SALES';
export type TrackingEventType = 'IMPRESSION' | 'CLICK';
// ===== Campaign =====
export interface Campaign {
id: string;
partnerId: string;
partnerName: string;
name: string;
objective: CampaignObjective;
status: CampaignStatus;
startAt: string;
endAt: string;
billingModel: BillingModel;
totalBudget: number; // in INR
dailyCap: number | null; // max spend per day
spent: number; // accumulated spend
targeting: PlacementTargeting;
surfaceKeys: SurfaceKey[];
eventIds: string[];
frequencyCap: number; // max impressions per user per day (0 = unlimited)
approvedBy: string | null;
rejectedReason: string | null;
createdBy: string;
createdAt: string;
updatedAt: string;
}
// ===== Campaign with resolved events =====
export interface CampaignWithEvents extends Campaign {
events: PickerEvent[];
}
// ===== Sponsored Placement =====
export interface SponsoredPlacement {
id: string;
campaignId: string;
eventId: string;
surfaceKey: SurfaceKey;
priority: 'SPONSORED';
bid: number; // per-unit cost (CPM rate / CPC rate / fixed allocation)
status: 'ACTIVE' | 'PAUSED';
rank: number;
}
// ===== Tracking Events =====
export interface AdTrackingEvent {
id: string;
type: TrackingEventType;
placementId: string;
campaignId: string;
surfaceKey: SurfaceKey;
eventId: string;
userId: string | null;
anonId: string;
sessionId: string;
timestamp: string;
device: string;
cityId: string;
}
// ===== Daily Stats (Rollup) =====
export interface PlacementDailyStats {
id: string;
campaignId: string;
placementId: string;
surfaceKey: SurfaceKey;
date: string; // YYYY-MM-DD
impressions: number;
clicks: number;
ctr: number; // clicks / impressions
spend: number; // INR
}
// ===== Campaign Report =====
export interface CampaignReport {
campaign: CampaignWithEvents;
totals: {
impressions: number;
clicks: number;
ctr: number;
spend: number;
remaining: number;
};
dailyStats: PlacementDailyStats[];
bySurface: {
surfaceKey: SurfaceKey;
surfaceName: string;
impressions: number;
clicks: number;
ctr: number;
spend: number;
}[];
}
// ===== Form Data =====
export interface CampaignFormData {
// Step 1: Basics
partnerName: string;
name: string;
objective: CampaignObjective;
startAt: string;
endAt: string;
// Step 2: Placement
surfaceKeys: SurfaceKey[];
eventIds: string[];
// Step 3: Targeting
targeting: PlacementTargeting;
// Step 4: Budget
billingModel: BillingModel;
totalBudget: number;
dailyCap: number | null;
frequencyCap: number;
}

View File

@@ -67,6 +67,11 @@ export const SCOPE_DEFINITIONS: Record<string, { label: string; category: string
'settings.read': { label: 'View Settings', category: 'Settings' },
'settings.write': { label: 'Modify Settings', category: 'Settings' },
'settings.staff': { label: 'Manage Staff', category: 'Settings' },
// Ad Control Module
'ads.read': { label: 'View Ad Campaigns', category: 'Ad Control' },
'ads.write': { label: 'Create/Edit Campaigns', category: 'Ad Control' },
'ads.approve': { label: 'Approve Campaigns', category: 'Ad Control' },
'ads.report': { label: 'View Ad Reports', category: 'Ad Control' },
};
export const ALL_SCOPES = Object.keys(SCOPE_DEFINITIONS);

View File

@@ -1,11 +1,12 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { TooltipProvider } from '@/components/ui/tooltip';
import { AppLayout } from '@/components/layout/AppLayout';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Plus, Search, RefreshCw, Loader2, Megaphone } from 'lucide-react';
import { Plus, Search, RefreshCw, Loader2, Megaphone, Zap } from 'lucide-react';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
@@ -20,6 +21,7 @@ import type { Surface, PlacementWithEvent, PlacementStatus, PickerEvent } from '
type StatusFilter = 'ALL' | PlacementStatus;
export default function AdControl() {
const navigate = useNavigate();
// Data
const [surfaces, setSurfaces] = useState<Surface[]>([]);
const [placements, setPlacements] = useState<PlacementWithEvent[]>([]);
@@ -149,6 +151,16 @@ export default function AdControl() {
return (
<TooltipProvider>
<AppLayout title="Ad Control" description="Manage featured events, top picks, and sponsored placements">
{/* Sub-navigation */}
<div className="flex items-center gap-3 mb-6 pb-4 border-b">
<Button variant="secondary" className="gap-2 font-semibold" disabled>
<Megaphone className="h-4 w-4" /> Manual Placements
</Button>
<Button variant="outline" className="gap-2" onClick={() => navigate('/ad-control/sponsored')}>
<Zap className="h-4 w-4" /> Sponsored Campaigns
</Button>
</div>
<div className="flex gap-6">
{/* Left — Surface Tabs */}
<SurfaceTabs

View File

@@ -0,0 +1,13 @@
import { TooltipProvider } from '@/components/ui/tooltip';
import { AppLayout } from '@/components/layout/AppLayout';
import { CampaignReportPage } from '@/features/ad-control/components/sponsored/CampaignReportPage';
export default function CampaignReport() {
return (
<TooltipProvider>
<AppLayout title="Campaign Report" description="Performance metrics and analytics">
<CampaignReportPage />
</AppLayout>
</TooltipProvider>
);
}

13
src/pages/NewCampaign.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { TooltipProvider } from '@/components/ui/tooltip';
import { AppLayout } from '@/components/layout/AppLayout';
import { CampaignWizard } from '@/features/ad-control/components/sponsored/CampaignWizard';
export default function NewCampaign() {
return (
<TooltipProvider>
<AppLayout title="New Sponsored Campaign" description="Create a new paid placement campaign">
<CampaignWizard />
</AppLayout>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,13 @@
import { TooltipProvider } from '@/components/ui/tooltip';
import { AppLayout } from '@/components/layout/AppLayout';
import { SponsoredDashboard } from '@/features/ad-control/components/sponsored/SponsoredDashboard';
export default function SponsoredAds() {
return (
<TooltipProvider>
<AppLayout title="Sponsored Campaigns" description="Create, manage, and monitor paid sponsored placements">
<SponsoredDashboard />
</AppLayout>
</TooltipProvider>
);
}