feat: Ad Control module — surfaces, placements, drag-and-drop reorder, event picker, targeting

This commit is contained in:
CycroftX
2026-02-10 15:06:58 +05:30
parent 2cfefc17dc
commit 3e1641d281
11 changed files with 1755 additions and 11 deletions

View File

@@ -12,6 +12,7 @@ import PartnerDirectory from "./features/partners/PartnerDirectory";
import PartnerProfile from "./features/partners/PartnerProfile";
import Events from "./pages/Events";
import Users from "./pages/Users";
import AdControl from "./pages/AdControl";
import Financials from "./pages/Financials";
import Settings from "./pages/Settings";
import NotFound from "./pages/NotFound";
@@ -68,6 +69,14 @@ const App = () => (
</ProtectedRoute>
}
/>
<Route
path="/ad-control"
element={
<ProtectedRoute>
<AdControl />
</ProtectedRoute>
}
/>
<Route
path="/financials"
element={

View File

@@ -6,7 +6,8 @@ import {
User,
DollarSign,
Settings,
Ticket
Ticket,
Megaphone
} from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -14,6 +15,7 @@ const navItems = [
{ title: 'Dashboard', href: '/', icon: LayoutDashboard },
{ title: 'Partner Management', href: '/partners', icon: Users },
{ title: 'Events', href: '/events', icon: Calendar },
{ title: 'Ad Control', href: '/ad-control', icon: Megaphone },
{ title: 'Users', href: '/users', icon: User },
{ title: 'Financials', href: '/financials', icon: DollarSign },
{ title: 'Settings', href: '/settings', icon: Settings },

View File

@@ -0,0 +1,167 @@
import { useState, useMemo } from 'react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Search, MapPin, Calendar, AlertTriangle, CheckCircle2,
ImageOff, Clock, Users,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import type { PickerEvent } from '@/lib/types/ad-control';
interface EventPickerModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
events: PickerEvent[];
onSelectEvent: (event: PickerEvent) => void;
alreadyPlacedEventIds: string[];
}
export function EventPickerModal({ open, onOpenChange, events, onSelectEvent, alreadyPlacedEventIds }: EventPickerModalProps) {
const [query, setQuery] = useState('');
const now = new Date();
const filtered = useMemo(() => {
if (!query.trim()) return events;
const q = query.toLowerCase();
return events.filter(e =>
e.title.toLowerCase().includes(q) ||
e.id.toLowerCase().includes(q) ||
e.organizer.toLowerCase().includes(q) ||
e.city.toLowerCase().includes(q) ||
e.category.toLowerCase().includes(q)
);
}, [events, query]);
const getWarnings = (event: PickerEvent): { label: string; severity: 'warning' | 'error' }[] => {
const warns: { label: string; severity: 'warning' | 'error' }[] = [];
if (event.approvalStatus === 'PENDING') warns.push({ label: 'Pending Approval', severity: 'warning' });
if (event.approvalStatus === 'REJECTED') warns.push({ label: 'Rejected', severity: 'error' });
if (new Date(event.endDate) < now) warns.push({ label: 'Event Ended', severity: 'error' });
if (!event.coverImage) warns.push({ label: 'No Cover Image', severity: 'warning' });
return warns;
};
const handleSelect = (event: PickerEvent) => {
const warnings = getWarnings(event);
const hasErrors = warnings.some(w => w.severity === 'error');
if (hasErrors) {
const msg = warnings.filter(w => w.severity === 'error').map(w => w.label).join(', ');
if (!confirm(`This event has issues: ${msg}. Proceed anyway?`)) return;
}
onSelectEvent(event);
onOpenChange(false);
setQuery('');
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>Select Event</DialogTitle>
<DialogDescription>Search for an event to place on this surface.</DialogDescription>
</DialogHeader>
{/* Search */}
<div className="relative">
<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 by name, ID, organizer, city, or category..."
className="pl-10"
autoFocus
/>
</div>
{/* Event List */}
<div className="flex-1 overflow-y-auto space-y-2 pr-1 min-h-0">
{filtered.length === 0 && (
<div className="text-center py-12 text-muted-foreground text-sm">
No events found matching "{query}"
</div>
)}
{filtered.map(event => {
const warnings = getWarnings(event);
const isPlaced = alreadyPlacedEventIds.includes(event.id);
const fillPercent = Math.round((event.ticketsSold / event.capacity) * 100);
return (
<button
key={event.id}
onClick={() => !isPlaced && handleSelect(event)}
disabled={isPlaced}
className={cn(
'w-full flex items-center gap-3 rounded-xl border p-3 text-left transition-all',
isPlaced
? 'opacity-50 cursor-not-allowed bg-muted/20'
: 'hover:shadow-md hover:border-primary/30 bg-card cursor-pointer',
)}
>
{/* Cover */}
{event.coverImage ? (
<img src={event.coverImage} alt="" className="h-16 w-24 rounded-lg object-cover flex-shrink-0" />
) : (
<div className="h-16 w-24 rounded-lg bg-muted flex items-center justify-center flex-shrink-0">
<ImageOff className="h-5 w-5 text-muted-foreground/30" />
</div>
)}
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<h4 className="font-semibold text-sm truncate">{event.title}</h4>
{event.approvalStatus === 'APPROVED' && (
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500 flex-shrink-0" />
)}
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><MapPin className="h-3 w-3" />{event.city}</span>
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{new Date(event.date).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' })}
</span>
<span>{event.organizer}</span>
</div>
<div className="flex items-center gap-2 mt-1">
<Badge variant="secondary" className="text-[10px] h-4">{event.category}</Badge>
<span className="text-[10px] text-muted-foreground flex items-center gap-1">
<Users className="h-3 w-3" /> {event.ticketsSold.toLocaleString()}/{event.capacity.toLocaleString()} ({fillPercent}%)
</span>
</div>
</div>
{/* Warnings */}
<div className="flex flex-col gap-1 flex-shrink-0 items-end">
{isPlaced && (
<Badge variant="secondary" className="text-[10px]">Already Placed</Badge>
)}
{warnings.map((w, i) => (
<Badge
key={i}
variant="outline"
className={cn(
'text-[10px] gap-1',
w.severity === 'error'
? 'bg-red-50 text-red-600 border-red-200'
: 'bg-amber-50 text-amber-600 border-amber-200'
)}
>
<AlertTriangle className="h-3 w-3" />
{w.label}
</Badge>
))}
</div>
</button>
);
})}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,282 @@
import { useState, useEffect } from 'react';
import {
Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle,
} from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Loader2, ExternalLink, X, Plus } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { createPlacement, updatePlacement, publishPlacement } from '@/lib/actions/ad-control';
import { MOCK_CITIES, MOCK_CATEGORIES } from '../data/mockAdData';
import type { PickerEvent, PlacementWithEvent, PlacementPriority, PlacementConfigData } from '@/lib/types/ad-control';
interface PlacementConfigDrawerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
event: PickerEvent | null;
surfaceId: string;
editingPlacement: PlacementWithEvent | null; // null = create mode
onComplete: () => void;
}
export function PlacementConfigDrawer({
open, onOpenChange, event, surfaceId, editingPlacement, onComplete,
}: PlacementConfigDrawerProps) {
const isEdit = !!editingPlacement;
const displayEvent = editingPlacement?.event || event;
const [startAt, setStartAt] = useState('');
const [endAt, setEndAt] = useState('');
const [selectedCities, setSelectedCities] = useState<string[]>([]);
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [boostLabel, setBoostLabel] = useState<string>('none');
const [priority, setPriority] = useState<PlacementPriority>('MANUAL');
const [notes, setNotes] = useState('');
const [loading, setLoading] = useState<'draft' | 'publish' | null>(null);
// Populate from editing placement
useEffect(() => {
if (editingPlacement) {
setStartAt(editingPlacement.startAt ? editingPlacement.startAt.slice(0, 16) : '');
setEndAt(editingPlacement.endAt ? editingPlacement.endAt.slice(0, 16) : '');
setSelectedCities(editingPlacement.targeting.cityIds);
setSelectedCategories(editingPlacement.targeting.categoryIds);
setBoostLabel(editingPlacement.boostLabel || 'none');
setPriority(editingPlacement.priority);
setNotes(editingPlacement.notes || '');
} else {
setStartAt('');
setEndAt('');
setSelectedCities([]);
setSelectedCategories([]);
setBoostLabel('none');
setPriority('MANUAL');
setNotes('');
}
}, [editingPlacement, open]);
const buildConfig = (): PlacementConfigData => ({
startAt: startAt ? new Date(startAt).toISOString() : null,
endAt: endAt ? new Date(endAt).toISOString() : null,
targeting: {
cityIds: selectedCities,
categoryIds: selectedCategories,
countryCodes: ['IN'],
},
boostLabel: boostLabel === 'none' ? null : boostLabel,
priority,
notes: notes.trim() || null,
});
const handleSaveDraft = async () => {
setLoading('draft');
try {
if (isEdit) {
const res = await updatePlacement(editingPlacement!.id, buildConfig());
res.success ? toast.success('Placement updated') : toast.error(res.message);
} else {
const res = await createPlacement(surfaceId, displayEvent!.id, buildConfig());
res.success ? toast.success(res.message) : toast.error(res.message);
}
onOpenChange(false);
onComplete();
} catch { toast.error('Save failed'); }
finally { setLoading(null); }
};
const handlePublish = async () => {
if (!confirm('Publish this placement? It will become visible on the public app.')) return;
setLoading('publish');
try {
if (isEdit) {
// Update first, then publish
await updatePlacement(editingPlacement!.id, buildConfig());
const res = await publishPlacement(editingPlacement!.id);
res.success ? toast.success(res.message) : toast.error(res.message);
} else {
const createRes = await createPlacement(surfaceId, displayEvent!.id, buildConfig());
if (createRes.success && createRes.data) {
const pubRes = await publishPlacement(createRes.data.id);
pubRes.success ? toast.success(pubRes.message) : toast.error(pubRes.message);
} else {
toast.error(createRes.message);
setLoading(null);
return;
}
}
onOpenChange(false);
onComplete();
} catch { toast.error('Publish failed'); }
finally { setLoading(null); }
};
const toggleCity = (id: string) => {
setSelectedCities(prev => prev.includes(id) ? prev.filter(c => c !== id) : [...prev, id]);
};
const toggleCategory = (id: string) => {
setSelectedCategories(prev => prev.includes(id) ? prev.filter(c => c !== id) : [...prev, id]);
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-lg overflow-y-auto">
<SheetHeader className="pb-4">
<SheetTitle>{isEdit ? 'Edit Placement' : 'Configure Placement'}</SheetTitle>
<SheetDescription>
{isEdit ? 'Update schedule, targeting, and settings.' : 'Set up schedule, targeting, and publish.'}
</SheetDescription>
</SheetHeader>
{/* Event Preview */}
{displayEvent && (
<div className="flex items-center gap-3 p-3 rounded-xl bg-muted/30 border mb-6">
{displayEvent.coverImage ? (
<img src={displayEvent.coverImage} alt="" className="h-14 w-20 rounded-lg object-cover" />
) : (
<div className="h-14 w-20 rounded-lg bg-muted" />
)}
<div className="min-w-0">
<h4 className="font-semibold text-sm truncate">{displayEvent.title}</h4>
<p className="text-xs text-muted-foreground">{displayEvent.city} · {displayEvent.organizer}</p>
<p className="text-xs text-muted-foreground">
{new Date(displayEvent.date).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' })}
</p>
</div>
</div>
)}
<div className="space-y-6">
{/* Schedule */}
<div className="space-y-3">
<Label className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">Schedule</Label>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Start Date/Time</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/Time</Label>
<Input type="datetime-local" value={endAt} onChange={e => setEndAt(e.target.value)} />
</div>
</div>
<p className="text-[11px] text-muted-foreground">Leave empty for no schedule constraints (always active when published).</p>
</div>
{/* Targeting — Cities */}
<div className="space-y-3">
<Label className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">City Targeting</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>
<p className="text-[11px] text-muted-foreground">No selection = all cities (nationwide).</p>
</div>
{/* Targeting — Categories */}
<div className="space-y-3">
<Label className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">Category Targeting</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>
{/* Boost Label + Priority */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs">Boost Label</Label>
<Select value={boostLabel} onValueChange={setBoostLabel}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value="Featured">Featured</SelectItem>
<SelectItem value="Top">Top</SelectItem>
<SelectItem value="Sponsored">Sponsored</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Priority</Label>
<Select value={priority} onValueChange={(v) => setPriority(v as PlacementPriority)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="SPONSORED">Sponsored</SelectItem>
<SelectItem value="MANUAL">Manual Curated</SelectItem>
<SelectItem value="ALGO">Algorithmic</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Notes */}
<div className="space-y-1.5">
<Label className="text-xs">Internal Notes</Label>
<Textarea
value={notes}
onChange={e => setNotes(e.target.value)}
placeholder="e.g. Sponsor deal #1234, approved by marketing team..."
rows={3}
/>
</div>
{/* Preview Link (mock) */}
<Button variant="outline" className="w-full gap-2 text-sm" onClick={() => toast.info('Preview URL copied!', { description: `https://eventifyplus.com/preview?placement=${editingPlacement?.id || 'new'}&token=mock-preview-token` })}>
<ExternalLink className="h-4 w-4" />
Preview in App
</Button>
</div>
{/* Actions */}
<SheetFooter className="pt-6 gap-2 flex-row">
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={!!loading} className="flex-1">
Cancel
</Button>
<Button
variant="outline"
onClick={handleSaveDraft}
disabled={!!loading}
className="flex-1"
>
{loading === 'draft' ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Saving...</> : 'Save as Draft'}
</Button>
<Button
onClick={handlePublish}
disabled={!!loading}
className="flex-1 bg-emerald-600 hover:bg-emerald-700"
>
{loading === 'publish' ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Publishing...</> : 'Publish'}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,257 @@
import { useState, useRef, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
GripVertical, MoreHorizontal, Eye, Pencil, Power, PowerOff,
Trash2, Calendar, MapPin, Loader2, Save, Sparkles,
} from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { publishPlacement, unpublishPlacement, deletePlacement, reorderPlacements } from '@/lib/actions/ad-control';
import type { PlacementWithEvent, PlacementStatus, PlacementPriority } from '@/lib/types/ad-control';
const STATUS_CONFIG: Record<PlacementStatus, { label: string; color: string; dot: string }> = {
ACTIVE: { label: 'Active', color: 'bg-emerald-50 text-emerald-700 border-emerald-200', dot: 'bg-emerald-500' },
SCHEDULED: { label: 'Scheduled', color: 'bg-blue-50 text-blue-700 border-blue-200', dot: 'bg-blue-500' },
DRAFT: { label: 'Draft', color: 'bg-slate-50 text-slate-600 border-slate-200', dot: 'bg-slate-400' },
EXPIRED: { label: 'Expired', color: 'bg-red-50 text-red-600 border-red-200', dot: 'bg-red-400' },
DISABLED: { label: 'Disabled', color: 'bg-orange-50 text-orange-600 border-orange-200', dot: 'bg-orange-400' },
};
const PRIORITY_CONFIG: Record<PlacementPriority, { label: string; color: string }> = {
SPONSORED: { label: 'Sponsored', color: 'bg-purple-100 text-purple-700' },
MANUAL: { label: 'Curated', color: 'bg-sky-100 text-sky-700' },
ALGO: { label: 'Algorithm', color: 'bg-zinc-100 text-zinc-600' },
};
interface PlacementListProps {
placements: PlacementWithEvent[];
surfaceId: string;
onEdit: (placement: PlacementWithEvent) => void;
onRefresh: () => void;
}
export function PlacementList({ placements, surfaceId, onEdit, onRefresh }: PlacementListProps) {
const [items, setItems] = useState(placements);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [overIndex, setOverIndex] = useState<number | null>(null);
const [hasReordered, setHasReordered] = useState(false);
const [saving, setSaving] = useState(false);
const [loadingAction, setLoadingAction] = useState<string | null>(null);
// Sync when placements prop changes
if (placements !== items && !hasReordered) {
setItems(placements);
}
// --- Drag and Drop ---
const handleDragStart = (index: number) => setDragIndex(index);
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
setOverIndex(index);
};
const handleDragEnd = () => {
if (dragIndex !== null && overIndex !== null && dragIndex !== overIndex) {
const reordered = [...items];
const [moved] = reordered.splice(dragIndex, 1);
reordered.splice(overIndex, 0, moved);
setItems(reordered);
setHasReordered(true);
}
setDragIndex(null);
setOverIndex(null);
};
const handleSaveOrder = async () => {
setSaving(true);
try {
const res = await reorderPlacements(surfaceId, items.map(p => p.id));
if (res.success) {
toast.success(res.message);
setHasReordered(false);
onRefresh();
} else { toast.error(res.message); }
} catch { toast.error('Failed to save order'); }
finally { setSaving(false); }
};
const handlePublish = async (id: string) => {
if (!confirm('Publish this placement? It will be visible to users.')) return;
setLoadingAction(id);
try {
const res = await publishPlacement(id);
res.success ? toast.success(res.message) : toast.error(res.message);
onRefresh();
} catch { toast.error('Publish failed'); }
finally { setLoadingAction(null); }
};
const handleUnpublish = async (id: string) => {
if (!confirm('Unpublish this placement?')) return;
setLoadingAction(id);
try {
const res = await unpublishPlacement(id);
res.success ? toast.success(res.message) : toast.error(res.message);
onRefresh();
} catch { toast.error('Unpublish failed'); }
finally { setLoadingAction(null); }
};
const handleDelete = async (id: string) => {
if (!confirm('Delete this placement permanently?')) return;
setLoadingAction(id);
try {
const res = await deletePlacement(id);
res.success ? toast.success(res.message) : toast.error(res.message);
onRefresh();
} catch { toast.error('Delete failed'); }
finally { setLoadingAction(null); }
};
if (items.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center border-2 border-dashed border-border/50 rounded-xl bg-muted/10">
<Sparkles className="h-10 w-10 text-muted-foreground/40 mb-4" />
<h3 className="text-lg font-semibold text-foreground">No placements yet</h3>
<p className="text-muted-foreground text-sm mt-1 max-w-sm">
Add events to this surface to feature them on the public app.
</p>
</div>
);
}
return (
<div className="space-y-3">
{/* Save Order Bar */}
{hasReordered && (
<div className="flex items-center justify-between bg-primary/5 border border-primary/20 rounded-lg px-4 py-2.5 animate-in slide-in-from-top-2">
<p className="text-sm font-medium text-primary">Order has been changed</p>
<Button size="sm" onClick={handleSaveOrder} disabled={saving} className="gap-2">
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
Save Order
</Button>
</div>
)}
{/* Placement Cards */}
{items.map((placement, index) => {
const statusCfg = STATUS_CONFIG[placement.status];
const priorityCfg = PRIORITY_CONFIG[placement.priority];
const event = placement.event;
const isLoading = loadingAction === placement.id;
return (
<div
key={placement.id}
draggable
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragEnd={handleDragEnd}
className={cn(
'group flex items-center gap-3 rounded-xl border bg-card p-3 transition-all duration-200',
'hover:shadow-md hover:border-primary/20',
dragIndex === index && 'opacity-50 scale-[0.98]',
overIndex === index && dragIndex !== index && 'border-primary border-dashed',
isLoading && 'opacity-50 pointer-events-none',
)}
>
{/* Drag Handle */}
<div className="cursor-grab active:cursor-grabbing text-muted-foreground/40 hover:text-muted-foreground transition-colors">
<GripVertical className="h-5 w-5" />
</div>
{/* Rank */}
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center text-xs font-bold text-muted-foreground flex-shrink-0">
{index + 1}
</div>
{/* Event Cover */}
{event?.coverImage ? (
<img src={event.coverImage} alt="" className="h-14 w-20 rounded-lg object-cover flex-shrink-0" />
) : (
<div className="h-14 w-20 rounded-lg bg-muted flex items-center justify-center flex-shrink-0">
<Sparkles className="h-5 w-5 text-muted-foreground/30" />
</div>
)}
{/* Event Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="font-semibold text-sm truncate">{event?.title || placement.eventId || 'Unknown Event'}</h4>
{placement.boostLabel && (
<Badge variant="outline" className="text-[10px] bg-amber-50 text-amber-700 border-amber-200">
{placement.boostLabel}
</Badge>
)}
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
{event && (
<>
<span className="flex items-center gap-1">
<MapPin className="h-3 w-3" /> {event.city}
</span>
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" /> {new Date(event.date).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })}
</span>
<span>{event.organizer}</span>
</>
)}
</div>
</div>
{/* Priority Badge */}
<Badge variant="secondary" className={cn('text-[10px] h-5', priorityCfg.color)}>
{priorityCfg.label}
</Badge>
{/* Status Badge */}
<Badge variant="outline" className={cn('text-[10px] h-5 gap-1', statusCfg.color)}>
<span className={cn('h-1.5 w-1.5 rounded-full', statusCfg.dot)} />
{statusCfg.label}
</Badge>
{/* Schedule */}
{(placement.startAt || placement.endAt) && (
<div className="text-[10px] text-muted-foreground text-right leading-tight hidden lg:block">
{placement.startAt && <div>{new Date(placement.startAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })}</div>}
{placement.endAt && <div> {new Date(placement.endAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })}</div>}
</div>
)}
{/* Actions */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem onClick={() => onEdit(placement)}>
<Pencil className="mr-2 h-4 w-4" /> Edit
</DropdownMenuItem>
{(placement.status === 'DRAFT' || placement.status === 'DISABLED') && (
<DropdownMenuItem onClick={() => handlePublish(placement.id)}>
<Power className="mr-2 h-4 w-4 text-emerald-600" /> Publish
</DropdownMenuItem>
)}
{(placement.status === 'ACTIVE' || placement.status === 'SCHEDULED') && (
<DropdownMenuItem onClick={() => handleUnpublish(placement.id)}>
<PowerOff className="mr-2 h-4 w-4 text-orange-600" /> Unpublish
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleDelete(placement.id)} className="text-red-600">
<Trash2 className="mr-2 h-4 w-4" /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,74 @@
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import {
Sparkles, TrendingUp, Layers, MapPin, Search,
} from 'lucide-react';
import type { Surface, PlacementItem } from '@/lib/types/ad-control';
const SURFACE_ICONS: Record<string, React.ElementType> = {
Sparkles, TrendingUp, Layers, MapPin, Search,
};
interface SurfaceTabsProps {
surfaces: Surface[];
activeSurfaceId: string;
onSelect: (surfaceId: string) => void;
placementCounts: Record<string, number>; // surfaceId → active/scheduled count
}
export function SurfaceTabs({ surfaces, activeSurfaceId, onSelect, placementCounts }: SurfaceTabsProps) {
return (
<div className="w-64 flex-shrink-0">
<div className="sticky top-24 space-y-1.5">
<p className="px-3 text-[10px] uppercase tracking-widest text-muted-foreground font-semibold mb-3">
Placement Surfaces
</p>
{surfaces.map((surface) => {
const Icon = SURFACE_ICONS[surface.icon] || Sparkles;
const count = placementCounts[surface.id] || 0;
const isActive = surface.id === activeSurfaceId;
return (
<button
key={surface.id}
onClick={() => onSelect(surface.id)}
className={cn(
'w-full flex items-center gap-3 px-3 py-3 rounded-xl text-left transition-all duration-200',
isActive
? 'bg-primary text-primary-foreground shadow-lg'
: 'hover:bg-muted/50 text-foreground'
)}
>
<div className={cn(
'h-9 w-9 rounded-lg flex items-center justify-center flex-shrink-0',
isActive ? 'bg-white/20' : 'bg-muted'
)}>
<Icon className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{surface.name.replace('Home ', '').replace(' Events', '')}</p>
<p className={cn(
'text-[11px]',
isActive ? 'text-primary-foreground/70' : 'text-muted-foreground'
)}>
{surface.layoutType} · {surface.sortBehavior}
</p>
</div>
<Badge
variant={isActive ? 'secondary' : 'outline'}
className={cn(
'text-[10px] h-5 min-w-[36px] justify-center font-mono',
isActive && 'bg-white/20 text-primary-foreground border-transparent',
count >= surface.maxSlots && !isActive && 'bg-red-50 text-red-600 border-red-200'
)}
>
{count}/{surface.maxSlots}
</Badge>
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,269 @@
// Ad Control — Mock Data: Surfaces, Events, and Seed Placements
import type {
Surface, PlacementItem, PickerEvent,
} from '@/lib/types/ad-control';
// ===== SURFACES =====
export const MOCK_SURFACES: Surface[] = [
{
id: 'srf-001', key: 'HOME_FEATURED_CAROUSEL', name: 'Home Featured Carousel',
description: 'Main hero carousel on the homepage — high visibility, prime real estate',
maxSlots: 8, layoutType: 'carousel', sortBehavior: 'rank', icon: 'Sparkles',
createdAt: '2025-06-01T00:00:00Z',
},
{
id: 'srf-002', key: 'HOME_TOP_EVENTS', name: 'Home Top Events',
description: 'Curated "Top Events" grid below the carousel on the homepage',
maxSlots: 12, layoutType: 'grid', sortBehavior: 'rank', icon: 'TrendingUp',
createdAt: '2025-06-01T00:00:00Z',
},
{
id: 'srf-003', key: 'CATEGORY_FEATURED', name: 'Category Featured',
description: 'Featured events pinned at the top of category pages',
maxSlots: 6, layoutType: 'grid', sortBehavior: 'rank', icon: 'Layers',
createdAt: '2025-07-15T00:00:00Z',
},
{
id: 'srf-004', key: 'CITY_TRENDING', name: 'City Trending',
description: 'Trending events shown on city landing pages',
maxSlots: 10, layoutType: 'list', sortBehavior: 'popularity', icon: 'MapPin',
createdAt: '2025-08-01T00:00:00Z',
},
{
id: 'srf-005', key: 'SEARCH_BOOSTED', name: 'Search Boosted',
description: 'Sponsored results that appear at the top of search results',
maxSlots: 5, layoutType: 'list', sortBehavior: 'rank', icon: 'Search',
createdAt: '2025-09-01T00:00:00Z',
},
];
// ===== MOCK EVENTS (for picker) =====
export const MOCK_PICKER_EVENTS: PickerEvent[] = [
{
id: 'evt-101', title: 'Mumbai Music Festival 2026', city: 'Mumbai', state: 'Maharashtra', country: 'IN',
date: '2026-03-15T18:00:00Z', endDate: '2026-03-17T23:00:00Z', organizer: 'SoundWave Productions',
organizerLogo: 'https://i.pravatar.cc/40?u=soundwave', category: 'Music',
coverImage: 'https://images.unsplash.com/photo-1459749411175-04bf5292ceea?w=400', approvalStatus: 'APPROVED',
ticketsSold: 4200, capacity: 8000,
},
{
id: 'evt-102', title: 'Delhi Tech Summit', city: 'New Delhi', state: 'Delhi', country: 'IN',
date: '2026-03-20T09:00:00Z', endDate: '2026-03-21T18:00:00Z', organizer: 'TechConf India',
organizerLogo: 'https://i.pravatar.cc/40?u=techconf', category: 'Technology',
coverImage: 'https://images.unsplash.com/photo-1540575467063-178a50c2df87?w=400', approvalStatus: 'APPROVED',
ticketsSold: 1800, capacity: 3000,
},
{
id: 'evt-103', title: 'Bangalore Food Carnival', city: 'Bangalore', state: 'Karnataka', country: 'IN',
date: '2026-04-05T11:00:00Z', endDate: '2026-04-07T22:00:00Z', organizer: 'FoodieHub',
organizerLogo: 'https://i.pravatar.cc/40?u=foodiehub', category: 'Food & Drink',
coverImage: 'https://images.unsplash.com/photo-1555939594-58d7cb561ad1?w=400', approvalStatus: 'APPROVED',
ticketsSold: 3100, capacity: 5000,
},
{
id: 'evt-104', title: 'Hyderabad Comedy Night', city: 'Hyderabad', state: 'Telangana', country: 'IN',
date: '2026-03-28T19:00:00Z', endDate: '2026-03-28T22:00:00Z', organizer: 'LaughFactory',
organizerLogo: 'https://i.pravatar.cc/40?u=laughfactory', category: 'Comedy',
coverImage: 'https://images.unsplash.com/photo-1527224857830-43a7acc85260?w=400', approvalStatus: 'APPROVED',
ticketsSold: 450, capacity: 600,
},
{
id: 'evt-105', title: 'Chennai Classical Dance Festival', city: 'Chennai', state: 'Tamil Nadu', country: 'IN',
date: '2026-04-12T17:00:00Z', endDate: '2026-04-14T21:00:00Z', organizer: 'Natya Academy',
organizerLogo: 'https://i.pravatar.cc/40?u=natya', category: 'Arts & Culture',
coverImage: 'https://images.unsplash.com/photo-1508700115892-45ecd05ae2ad?w=400', approvalStatus: 'APPROVED',
ticketsSold: 900, capacity: 1500,
},
{
id: 'evt-106', title: 'Pune Marathon 2026', city: 'Pune', state: 'Maharashtra', country: 'IN',
date: '2026-03-10T05:30:00Z', endDate: '2026-03-10T12:00:00Z', organizer: 'RunIndia',
organizerLogo: 'https://i.pravatar.cc/40?u=runindia', category: 'Sports',
coverImage: 'https://images.unsplash.com/photo-1513593771513-7b58b6c4af38?w=400', approvalStatus: 'APPROVED',
ticketsSold: 6500, capacity: 10000,
},
{
id: 'evt-107', title: 'Goa Sunburn Festival', city: 'Goa', state: 'Goa', country: 'IN',
date: '2026-04-25T14:00:00Z', endDate: '2026-04-27T04:00:00Z', organizer: 'Sunburn Events',
organizerLogo: 'https://i.pravatar.cc/40?u=sunburn', category: 'Music',
coverImage: 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400', approvalStatus: 'APPROVED',
ticketsSold: 12000, capacity: 20000,
},
{
id: 'evt-108', title: 'Jaipur Literature Fest', city: 'Jaipur', state: 'Rajasthan', country: 'IN',
date: '2026-02-20T10:00:00Z', endDate: '2026-02-24T18:00:00Z', organizer: 'JLF Foundation',
organizerLogo: 'https://i.pravatar.cc/40?u=jlf', category: 'Arts & Culture',
coverImage: 'https://images.unsplash.com/photo-1481627834876-b7833e8f5570?w=400', approvalStatus: 'APPROVED',
ticketsSold: 2200, capacity: 5000,
},
{
id: 'evt-109', title: 'Kolkata Film Festival', city: 'Kolkata', state: 'West Bengal', country: 'IN',
date: '2026-05-10T10:00:00Z', endDate: '2026-05-17T22:00:00Z', organizer: 'KIFF',
organizerLogo: 'https://i.pravatar.cc/40?u=kiff', category: 'Film',
coverImage: 'https://images.unsplash.com/photo-1489599849927-2ee91cede3ba?w=400', approvalStatus: 'PENDING',
ticketsSold: 0, capacity: 3000,
},
{
id: 'evt-110', title: 'Ahmedabad Startup Week', city: 'Ahmedabad', state: 'Gujarat', country: 'IN',
date: '2026-04-01T09:00:00Z', endDate: '2026-04-05T18:00:00Z', organizer: 'Startup Gujarat',
organizerLogo: 'https://i.pravatar.cc/40?u=startupguj', category: 'Business',
coverImage: null, approvalStatus: 'APPROVED',
ticketsSold: 800, capacity: 2000,
},
{
id: 'evt-111', title: 'Mumbai Art Walk', city: 'Mumbai', state: 'Maharashtra', country: 'IN',
date: '2026-03-08T16:00:00Z', endDate: '2026-03-08T21:00:00Z', organizer: 'Art District',
organizerLogo: 'https://i.pravatar.cc/40?u=artdistrict', category: 'Arts & Culture',
coverImage: 'https://images.unsplash.com/photo-1531243269054-5ebf6f34081e?w=400', approvalStatus: 'APPROVED',
ticketsSold: 320, capacity: 500,
},
{
id: 'evt-112', title: 'Delhi Wine Experience', city: 'New Delhi', state: 'Delhi', country: 'IN',
date: '2026-03-22T18:00:00Z', endDate: '2026-03-22T23:00:00Z', organizer: 'Vineyard Co.',
organizerLogo: 'https://i.pravatar.cc/40?u=vineyard', category: 'Food & Drink',
coverImage: 'https://images.unsplash.com/photo-1510812431401-41d2bd2722f3?w=400', approvalStatus: 'APPROVED',
ticketsSold: 180, capacity: 200,
},
{
id: 'evt-113', title: 'Bangalore Indie Music Showcase', city: 'Bangalore', state: 'Karnataka', country: 'IN',
date: '2026-03-30T19:00:00Z', endDate: '2026-03-30T23:00:00Z', organizer: 'IndieWave',
organizerLogo: 'https://i.pravatar.cc/40?u=indiewave', category: 'Music',
coverImage: 'https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=400', approvalStatus: 'APPROVED',
ticketsSold: 700, capacity: 1000,
},
{
id: 'evt-114', title: 'Old Event (Ended)', city: 'Mumbai', state: 'Maharashtra', country: 'IN',
date: '2025-12-01T10:00:00Z', endDate: '2025-12-03T20:00:00Z', organizer: 'Past Events Co.',
organizerLogo: 'https://i.pravatar.cc/40?u=pastevents', category: 'Music',
coverImage: 'https://images.unsplash.com/photo-1501281668745-f7f57925c3b4?w=400', approvalStatus: 'APPROVED',
ticketsSold: 5000, capacity: 5000,
},
{
id: 'evt-115', title: 'Rejected Event Example', city: 'Pune', state: 'Maharashtra', country: 'IN',
date: '2026-05-01T18:00:00Z', endDate: '2026-05-01T22:00:00Z', organizer: 'Shady Promo',
organizerLogo: 'https://i.pravatar.cc/40?u=shady', category: 'Nightlife',
coverImage: 'https://images.unsplash.com/photo-1516450360452-9312f5e86fc7?w=400', approvalStatus: 'REJECTED',
ticketsSold: 0, capacity: 500,
},
];
// ===== SEED PLACEMENTS =====
export const MOCK_PLACEMENTS: PlacementItem[] = [
// HOME_FEATURED_CAROUSEL — 4 active items
{
id: 'plc-001', surfaceId: 'srf-001', itemType: 'EVENT', eventId: 'evt-101',
status: 'ACTIVE', priority: 'SPONSORED', rank: 1,
startAt: '2026-02-01T00:00:00Z', endAt: '2026-03-20T00:00:00Z',
targeting: { cityIds: [], categoryIds: [], countryCodes: ['IN'] },
boostLabel: 'Featured', notes: 'Headline sponsor placement', createdBy: 'admin-1', updatedBy: 'admin-1',
createdAt: '2026-01-28T10:00:00Z', updatedAt: '2026-02-05T14:00:00Z',
},
{
id: 'plc-002', surfaceId: 'srf-001', itemType: 'EVENT', eventId: 'evt-107',
status: 'ACTIVE', priority: 'MANUAL', rank: 2,
startAt: '2026-02-10T00:00:00Z', endAt: '2026-04-28T00:00:00Z',
targeting: { cityIds: [], categoryIds: [], countryCodes: ['IN'] },
boostLabel: 'Featured', notes: 'High demand festival', createdBy: 'admin-1', updatedBy: 'admin-1',
createdAt: '2026-02-01T10:00:00Z', updatedAt: '2026-02-10T09:00:00Z',
},
{
id: 'plc-003', surfaceId: 'srf-001', itemType: 'EVENT', eventId: 'evt-102',
status: 'ACTIVE', priority: 'MANUAL', rank: 3,
startAt: null, endAt: null,
targeting: { cityIds: ['delhi'], categoryIds: ['technology'], countryCodes: ['IN'] },
boostLabel: null, notes: null, createdBy: 'admin-1', updatedBy: 'admin-1',
createdAt: '2026-02-05T11:00:00Z', updatedAt: '2026-02-05T11:00:00Z',
},
{
id: 'plc-004', surfaceId: 'srf-001', itemType: 'EVENT', eventId: 'evt-105',
status: 'SCHEDULED', priority: 'MANUAL', rank: 4,
startAt: '2026-03-01T00:00:00Z', endAt: '2026-04-15T00:00:00Z',
targeting: { cityIds: ['chennai'], categoryIds: [], countryCodes: ['IN'] },
boostLabel: 'Featured', notes: 'Scheduled for March launch', createdBy: 'admin-1', updatedBy: 'admin-1',
createdAt: '2026-02-08T09:00:00Z', updatedAt: '2026-02-08T09:00:00Z',
},
// HOME_TOP_EVENTS — 3 items
{
id: 'plc-005', surfaceId: 'srf-002', itemType: 'EVENT', eventId: 'evt-103',
status: 'ACTIVE', priority: 'MANUAL', rank: 1,
startAt: null, endAt: null,
targeting: { cityIds: ['bangalore'], categoryIds: ['food-drink'], countryCodes: ['IN'] },
boostLabel: 'Top', notes: null, createdBy: 'admin-1', updatedBy: 'admin-1',
createdAt: '2026-02-03T10:00:00Z', updatedAt: '2026-02-03T10:00:00Z',
},
{
id: 'plc-006', surfaceId: 'srf-002', itemType: 'EVENT', eventId: 'evt-106',
status: 'ACTIVE', priority: 'MANUAL', rank: 2,
startAt: null, endAt: null,
targeting: { cityIds: ['pune'], categoryIds: ['sports'], countryCodes: ['IN'] },
boostLabel: 'Top', notes: null, createdBy: 'admin-1', updatedBy: 'admin-1',
createdAt: '2026-02-04T12:00:00Z', updatedAt: '2026-02-04T12:00:00Z',
},
{
id: 'plc-007', surfaceId: 'srf-002', itemType: 'EVENT', eventId: 'evt-104',
status: 'DRAFT', priority: 'MANUAL', rank: 3,
startAt: null, endAt: null,
targeting: { cityIds: ['hyderabad'], categoryIds: ['comedy'], countryCodes: ['IN'] },
boostLabel: null, notes: 'Pending manager approval', createdBy: 'admin-1', updatedBy: 'admin-1',
createdAt: '2026-02-07T15:00:00Z', updatedAt: '2026-02-07T15:00:00Z',
},
// CITY_TRENDING
{
id: 'plc-008', surfaceId: 'srf-004', itemType: 'EVENT', eventId: 'evt-111',
status: 'ACTIVE', priority: 'ALGO', rank: 1,
startAt: null, endAt: null,
targeting: { cityIds: ['mumbai'], categoryIds: [], countryCodes: ['IN'] },
boostLabel: null, notes: 'Auto-promoted by algorithm', createdBy: 'system', updatedBy: 'system',
createdAt: '2026-02-09T00:00:00Z', updatedAt: '2026-02-09T00:00:00Z',
},
{
id: 'plc-009', surfaceId: 'srf-004', itemType: 'EVENT', eventId: 'evt-113',
status: 'ACTIVE', priority: 'MANUAL', rank: 2,
startAt: null, endAt: null,
targeting: { cityIds: ['bangalore'], categoryIds: ['music'], countryCodes: ['IN'] },
boostLabel: null, notes: null, createdBy: 'admin-1', updatedBy: 'admin-1',
createdAt: '2026-02-09T08:00:00Z', updatedAt: '2026-02-09T08:00:00Z',
},
// Expired placement
{
id: 'plc-010', surfaceId: 'srf-001', itemType: 'EVENT', eventId: 'evt-114',
status: 'EXPIRED', priority: 'MANUAL', rank: 99,
startAt: '2025-11-15T00:00:00Z', endAt: '2025-12-05T00:00:00Z',
targeting: { cityIds: ['mumbai'], categoryIds: [], countryCodes: ['IN'] },
boostLabel: 'Featured', notes: 'Event has ended', createdBy: 'admin-1', updatedBy: 'admin-1',
createdAt: '2025-11-10T10:00:00Z', updatedAt: '2025-12-05T00:01:00Z',
},
];
// ===== Targeting options for UI =====
export const MOCK_CITIES = [
{ id: 'mumbai', name: 'Mumbai' },
{ id: 'delhi', name: 'New Delhi' },
{ id: 'bangalore', name: 'Bangalore' },
{ id: 'hyderabad', name: 'Hyderabad' },
{ id: 'chennai', name: 'Chennai' },
{ id: 'pune', name: 'Pune' },
{ id: 'kolkata', name: 'Kolkata' },
{ id: 'jaipur', name: 'Jaipur' },
{ id: 'goa', name: 'Goa' },
{ id: 'ahmedabad', name: 'Ahmedabad' },
];
export const MOCK_CATEGORIES = [
{ id: 'music', name: 'Music' },
{ id: 'technology', name: 'Technology' },
{ id: 'food-drink', name: 'Food & Drink' },
{ id: 'comedy', name: 'Comedy' },
{ id: 'arts-culture', name: 'Arts & Culture' },
{ id: 'sports', name: 'Sports' },
{ id: 'business', name: 'Business' },
{ id: 'film', name: 'Film' },
{ id: 'nightlife', name: 'Nightlife' },
];

View File

@@ -0,0 +1,280 @@
'use server';
// Ad Control — Server Actions (localStorage-persisted mock)
import type {
Surface, PlacementItem, PlacementConfigData, PlacementWithEvent, PlacementStatus,
} from '@/lib/types/ad-control';
import { MOCK_SURFACES, MOCK_PLACEMENTS, MOCK_PICKER_EVENTS } from '@/features/ad-control/data/mockAdData';
import { logPlacementAction, getPlacementAuditLog } from '@/lib/audit/placement-audit';
const PLACEMENTS_KEY = 'ad_control_placements';
// --- Persistence Helpers ---
function getPlacementsStore(): PlacementItem[] {
if (typeof window === 'undefined') return MOCK_PLACEMENTS;
try {
const raw = localStorage.getItem(PLACEMENTS_KEY);
return raw ? JSON.parse(raw) : MOCK_PLACEMENTS;
} catch { return MOCK_PLACEMENTS; }
}
function savePlacementsStore(items: PlacementItem[]) {
if (typeof window === 'undefined') return;
localStorage.setItem(PLACEMENTS_KEY, JSON.stringify(items));
}
// --- Auto-expire check ---
function autoExpire(items: PlacementItem[]): PlacementItem[] {
const now = new Date().toISOString();
let changed = false;
const updated = items.map(p => {
if ((p.status === 'ACTIVE' || p.status === 'SCHEDULED') && p.endAt && p.endAt < now) {
changed = true;
return { ...p, status: 'EXPIRED' as PlacementStatus, updatedAt: now };
}
return p;
});
if (changed) savePlacementsStore(updated);
return updated;
}
// --- Resolve events ---
function resolveEvent(p: PlacementItem): PlacementWithEvent {
const event = p.eventId ? MOCK_PICKER_EVENTS.find(e => e.id === p.eventId) : undefined;
return { ...p, event };
}
// ===== QUERIES =====
export async function getSurfaces(): Promise<{ success: boolean; data: Surface[] }> {
await new Promise(r => setTimeout(r, 200));
return { success: true, data: MOCK_SURFACES };
}
export async function getPlacements(
surfaceId?: string,
status?: PlacementStatus | 'ALL',
): Promise<{ success: boolean; data: PlacementWithEvent[] }> {
await new Promise(r => setTimeout(r, 300));
let items = autoExpire(getPlacementsStore());
if (surfaceId) items = items.filter(p => p.surfaceId === surfaceId);
if (status && status !== 'ALL') items = items.filter(p => p.status === status);
items.sort((a, b) => a.rank - b.rank);
return { success: true, data: items.map(resolveEvent) };
}
export async function getPickerEvents(): Promise<{ success: boolean; data: typeof MOCK_PICKER_EVENTS }> {
await new Promise(r => setTimeout(r, 200));
return { success: true, data: MOCK_PICKER_EVENTS };
}
export async function getPlacementAudit(placementId: string) {
await new Promise(r => setTimeout(r, 200));
return { success: true, data: getPlacementAuditLog(placementId) };
}
// ===== MUTATIONS =====
export async function createPlacement(
surfaceId: string,
eventId: string,
config: PlacementConfigData,
): Promise<{ success: boolean; message: string; data?: PlacementItem }> {
await new Promise(r => setTimeout(r, 500));
const store = getPlacementsStore();
const surface = MOCK_SURFACES.find(s => s.id === surfaceId);
if (!surface) return { success: false, message: 'Surface not found' };
// Check max slots (only active/scheduled count)
const activeCount = store.filter(p => p.surfaceId === surfaceId && (p.status === 'ACTIVE' || p.status === 'SCHEDULED')).length;
if (activeCount >= surface.maxSlots) {
return { success: false, message: `Surface "${surface.name}" is full (${surface.maxSlots} max slots)` };
}
// Check duplicate
if (store.some(p => p.surfaceId === surfaceId && p.eventId === eventId && p.status !== 'EXPIRED' && p.status !== 'DISABLED')) {
return { success: false, message: 'This event is already placed on this surface' };
}
const maxRank = store.filter(p => p.surfaceId === surfaceId).reduce((max, p) => Math.max(max, p.rank), 0);
const now = new Date().toISOString();
const newItem: PlacementItem = {
id: `plc-${Date.now()}`,
surfaceId,
itemType: 'EVENT',
eventId,
status: 'DRAFT',
priority: config.priority,
rank: maxRank + 1,
startAt: config.startAt,
endAt: config.endAt,
targeting: config.targeting,
boostLabel: config.boostLabel,
notes: config.notes,
createdBy: 'admin-1',
updatedBy: 'admin-1',
createdAt: now,
updatedAt: now,
};
store.push(newItem);
savePlacementsStore(store);
logPlacementAction(newItem.id, 'admin-1', 'CREATED', null, newItem);
return { success: true, message: 'Placement created as draft', data: newItem };
}
export async function updatePlacement(
id: string,
patch: Partial<PlacementConfigData>,
): Promise<{ success: boolean; message: string }> {
await new Promise(r => setTimeout(r, 400));
const store = getPlacementsStore();
const idx = store.findIndex(p => p.id === id);
if (idx === -1) return { success: false, message: 'Placement not found' };
const before = { ...store[idx] };
const now = new Date().toISOString();
if (patch.startAt !== undefined) store[idx].startAt = patch.startAt;
if (patch.endAt !== undefined) store[idx].endAt = patch.endAt;
if (patch.targeting) store[idx].targeting = patch.targeting;
if (patch.boostLabel !== undefined) store[idx].boostLabel = patch.boostLabel;
if (patch.priority) store[idx].priority = patch.priority;
if (patch.notes !== undefined) store[idx].notes = patch.notes;
store[idx].updatedBy = 'admin-1';
store[idx].updatedAt = now;
savePlacementsStore(store);
logPlacementAction(id, 'admin-1', 'UPDATED', before, store[idx]);
return { success: true, message: 'Placement updated' };
}
export async function publishPlacement(id: string): Promise<{ success: boolean; message: string }> {
await new Promise(r => setTimeout(r, 400));
const store = getPlacementsStore();
const idx = store.findIndex(p => p.id === id);
if (idx === -1) return { success: false, message: 'Placement not found' };
const before = { ...store[idx] };
const now = new Date().toISOString();
// If has future startAt, mark as SCHEDULED instead
if (store[idx].startAt && new Date(store[idx].startAt!) > new Date()) {
store[idx].status = 'SCHEDULED';
} else {
store[idx].status = 'ACTIVE';
}
store[idx].updatedBy = 'admin-1';
store[idx].updatedAt = now;
savePlacementsStore(store);
logPlacementAction(id, 'admin-1', 'PUBLISHED', before, store[idx]);
return { success: true, message: `Placement ${store[idx].status === 'SCHEDULED' ? 'scheduled' : 'published'}` };
}
export async function unpublishPlacement(id: string): Promise<{ success: boolean; message: string }> {
await new Promise(r => setTimeout(r, 400));
const store = getPlacementsStore();
const idx = store.findIndex(p => p.id === id);
if (idx === -1) return { success: false, message: 'Placement not found' };
const before = { ...store[idx] };
store[idx].status = 'DISABLED';
store[idx].updatedBy = 'admin-1';
store[idx].updatedAt = new Date().toISOString();
savePlacementsStore(store);
logPlacementAction(id, 'admin-1', 'UNPUBLISHED', before, store[idx]);
return { success: true, message: 'Placement unpublished' };
}
export async function reorderPlacements(
surfaceId: string,
orderedIds: string[],
): Promise<{ success: boolean; message: string }> {
await new Promise(r => setTimeout(r, 600));
const store = getPlacementsStore();
// Update ranks transactionally
orderedIds.forEach((id, index) => {
const item = store.find(p => p.id === id && p.surfaceId === surfaceId);
if (item) {
item.rank = index + 1;
item.updatedAt = new Date().toISOString();
}
});
savePlacementsStore(store);
logPlacementAction(surfaceId, 'admin-1', 'REORDERED', null, { orderedIds });
return { success: true, message: `Reordered ${orderedIds.length} placements` };
}
export async function deletePlacement(id: string): Promise<{ success: boolean; message: string }> {
await new Promise(r => setTimeout(r, 400));
const store = getPlacementsStore();
const item = store.find(p => p.id === id);
if (!item) return { success: false, message: 'Placement not found' };
logPlacementAction(id, 'admin-1', 'DELETED', item, null);
const updated = store.filter(p => p.id !== id);
savePlacementsStore(updated);
return { success: true, message: 'Placement deleted' };
}
// ===== PUBLIC API (mock) =====
export async function getPublicPlacements(
surfaceKey: string,
city?: string,
category?: string,
): Promise<{ success: boolean; data: PlacementWithEvent[] }> {
await new Promise(r => setTimeout(r, 200));
const surface = MOCK_SURFACES.find(s => s.key === surfaceKey);
if (!surface) return { success: true, data: [] };
const now = new Date().toISOString();
let items = autoExpire(getPlacementsStore())
.filter(p => p.surfaceId === surface.id && p.status === 'ACTIVE')
.filter(p => !p.startAt || p.startAt <= now)
.filter(p => !p.endAt || p.endAt > now);
// Apply targeting filters
if (city) {
items = items.filter(p => p.targeting.cityIds.length === 0 || p.targeting.cityIds.includes(city));
}
if (category) {
items = items.filter(p => p.targeting.categoryIds.length === 0 || p.targeting.categoryIds.includes(category));
}
items.sort((a, b) => {
const priorityOrder = { SPONSORED: 0, MANUAL: 1, ALGO: 2 };
if (priorityOrder[a.priority] !== priorityOrder[b.priority]) {
return priorityOrder[a.priority] - priorityOrder[b.priority];
}
return a.rank - b.rank;
});
return { success: true, data: items.slice(0, surface.maxSlots).map(resolveEvent) };
}

View File

@@ -0,0 +1,50 @@
// Placement Audit Logger — localStorage-backed audit trail for ad placements
import type { PlacementAuditEntry } from '@/lib/types/ad-control';
const AUDIT_KEY = 'placement_audit_log';
function getAuditStore(): PlacementAuditEntry[] {
if (typeof window === 'undefined') return [];
try {
return JSON.parse(localStorage.getItem(AUDIT_KEY) || '[]');
} catch { return []; }
}
function saveAuditStore(entries: PlacementAuditEntry[]) {
if (typeof window === 'undefined') return;
localStorage.setItem(AUDIT_KEY, JSON.stringify(entries));
}
export function logPlacementAction(
placementId: string,
actorId: string,
action: string,
before?: Record<string, any> | null,
after?: Record<string, any> | null,
): PlacementAuditEntry {
const entry: PlacementAuditEntry = {
id: `paudit-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
placementId,
actorId,
action,
before: before ?? null,
after: after ?? null,
createdAt: new Date().toISOString(),
};
const store = getAuditStore();
store.unshift(entry); // newest first
saveAuditStore(store.slice(0, 500)); // cap at 500 entries
console.log(`[PLACEMENT AUDIT] ${actorId}${action} on ${placementId}`);
return entry;
}
export function getPlacementAuditLog(placementId: string): PlacementAuditEntry[] {
return getAuditStore().filter(e => e.placementId === placementId);
}
export function getAllAuditLogs(): PlacementAuditEntry[] {
return getAuditStore();
}

110
src/lib/types/ad-control.ts Normal file
View File

@@ -0,0 +1,110 @@
// Ad Control Module — Types & Interfaces
// ===== Enums =====
export type SurfaceKey =
| 'HOME_FEATURED_CAROUSEL'
| 'HOME_TOP_EVENTS'
| 'CATEGORY_FEATURED'
| 'CITY_TRENDING'
| 'SEARCH_BOOSTED';
export type PlacementStatus = 'DRAFT' | 'ACTIVE' | 'SCHEDULED' | 'EXPIRED' | 'DISABLED';
export type PlacementPriority = 'SPONSORED' | 'MANUAL' | 'ALGO';
export type ItemType = 'EVENT' | 'BANNER' | 'COLLECTION';
export type LayoutType = 'carousel' | 'grid' | 'list';
export type SortBehavior = 'rank' | 'date' | 'popularity';
// ===== Surface =====
export interface Surface {
id: string;
key: SurfaceKey;
name: string;
description: string;
maxSlots: number;
layoutType: LayoutType;
sortBehavior: SortBehavior;
icon: string; // lucide icon name
createdAt: string;
}
// ===== Placement Targeting =====
export interface PlacementTargeting {
cityIds: string[];
categoryIds: string[];
countryCodes: string[];
}
// ===== Placement Item =====
export interface PlacementItem {
id: string;
surfaceId: string;
itemType: ItemType;
eventId: string | null;
bannerId?: string | null;
status: PlacementStatus;
priority: PlacementPriority;
rank: number;
startAt: string | null;
endAt: string | null;
targeting: PlacementTargeting;
boostLabel: string | null; // "Featured" | "Top" | "Sponsored" | null
notes: string | null;
createdBy: string;
updatedBy: string;
createdAt: string;
updatedAt: string;
}
// ===== Placement Audit =====
export interface PlacementAuditEntry {
id: string;
placementId: string;
actorId: string;
action: string; // CREATED | PUBLISHED | UNPUBLISHED | REORDERED | UPDATED | DELETED
before: Record<string, any> | null;
after: Record<string, any> | null;
createdAt: string;
}
// ===== Mock Event (for Picker) =====
export type EventApprovalStatus = 'APPROVED' | 'PENDING' | 'REJECTED';
export interface PickerEvent {
id: string;
title: string;
city: string;
state: string;
country: string;
date: string; // event start date
endDate: string; // event end date
organizer: string;
organizerLogo: string;
category: string;
coverImage: string | null;
approvalStatus: EventApprovalStatus;
ticketsSold: number;
capacity: number;
}
// ===== Placement with resolved event =====
export interface PlacementWithEvent extends PlacementItem {
event?: PickerEvent;
}
// ===== Config form data =====
export interface PlacementConfigData {
startAt: string | null;
endAt: string | null;
targeting: PlacementTargeting;
boostLabel: string | null;
priority: PlacementPriority;
notes: string | null;
}

244
src/pages/AdControl.tsx Normal file
View File

@@ -0,0 +1,244 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { TooltipProvider } from '@/components/ui/tooltip';
import { AppLayout } from '@/components/layout/AppLayout';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Plus, Search, RefreshCw, Loader2, Megaphone } from 'lucide-react';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import { SurfaceTabs } from '@/features/ad-control/components/SurfaceTabs';
import { PlacementList } from '@/features/ad-control/components/PlacementList';
import { EventPickerModal } from '@/features/ad-control/components/EventPickerModal';
import { PlacementConfigDrawer } from '@/features/ad-control/components/PlacementConfigDrawer';
import { getSurfaces, getPlacements, getPickerEvents } from '@/lib/actions/ad-control';
import type { Surface, PlacementWithEvent, PlacementStatus, PickerEvent } from '@/lib/types/ad-control';
type StatusFilter = 'ALL' | PlacementStatus;
export default function AdControl() {
// Data
const [surfaces, setSurfaces] = useState<Surface[]>([]);
const [placements, setPlacements] = useState<PlacementWithEvent[]>([]);
const [pickerEvents, setPickerEvents] = useState<PickerEvent[]>([]);
const [allPlacements, setAllPlacements] = useState<PlacementWithEvent[]>([]);
// UI State
const [activeSurfaceId, setActiveSurfaceId] = useState('');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');
const [searchQuery, setSearchQuery] = useState('');
const [loading, setLoading] = useState(true);
// Modals
const [pickerOpen, setPickerOpen] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const [selectedEvent, setSelectedEvent] = useState<PickerEvent | null>(null);
const [editingPlacement, setEditingPlacement] = useState<PlacementWithEvent | null>(null);
// --- Data Fetching ---
const loadSurfaces = useCallback(async () => {
const res = await getSurfaces();
if (res.success) {
setSurfaces(res.data);
if (!activeSurfaceId && res.data.length > 0) setActiveSurfaceId(res.data[0].id);
}
}, []);
const loadPlacements = useCallback(async () => {
// Load all placements for count badges
const allRes = await getPlacements();
if (allRes.success) setAllPlacements(allRes.data);
// Load filtered for active surface
if (!activeSurfaceId) return;
const res = await getPlacements(activeSurfaceId, statusFilter);
if (res.success) setPlacements(res.data);
}, [activeSurfaceId, statusFilter]);
const loadPickerEvents = useCallback(async () => {
const res = await getPickerEvents();
if (res.success) setPickerEvents(res.data);
}, []);
useEffect(() => {
const init = async () => {
setLoading(true);
await loadSurfaces();
await loadPickerEvents();
setLoading(false);
};
init();
}, []);
useEffect(() => {
if (activeSurfaceId) loadPlacements();
}, [activeSurfaceId, statusFilter]);
const handleRefresh = useCallback(async () => {
setLoading(true);
await loadPlacements();
setLoading(false);
}, [loadPlacements]);
// --- Computed ---
const activeSurface = surfaces.find(s => s.id === activeSurfaceId);
const placementCounts = useMemo(() => {
const counts: Record<string, number> = {};
allPlacements.forEach(p => {
if (p.status === 'ACTIVE' || p.status === 'SCHEDULED') {
counts[p.surfaceId] = (counts[p.surfaceId] || 0) + 1;
}
});
return counts;
}, [allPlacements]);
const filteredPlacements = useMemo(() => {
if (!searchQuery.trim()) return placements;
const q = searchQuery.toLowerCase();
return placements.filter(p =>
p.event?.title.toLowerCase().includes(q) ||
p.event?.city.toLowerCase().includes(q) ||
p.event?.organizer.toLowerCase().includes(q) ||
p.eventId?.toLowerCase().includes(q)
);
}, [placements, searchQuery]);
const alreadyPlacedEventIds = useMemo(() => {
return allPlacements
.filter(p => p.surfaceId === activeSurfaceId && p.status !== 'EXPIRED' && p.status !== 'DISABLED')
.map(p => p.eventId)
.filter(Boolean) as string[];
}, [allPlacements, activeSurfaceId]);
const activeCount = placementCounts[activeSurfaceId] || 0;
// --- Handlers ---
const handleEventSelected = (event: PickerEvent) => {
setSelectedEvent(event);
setEditingPlacement(null);
setDrawerOpen(true);
};
const handleEditPlacement = (placement: PlacementWithEvent) => {
setEditingPlacement(placement);
setSelectedEvent(null);
setDrawerOpen(true);
};
const handleDrawerComplete = () => {
setSelectedEvent(null);
setEditingPlacement(null);
handleRefresh();
};
// --- Render ---
if (loading && surfaces.length === 0) {
return (
<AppLayout title="Ad Control" description="Manage event placements and promotions">
<div className="flex items-center justify-center py-32">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</AppLayout>
);
}
return (
<TooltipProvider>
<AppLayout title="Ad Control" description="Manage featured events, top picks, and sponsored placements">
<div className="flex gap-6">
{/* Left — Surface Tabs */}
<SurfaceTabs
surfaces={surfaces}
activeSurfaceId={activeSurfaceId}
onSelect={(id) => { setActiveSurfaceId(id); setStatusFilter('ALL'); setSearchQuery(''); }}
placementCounts={placementCounts}
/>
{/* Right — Placement Editor */}
<div className="flex-1 min-w-0">
{/* Surface Header */}
{activeSurface && (
<div className="mb-6">
<div className="flex items-center justify-between mb-1">
<div>
<h2 className="text-xl font-bold">{activeSurface.name}</h2>
<p className="text-sm text-muted-foreground">{activeSurface.description}</p>
</div>
<div className="flex items-center gap-3">
<Badge variant="outline" className="text-sm py-1 px-3 font-mono">
{activeCount} / {activeSurface.maxSlots} slots used
</Badge>
<Button onClick={() => setPickerOpen(true)} className="gap-2" disabled={activeCount >= activeSurface.maxSlots}>
<Plus className="h-4 w-4" />
Add Event
</Button>
</div>
</div>
</div>
)}
{/* Toolbar */}
<div className="flex items-center gap-3 mb-4">
{/* Search */}
<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={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="Search placements..."
className="pl-10"
/>
</div>
{/* Status Filter */}
<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="SCHEDULED" className="text-xs px-3">Scheduled</TabsTrigger>
<TabsTrigger value="DRAFT" className="text-xs px-3">Draft</TabsTrigger>
<TabsTrigger value="EXPIRED" className="text-xs px-3">Expired</TabsTrigger>
</TabsList>
</Tabs>
{/* Refresh */}
<Button variant="ghost" size="icon" onClick={handleRefresh} className="h-9 w-9">
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
</Button>
</div>
{/* Placement List */}
<PlacementList
placements={filteredPlacements}
surfaceId={activeSurfaceId}
onEdit={handleEditPlacement}
onRefresh={handleRefresh}
/>
</div>
</div>
{/* Modals */}
<EventPickerModal
open={pickerOpen}
onOpenChange={setPickerOpen}
events={pickerEvents}
onSelectEvent={handleEventSelected}
alreadyPlacedEventIds={alreadyPlacedEventIds}
/>
<PlacementConfigDrawer
open={drawerOpen}
onOpenChange={setDrawerOpen}
event={selectedEvent}
surfaceId={activeSurfaceId}
editingPlacement={editingPlacement}
onComplete={handleDrawerComplete}
/>
</AppLayout>
</TooltipProvider>
);
}