feat: Ad Control module — surfaces, placements, drag-and-drop reorder, event picker, targeting
This commit is contained in:
@@ -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={
|
||||
|
||||
@@ -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 },
|
||||
|
||||
167
src/features/ad-control/components/EventPickerModal.tsx
Normal file
167
src/features/ad-control/components/EventPickerModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
282
src/features/ad-control/components/PlacementConfigDrawer.tsx
Normal file
282
src/features/ad-control/components/PlacementConfigDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
257
src/features/ad-control/components/PlacementList.tsx
Normal file
257
src/features/ad-control/components/PlacementList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
src/features/ad-control/components/SurfaceTabs.tsx
Normal file
74
src/features/ad-control/components/SurfaceTabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
269
src/features/ad-control/data/mockAdData.ts
Normal file
269
src/features/ad-control/data/mockAdData.ts
Normal 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' },
|
||||
];
|
||||
280
src/lib/actions/ad-control.ts
Normal file
280
src/lib/actions/ad-control.ts
Normal 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) };
|
||||
}
|
||||
50
src/lib/audit/placement-audit.ts
Normal file
50
src/lib/audit/placement-audit.ts
Normal 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
110
src/lib/types/ad-control.ts
Normal 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
244
src/pages/AdControl.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user