349 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|