SkateSpots/src/components/EventCard.tsx

349 lines
14 KiB
TypeScript

import { dateLocaleCode } from '@/lib/dateFnsI18n';
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Calendar, Clock, MapPin, MoreVertical, Flag, Users, Sparkles } from 'lucide-react';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { genUserName } from '@/lib/genUserName';
import { useEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
import { ZapButton } from '@/components/ZapButton';
import { EventCountdown } from '@/components/EventCountdown';
import { TrustBadge } from '@/components/TrustBadge';
import { OfficialBadge } from '@/components/OfficialBadge';
import { ProBadge } from '@/components/ProBadge';
import { ReportDialog } from '@/components/ReportDialog';
import { MuteMenuItem } from '@/components/MuteMenuItem';
import { useTranslation } from '@/contexts/language';
import type { SkateEvent } from '@/lib/types';
import { EVENT_TYPE_LABELS } from '@/lib/types';
import { EVENT_TYPE_KEYS } from '@/lib/labelKeys';
function eventTypeLabel(t: (key: string) => string, type: string): string {
return EVENT_TYPE_KEYS[type] ? t(EVENT_TYPE_KEYS[type]) : (EVENT_TYPE_LABELS[type] || type);
}
interface EventCardProps {
event: SkateEvent;
compact?: boolean;
/**
* Pre-computed going count from a batch query (see useBatchEventRsvps). When
* provided, the card skips its own per-card RSVP query — this avoids an N+1
* query storm when rendering a grid of cards.
*/
goingCount?: number;
}
function formatDate(timestamp: number): string {
const date = new Date(timestamp * 1000);
return date.toLocaleDateString(dateLocaleCode(), {
weekday: 'short',
month: 'short',
day: 'numeric',
});
}
function formatTime(timestamp: number): string {
const date = new Date(timestamp * 1000);
return date.toLocaleTimeString(dateLocaleCode(), {
hour: 'numeric',
minute: '2-digit',
});
}
export function EventCard({ event, compact = false, goingCount: goingCountProp }: EventCardProps) {
const { t } = useTranslation();
const author = useAuthor(event.pubkey);
const { user } = useCurrentUser();
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? genUserName(event.pubkey);
const [showReportDialog, setShowReportDialog] = useState(false);
const isOwner = user?.pubkey === event.pubkey;
// Skip the per-card RSVP query when a batch count was supplied (grid view).
const { goingCount: goingCountSelf } = useEventRsvps(
goingCountProp === undefined ? eventCoord(event) : undefined,
);
const goingCount = goingCountProp ?? goingCountSelf;
// Sponsors ride along on the parsed event (no extra query). Title sponsor
// first, so paid placements get real visibility in the discovery feed.
const sponsors = event.sponsors ?? [];
const leadSponsor = sponsors.find((s) => s.tier === 'title') ?? sponsors[0];
if (compact) {
return (
<Link to={`/event/${event.pubkey}/${event.identifier}`}>
<Card className="group hover:shadow-2xl hover:shadow-[#ccff00]/20 transition-all duration-300 border-2 border-[#ccff00]/20 hover:border-[#ccff00]/60 bg-black/90 backdrop-blur">
<CardContent className="p-4">
<div className="flex items-start gap-4">
{/* Date Badge */}
<div className="flex-shrink-0 w-16 text-center">
<div className="bg-[#ccff00]/10 border-2 border-[#ccff00]/50 rounded-lg p-2">
<div className="text-xs font-bold text-[#ccff00] uppercase">
{new Date(event.start * 1000).toLocaleDateString(dateLocaleCode(), { month: 'short' })}
</div>
<div className="text-2xl font-black text-white">
{new Date(event.start * 1000).getDate()}
</div>
</div>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-1">
<h3 className="font-bold text-white group-hover:text-[#ccff00] transition-colors line-clamp-1 uppercase tracking-wide">
{event.title}
</h3>
<div className="flex items-center gap-1 flex-shrink-0">
<ProBadge featuredUntil={event.featuredUntil} compact />
<EventCountdown start={event.start} end={event.end} />
</div>
</div>
<div className="flex items-center gap-2 text-xs text-gray-400 flex-wrap">
<span className="flex items-center gap-1">
<Clock className="h-3 w-3 text-[#ccff00]" />
{formatTime(event.start)}
</span>
{event.location && (
<span className="flex items-center gap-1 truncate">
<MapPin className="h-3 w-3 flex-shrink-0 text-[#ff0040]" />
<span className="truncate">{event.location}</span>
</span>
)}
{goingCount > 0 && (
<span className="flex items-center gap-1 text-green-400 font-bold">
<Users className="h-3 w-3" />
{goingCount}
</span>
)}
<TrustBadge pubkey={event.pubkey} size="sm" />
</div>
</div>
</div>
</CardContent>
</Card>
</Link>
);
}
return (
<Link to={`/event/${event.pubkey}/${event.identifier}`}>
<Card className="group overflow-hidden hover:shadow-xl hover:shadow-primary/10 transition-all duration-300 border-border/50 hover:border-primary/30">
{/* Image */}
<div className="relative aspect-[16/9] overflow-hidden bg-muted">
{event.image ? (
<img
src={event.image}
alt={event.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-primary/20 to-accent/20">
<Calendar className="h-12 w-12 text-primary/40" />
</div>
)}
{/* Bottom legibility gradient for overlays */}
<div className="absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-black/70 to-transparent pointer-events-none" />
{/* Date overlay */}
<div className="absolute top-3 left-3 flex items-center gap-2">
<div className="bg-background/95 backdrop-blur-sm rounded-lg px-3 py-2 text-center shadow-lg">
<div className="text-xs font-medium text-primary uppercase">
{new Date(event.start * 1000).toLocaleDateString(dateLocaleCode(), { month: 'short' })}
</div>
<div className="text-xl font-bold">
{new Date(event.start * 1000).getDate()}
</div>
</div>
{/* Live status pill — the at-a-glance signal in the grid */}
<EventCountdown start={event.start} end={event.end} className="shadow-lg" />
</div>
{/* Type badges + Pro */}
<div className="absolute top-3 right-3 flex flex-wrap gap-1 justify-end">
<ProBadge featuredUntil={event.featuredUntil} />
{event.types.slice(0, 2).map((type) => (
<Badge
key={type}
className="bg-primary text-primary-foreground"
>
{eventTypeLabel(t, type)}
</Badge>
))}
</div>
{/* Sponsor hint — gives paid placements visibility in the feed */}
{leadSponsor && (
<div className="absolute bottom-3 left-3 right-3 flex items-center gap-1.5 text-[11px] font-bold text-white/90">
<Sparkles className="h-3 w-3 flex-shrink-0 text-[#ccff00]" />
<span className="truncate">
<span className="text-white/60 uppercase tracking-wide">{t('placesUi.sponsors.presentedBy')} </span>
{leadSponsor.name}
{sponsors.length > 1 && (
<span className="text-white/60"> +{sponsors.length - 1}</span>
)}
</span>
</div>
)}
</div>
<CardHeader className="pb-2">
{/* Name Display at Top */}
<div className="mb-3 pb-2 border-b border-[#ccff00]/20">
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8 border border-[#ccff00]/30">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="text-xs bg-[#ccff00]/20 text-[#ccff00]">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h4 className="font-bold text-white text-sm truncate">{displayName}</h4>
<TrustBadge pubkey={event.pubkey} size="sm" />
<OfficialBadge pubkey={event.pubkey} size="sm" />
</div>
{metadata?.nip05 && (
<div className="flex items-center gap-1 text-xs">
<div className="w-1.5 h-1.5 rounded-full bg-[#ccff00]" />
<span className="text-[#ccff00] font-bold truncate">{metadata.nip05}</span>
</div>
)}
</div>
</div>
</div>
<div className="flex items-start justify-between gap-2">
<h3 className="font-bold text-lg leading-tight text-white group-hover:text-[#ccff00] transition-colors line-clamp-2 uppercase">
{event.title}
</h3>
</div>
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-400">
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4 text-[#ccff00]" />
{formatDate(event.start)}
</span>
<span className="flex items-center gap-1">
<Clock className="h-4 w-4 text-[#ff6600]" />
{formatTime(event.start)}
</span>
</div>
{event.location && (
<p className="text-sm text-gray-400 flex items-center gap-1 line-clamp-1">
<MapPin className="h-4 w-4 flex-shrink-0 text-[#ff0040]" />
{event.location}
</p>
)}
</CardHeader>
<CardContent className="pb-4">
{event.description && (
<p className="text-sm text-gray-400 line-clamp-2">
{event.description}
</p>
)}
<div className="flex items-center justify-between mt-4 pt-4 border-t border-[#ccff00]/20">
{goingCount > 0 ? (
<span className="flex items-center gap-1.5 text-sm font-bold text-green-400">
<Users className="h-4 w-4" />
{goingCount} {t('placesUi.attendees.goingCount')}
</span>
) : (
<span />
)}
<div className="flex items-center gap-2" onClick={(e) => e.preventDefault()}>
<ZapButton target={event.event} className="text-gray-400 hover:text-[#ccff00]" />
{/* More options menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-gray-400 hover:text-white"
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
{!isOwner && (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowReportDialog(true);
}}
className="text-red-500 focus:text-red-500"
>
<Flag className="h-4 w-4 mr-2" />
{t('placesUi.eventCard.report')}
</DropdownMenuItem>
)}
<MuteMenuItem pubkey={event.pubkey} />
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardContent>
</Card>
{/* Report Dialog */}
<ReportDialog
open={showReportDialog}
onOpenChange={setShowReportDialog}
targetType="event"
targetPubkey={event.pubkey}
targetEventId={event.id}
targetTitle={event.title}
/>
</Link>
);
}
export function EventCardSkeleton({ compact = false }: { compact?: boolean }) {
if (compact) {
return (
<Card>
<CardContent className="p-4">
<div className="flex items-start gap-4">
<Skeleton className="w-14 h-16 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
</div>
</CardContent>
</Card>
);
}
return (
<Card className="overflow-hidden">
<Skeleton className="aspect-[16/9] w-full" />
<CardHeader className="pb-2">
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-2/3" />
</CardHeader>
<CardContent className="pb-4">
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
</CardContent>
</Card>
);
}