SkateSpots/src/components/TourCard.tsx
shakespeare.diy aea66d11e2 Fix 78 pre-existing TypeScript errors surfaced by npm test
- useDirectMessages: correct nip44 getConversationKey (Uint8Array) + finalizeEvent EventTemplate (remove pubkey/unused ephemeralPubkey/bytesToHex)
- Access current-user metadata via useCurrentUser() spread instead of user.metadata across ~10 files (rename to userMetadata where author metadata already bound)
- useCrewGoal: explicit UseQueryResult<CrewGoal|null> return type (fixes CrewForm/CrewTreasuryCard {} inference)
- New useCommentCounts hook; fix EventsPage/MapPage misuse of useComments(string[])
- CoachDetailPage: typed pget/pbool accessors for craft-specific union fields
- MapPage: shop entries include tags/category to satisfy SkateSpot
- SkateGlobe: read event.tags; LabPage: remove duplicate serviceType; LabCrewsStrip: add 'judging'
- Icon prop widened to accept style; ReportDialog subtype guard; SecuritySettings/TourCard/EventsPage comparisons; CashuWallet onClick; ConversationPage recipients; useLoggedInAccounts NLoginType import; LicensePage icon; TourDetailPage ZapButton target + real npub link; TestApp useSkateRelay

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-03 21:18:02 +07:00

457 lines
18 KiB
TypeScript

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 type { SkateTour, TourCategory } from '@/lib/types';
import { TOUR_CATEGORIES, TOUR_SKILL_LEVELS } from '@/lib/types';
import { cn } from '@/lib/utils';
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, unit?: string): string {
if (!duration) return '';
switch (unit) {
case 'hours':
return `${duration} hr${duration > 1 ? 's' : ''}`;
case 'days':
return `${duration} day${duration > 1 ? 's' : ''}`;
case 'weeks':
return duration >= 4 ? `${Math.floor(duration / 4)} month${Math.floor(duration / 4) > 1 ? 's' : ''}` : `${duration} week${duration > 1 ? 's' : ''}`;
default:
return `${duration}`;
}
}
function formatDateRange(startDate?: number, endDate?: number): string {
if (!startDate) return '';
const start = new Date(startDate * 1000);
const startStr = start.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
if (!endDate) return startStr;
const end = new Date(endDate * 1000);
const endStr = end.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
return `${startStr} - ${endStr}`;
}
function getTourStatus(tour: SkateTour): { label: string; color: string } | null {
if (tour.category !== 'destruction') return null;
const now = Date.now() / 1000;
if (tour.startDate && now < tour.startDate) {
return { label: 'Registration Open', color: '#ccff00' };
} else if (tour.startDate && tour.endDate && now >= tour.startDate && now <= tour.endDate) {
return { label: 'Active', color: '#ff0040' };
} else if (tour.endDate && now > tour.endDate) {
return { label: 'Completed', color: '#888' };
}
return null;
}
export function TourCard({ tour, compact = false }: TourCardProps) {
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 tourStatus = getTourStatus(tour);
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(', ') || '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 />
{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 }}
>
{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)}
</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} {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} />
{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" />
{tour.prizePool.toLocaleString()} sats
</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 dates for destruction tours */}
{tour.category === 'destruction' && 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)}</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)}
</span>
)}
{tour.maxGroupSize && (
<span className="flex items-center gap-1">
<Users className="h-4 w-4 text-[#ff6600]" />
Max {tour.maxGroupSize}
</span>
)}
{tour.maxTeams && (
<span className="flex items-center gap-1">
<Users className="h-4 w-4 text-[#ff6600]" />
{tour.maxTeams} teams max
</span>
)}
{skillInfo && (
<span className="flex items-center gap-1">
<span>{skillInfo.emoji}</span>
{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" />
Entry: {tour.entryFee.toLocaleString()} sats
</Badge>
) : tour.price ? (
<Badge variant="outline" className="border-orange-500/50 text-orange-400">
<Zap className="h-3 w-3 mr-1" />
{tour.price.toLocaleString()} sats
</Badge>
) : (
<Badge variant="outline" className="border-green-500/50 text-green-400">
Contact for price
</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" />
Report
</DropdownMenuItem>
)}
</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>
);
}