SkateSpots/src/components/TourCard.tsx

543 lines
22 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 { Route, Clock, MapPin, Users, Zap, Globe, Calendar, Trophy, Skull, MoreVertical, Flag } from 'lucide-react';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { genUserName } from '@/lib/genUserName';
import { ZapButton } from '@/components/ZapButton';
import { TrustBadge } from '@/components/TrustBadge';
import { ProBadge } from '@/components/ProBadge';
import { ReportDialog } from '@/components/ReportDialog';
import { MuteMenuItem } from '@/components/MuteMenuItem';
import type { SkateTour, TourCategory } from '@/lib/types';
import { TOUR_CATEGORIES, TOUR_SKILL_LEVELS } from '@/lib/types';
import { TOUR_CATEGORY_KEYS, TOUR_SKILL_KEYS } from '@/lib/labelKeys';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
type Translate = (key: string, params?: Record<string, string | number>) => string;
interface TourCardProps {
tour: SkateTour;
compact?: boolean;
}
function _getCategoryIcon(category: TourCategory) {
switch (category) {
case 'local':
return <Route className="h-4 w-4" />;
case 'national':
return <Route className="h-4 w-4" />;
case 'international':
return <Globe className="h-4 w-4" />;
case 'destruction':
return <Skull className="h-4 w-4" />;
default:
return <Route className="h-4 w-4" />;
}
}
function formatDuration(duration: number | undefined, unit: string | undefined, t: Translate): string {
if (!duration) return '';
switch (unit) {
case 'hours':
return t('placesUi.tourCard.durationHours', { count: duration });
case 'days':
return t('placesUi.tourCard.durationDays', { count: duration });
case 'weeks':
return duration >= 4 ? t('placesUi.tourCard.durationMonths', { count: Math.floor(duration / 4) }) : t('placesUi.tourCard.durationWeeks', { count: duration });
default:
return `${duration}`;
}
}
function formatDateRange(startDate?: number, endDate?: number, withTime = false): string {
if (!startDate) return '';
const start = new Date(startDate * 1000);
if (withTime) {
// Local scheduled session — show the day and start time.
return start.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
const startStr = start.toLocaleDateString(dateLocaleCode(), { month: 'short', day: 'numeric' });
if (!endDate) return startStr;
const end = new Date(endDate * 1000);
const endStr = end.toLocaleDateString(dateLocaleCode(), { month: 'short', day: 'numeric', year: 'numeric' });
return `${startStr} - ${endStr}`;
}
/**
* A short, human countdown for an upcoming (or live) tour based on its start
* date. Returns null when there's no date or the tour is well in the past.
*/
function getCountdown(
tour: SkateTour,
t: Translate,
): { label: string; color: string; live?: boolean } | null {
if (!tour.startDate) return null;
const nowSecs = Date.now() / 1000;
const start = tour.startDate;
const end = tour.endDate;
const accent = TOUR_CATEGORIES[tour.category].color;
// Currently running (has an end date and we're inside the window).
if (start <= nowSecs && end && nowSecs <= end) {
return { label: t('placesUi.tourCard.happeningNow'), color: '#ff0040', live: true };
}
// Already started but single-session / no end — treat same-day as live, else past.
if (start <= nowSecs) {
if (nowSecs - start < 12 * 60 * 60) {
return { label: t('placesUi.tourCard.happeningNow'), color: '#ff0040', live: true };
}
return null;
}
const diffSecs = start - nowSecs;
const days = Math.floor(diffSecs / 86400);
const hours = Math.floor(diffSecs / 3600);
if (hours < 1) return { label: t('placesUi.tourCard.startingSoon'), color: accent, live: true };
if (hours < 24) return { label: t('placesUi.tourCard.inHours', { count: hours }), color: accent };
if (days === 1) return { label: t('placesUi.tourCard.tomorrow'), color: accent };
if (days < 7) return { label: t('placesUi.tourCard.inDays', { count: days }), color: accent };
if (days < 30) {
const w = Math.round(days / 7);
return { label: t('placesUi.tourCard.inWeeks', { count: w }), color: accent };
}
const m = Math.round(days / 30);
return { label: t('placesUi.tourCard.inMonths', { count: m }), color: accent };
}
function getTourStatus(tour: SkateTour, t: Translate): { label: string; color: string } | null {
if (tour.category !== 'destruction') return null;
const now = Date.now() / 1000;
if (tour.startDate && now < tour.startDate) {
return { label: t('placesUi.tourCard.registrationOpen'), color: '#ccff00' };
} else if (tour.startDate && tour.endDate && now >= tour.startDate && now <= tour.endDate) {
return { label: t('placesUi.tourCard.active'), color: '#ff0040' };
} else if (tour.endDate && now > tour.endDate) {
return { label: t('placesUi.tourCard.completed'), color: '#888' };
}
return null;
}
export function TourCard({ tour, compact = false }: TourCardProps) {
const { t } = useTranslation();
const author = useAuthor(tour.pubkey);
const { user } = useCurrentUser();
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? genUserName(tour.pubkey);
const [showReportDialog, setShowReportDialog] = useState(false);
const categoryInfo = TOUR_CATEGORIES[tour.category];
const skillInfo = tour.skillLevel ? TOUR_SKILL_LEVELS[tour.skillLevel as keyof typeof TOUR_SKILL_LEVELS] : null;
const skillKey = tour.skillLevel ? TOUR_SKILL_KEYS[tour.skillLevel] : undefined;
const tourStatus = getTourStatus(tour, t);
const countdown = getCountdown(tour, t);
const isOwner = user?.pubkey === tour.pubkey;
// Get location text based on category
const locationText = tour.category === 'destruction' || tour.category === 'international'
? tour.countries?.join(', ') || tour.cities?.join(', ') || t('placesUi.tourCard.worldwide')
: tour.location || tour.cities?.join(', ');
if (compact) {
return (
<Link to={`/tour/${tour.pubkey}/${tour.identifier}`}>
<Card
className="group hover:shadow-2xl transition-all duration-300 border-2 bg-black/90 backdrop-blur"
style={{
borderColor: `${categoryInfo.color}33`,
boxShadow: `0 0 0 0 ${categoryInfo.color}00`
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = `${categoryInfo.color}99`;
e.currentTarget.style.boxShadow = `0 10px 40px ${categoryInfo.color}33`;
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = `${categoryInfo.color}33`;
e.currentTarget.style.boxShadow = `0 0 0 0 ${categoryInfo.color}00`;
}}
>
<CardContent className="p-4">
<div className="flex items-start gap-4">
{/* Category Badge */}
<div
className="flex-shrink-0 w-14 h-14 rounded-lg flex flex-col items-center justify-center"
style={{ backgroundColor: `${categoryInfo.color}20`, border: `2px solid ${categoryInfo.color}80` }}
>
<span className="text-xl">{categoryInfo.emoji}</span>
</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 transition-colors line-clamp-1 uppercase tracking-wide"
style={{ color: 'white' }}
>
{tour.title}
</h3>
<div className="flex items-center gap-1 flex-shrink-0">
<ProBadge featuredUntil={tour.featuredUntil} compact />
{countdown && (
<Badge
className={cn(
'text-[10px] border-0 font-bold uppercase',
countdown.live && 'animate-pulse'
)}
style={{ backgroundColor: countdown.color, color: countdown.color === '#ccff00' ? 'black' : 'white' }}
>
{countdown.label}
</Badge>
)}
{tourStatus && (
<Badge
className="text-xs border-0 font-bold uppercase"
style={{ backgroundColor: tourStatus.color, color: tourStatus.color === '#ccff00' ? 'black' : 'white' }}
>
{tourStatus.label}
</Badge>
)}
</div>
</div>
<div className="flex items-center gap-2 text-xs text-gray-400 flex-wrap">
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 font-bold"
style={{ borderColor: categoryInfo.color, color: categoryInfo.color }}
>
{TOUR_CATEGORY_KEYS[tour.category] ? t(TOUR_CATEGORY_KEYS[tour.category]) : categoryInfo.label}
</Badge>
{tour.duration && (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" style={{ color: categoryInfo.color }} />
{formatDuration(tour.duration, tour.durationUnit, t)}
</span>
)}
{locationText && (
<span className="flex items-center gap-1 truncate">
<MapPin className="h-3 w-3 flex-shrink-0 text-[#ff0040]" />
<span className="truncate">{locationText}</span>
</span>
)}
<TrustBadge pubkey={tour.pubkey} size="sm" />
</div>
</div>
</div>
</CardContent>
</Card>
</Link>
);
}
return (
<Link to={`/tour/${tour.pubkey}/${tour.identifier}`}>
<Card
className="group overflow-hidden transition-all duration-300 border-2 bg-black/90 backdrop-blur"
style={{
borderColor: `${categoryInfo.color}33`,
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = `${categoryInfo.color}80`;
e.currentTarget.style.boxShadow = `0 20px 50px ${categoryInfo.color}20`;
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = `${categoryInfo.color}33`;
e.currentTarget.style.boxShadow = 'none';
}}
>
{/* Image */}
<div className="relative aspect-[16/9] overflow-hidden bg-muted">
{tour.image ? (
<img
src={tour.image}
alt={tour.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"
style={{ background: `linear-gradient(135deg, ${categoryInfo.color}20 0%, ${categoryInfo.color}05 100%)` }}
>
{tour.category === 'destruction' ? (
<Skull className="h-16 w-16" style={{ color: `${categoryInfo.color}40` }} />
) : (
<Route className="h-16 w-16" style={{ color: `${categoryInfo.color}40` }} />
)}
</div>
)}
{/* Category overlay */}
<div className="absolute top-3 left-3">
<Badge
className="backdrop-blur-sm shadow-lg font-bold text-xs uppercase"
style={{
backgroundColor: `${categoryInfo.color}dd`,
color: (categoryInfo.color as string) === '#ccff00' ? 'black' : 'white'
}}
>
{categoryInfo.emoji} {TOUR_CATEGORY_KEYS[tour.category] ? t(TOUR_CATEGORY_KEYS[tour.category]) : categoryInfo.label}
</Badge>
</div>
{/* Status/Price overlay */}
<div className="absolute top-3 right-3 flex flex-col gap-1 items-end">
<ProBadge featuredUntil={tour.featuredUntil} />
{countdown && (
<Badge
className={cn(
'backdrop-blur-sm shadow-lg font-bold text-xs uppercase',
countdown.live && 'animate-pulse'
)}
style={{
backgroundColor: `${countdown.color}ee`,
color: countdown.color === '#ccff00' ? 'black' : 'white',
}}
>
<Calendar className="h-3 w-3 mr-1" />
{countdown.label}
</Badge>
)}
{tourStatus && (
<Badge
className="backdrop-blur-sm shadow-lg font-bold text-xs uppercase"
style={{
backgroundColor: `${tourStatus.color}dd`,
color: tourStatus.color === '#ccff00' ? 'black' : 'white'
}}
>
{tourStatus.label}
</Badge>
)}
{tour.prizePool && (
<Badge className="bg-orange-500/90 backdrop-blur-sm shadow-lg font-bold text-xs">
<Trophy className="h-3 w-3 mr-1" />
{t('placesUi.tourCard.prizePool', { amount: tour.prizePool.toLocaleString() })}
</Badge>
)}
</div>
</div>
<CardHeader className="pb-2">
{/* Guide/Organizer info */}
<div className="mb-3 pb-2 border-b" style={{ borderColor: `${categoryInfo.color}33` }}>
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8" style={{ border: `1px solid ${categoryInfo.color}50` }}>
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="text-xs" style={{ backgroundColor: `${categoryInfo.color}20`, color: categoryInfo.color }}>
{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={tour.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 transition-colors line-clamp-2 uppercase"
>
{tour.title}
</h3>
</div>
{/* Tour schedule — shown whenever the tour has a date set */}
{tour.startDate && (
<div className="flex items-center gap-2 text-sm text-gray-400">
<Calendar className="h-4 w-4" style={{ color: categoryInfo.color }} />
<span>{formatDateRange(tour.startDate, tour.endDate, tour.category === 'local')}</span>
</div>
)}
{/* Tour meta info */}
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-400">
{tour.duration && (
<span className="flex items-center gap-1">
<Clock className="h-4 w-4" style={{ color: categoryInfo.color }} />
{formatDuration(tour.duration, tour.durationUnit, t)}
</span>
)}
{tour.maxGroupSize && (
<span className="flex items-center gap-1">
<Users className="h-4 w-4 text-[#ff6600]" />
{t('placesUi.tourCard.maxGroup', { count: tour.maxGroupSize })}
</span>
)}
{tour.maxTeams && (
<span className="flex items-center gap-1">
<Users className="h-4 w-4 text-[#ff6600]" />
{t('placesUi.tourCard.teamsMax', { count: tour.maxTeams })}
</span>
)}
{skillInfo && (
<span className="flex items-center gap-1">
<span>{skillInfo.emoji}</span>
{skillKey ? t(skillKey) : skillInfo.label}
</span>
)}
</div>
{locationText && (
<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]" />
{locationText}
</p>
)}
</CardHeader>
<CardContent className="pb-4">
{tour.description && (
<p className="text-sm text-gray-400 line-clamp-2">
{tour.description}
</p>
)}
{/* Price or Entry Fee */}
<div className="flex items-center justify-between mt-4 pt-4 border-t" style={{ borderColor: `${categoryInfo.color}33` }}>
<div className="flex items-center gap-2">
{tour.category === 'destruction' && tour.entryFee ? (
<Badge variant="outline" className="border-orange-500/50 text-orange-400">
<Zap className="h-3 w-3 mr-1" />
{t('placesUi.tourCard.entry', { amount: tour.entryFee.toLocaleString() })}
</Badge>
) : tour.price ? (
<Badge variant="outline" className="border-orange-500/50 text-orange-400">
<Zap className="h-3 w-3 mr-1" />
{t('placesUi.tourCard.price', { amount: tour.price.toLocaleString() })}
</Badge>
) : (
<Badge variant="outline" className="border-green-500/50 text-green-400">
{t('placesUi.tourCard.contactForPrice')}
</Badge>
)}
</div>
<div className="flex items-center gap-2" onClick={(e) => e.preventDefault()}>
<ZapButton target={tour.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={tour.pubkey} />
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardContent>
</Card>
{/* Report Dialog */}
<ReportDialog
open={showReportDialog}
onOpenChange={setShowReportDialog}
targetType="tour"
targetPubkey={tour.pubkey}
targetEventId={tour.id}
targetTitle={tour.title}
/>
</Link>
);
}
export function TourCardSkeleton({ compact = false }: { compact?: boolean }) {
if (compact) {
return (
<Card className="border-2 border-gray-800">
<CardContent className="p-4">
<div className="flex items-start gap-4">
<Skeleton className="w-14 h-14 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 border-2 border-gray-800">
<Skeleton className="aspect-[16/9] w-full" />
<CardHeader className="pb-2">
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-gray-800">
<Skeleton className="w-8 h-8 rounded-full" />
<div className="flex-1 space-y-1">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-32" />
</div>
</div>
<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>
<div className="flex items-center justify-between mt-4 pt-4 border-t border-gray-800">
<Skeleton className="h-6 w-24" />
<Skeleton className="h-8 w-8 rounded-full" />
</div>
</CardContent>
</Card>
);
}