feat: implement sponsored ads module end-to-end
This commit is contained in:
160
prisma/schema.prisma
Normal file
160
prisma/schema.prisma
Normal 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())
|
||||
}
|
||||
27
src/App.tsx
27
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 = () => (
|
||||
</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>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
426
src/features/ad-control/components/sponsored/CampaignWizard.tsx
Normal file
426
src/features/ad-control/components/sponsored/CampaignWizard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
193
src/features/ad-control/data/mockAdsData.ts
Normal file
193
src/features/ad-control/data/mockAdsData.ts
Normal 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
388
src/lib/actions/ads.ts
Normal 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
115
src/lib/ads/serving.ts
Normal 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
194
src/lib/ads/tracking.ts
Normal 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
175
src/lib/api/public-ads.ts
Normal 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
129
src/lib/types/ads.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
13
src/pages/CampaignReport.tsx
Normal file
13
src/pages/CampaignReport.tsx
Normal 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
13
src/pages/NewCampaign.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
src/pages/SponsoredAds.tsx
Normal file
13
src/pages/SponsoredAds.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user