168 lines
8.5 KiB
TypeScript
168 lines
8.5 KiB
TypeScript
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>
|
|
);
|
|
}
|