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 Events from "./pages/Events";
|
||||||
import Users from "./pages/Users";
|
import Users from "./pages/Users";
|
||||||
import AdControl from "./pages/AdControl";
|
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 Financials from "./pages/Financials";
|
||||||
import Settings from "./pages/Settings";
|
import Settings from "./pages/Settings";
|
||||||
import NotFound from "./pages/NotFound";
|
import NotFound from "./pages/NotFound";
|
||||||
@@ -93,6 +96,30 @@ const App = () => (
|
|||||||
</ProtectedRoute>
|
</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 */}
|
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
DollarSign,
|
DollarSign,
|
||||||
Settings,
|
Settings,
|
||||||
Ticket,
|
Ticket,
|
||||||
Megaphone
|
Megaphone,
|
||||||
|
Zap
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ const navItems = [
|
|||||||
{ title: 'Partner Management', href: '/partners', icon: Users },
|
{ title: 'Partner Management', href: '/partners', icon: Users },
|
||||||
{ title: 'Events', href: '/events', icon: Calendar },
|
{ title: 'Events', href: '/events', icon: Calendar },
|
||||||
{ title: 'Ad Control', href: '/ad-control', icon: Megaphone },
|
{ title: 'Ad Control', href: '/ad-control', icon: Megaphone },
|
||||||
|
{ title: 'Sponsored Ads', href: '/ad-control/sponsored', icon: Zap },
|
||||||
{ title: 'Users', href: '/users', icon: User },
|
{ title: 'Users', href: '/users', icon: User },
|
||||||
{ title: 'Financials', href: '/financials', icon: DollarSign },
|
{ title: 'Financials', href: '/financials', icon: DollarSign },
|
||||||
{ title: 'Settings', href: '/settings', icon: Settings },
|
{ 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.read': { label: 'View Settings', category: 'Settings' },
|
||||||
'settings.write': { label: 'Modify Settings', category: 'Settings' },
|
'settings.write': { label: 'Modify Settings', category: 'Settings' },
|
||||||
'settings.staff': { label: 'Manage Staff', 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);
|
export const ALL_SCOPES = Object.keys(SCOPE_DEFINITIONS);
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||||
import { AppLayout } from '@/components/layout/AppLayout';
|
import { AppLayout } from '@/components/layout/AppLayout';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
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 { cn } from '@/lib/utils';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ import type { Surface, PlacementWithEvent, PlacementStatus, PickerEvent } from '
|
|||||||
type StatusFilter = 'ALL' | PlacementStatus;
|
type StatusFilter = 'ALL' | PlacementStatus;
|
||||||
|
|
||||||
export default function AdControl() {
|
export default function AdControl() {
|
||||||
|
const navigate = useNavigate();
|
||||||
// Data
|
// Data
|
||||||
const [surfaces, setSurfaces] = useState<Surface[]>([]);
|
const [surfaces, setSurfaces] = useState<Surface[]>([]);
|
||||||
const [placements, setPlacements] = useState<PlacementWithEvent[]>([]);
|
const [placements, setPlacements] = useState<PlacementWithEvent[]>([]);
|
||||||
@@ -149,6 +151,16 @@ export default function AdControl() {
|
|||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<AppLayout title="Ad Control" description="Manage featured events, top picks, and sponsored placements">
|
<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">
|
<div className="flex gap-6">
|
||||||
{/* Left — Surface Tabs */}
|
{/* Left — Surface Tabs */}
|
||||||
<SurfaceTabs
|
<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