- New `useBatchSpotCommunityScores(spots)` — ONE query for many spots (same signals/weights as the single-spot hook; counts distinct accounts, drops the mapper, caps per account; skip WoT/activity weighting at this scale). - SpotCard takes an optional `community` prop and shows a small "Community" (green) or "Seen" chip in the footer. - MapPage scores the visible spot cards (shops excluded — different kind) and passes each card its score.
306 lines
12 KiB
TypeScript
306 lines
12 KiB
TypeScript
import { useState } from 'react';
|
||
import { Link } from 'react-router-dom';
|
||
import { Card, CardContent, CardFooter, 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 { MapPin, Store, Bookmark, MoreVertical, Flag, ShieldCheck } from 'lucide-react';
|
||
import { useAuthor } from '@/hooks/useAuthor';
|
||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||
import { useVenuesWithPasses } from '@/hooks/useRiderCard';
|
||
import type { SpotCommunityMini } from '@/hooks/useBatchSpotCommunityScores';
|
||
import { genUserName } from '@/lib/genUserName';
|
||
import { ZapButton } from '@/components/ZapButton';
|
||
import { TrustBadge } from '@/components/TrustBadge';
|
||
import { OfficialBadge } from '@/components/OfficialBadge';
|
||
import { AddToCollectionDialog } from '@/components/AddToCollectionDialog';
|
||
import { ReportDialog } from '@/components/ReportDialog';
|
||
import { SpotStatusBadge } from '@/components/SpotStatusBadge';
|
||
import type { SkateSpot } from '@/lib/types';
|
||
import { ACCESS_TYPE_INFO, SHELTER_INFO, SKATE_SHOP_KIND } from '@/lib/types';
|
||
import { spotTypeLabel } from '@/lib/spotTypeLabel';
|
||
import { ACCESS_TYPE_KEYS, SHELTER_SHORT_KEYS } from '@/lib/labelKeys';
|
||
import type { AggregatedSpotStatus } from '@/hooks/useSpotStatus';
|
||
import { useTranslation } from '@/contexts/language';
|
||
import { cn } from '@/lib/utils';
|
||
|
||
interface SpotCardProps {
|
||
spot: SkateSpot;
|
||
/** Optional community status, passed in batched from the parent list/map. */
|
||
status?: AggregatedSpotStatus | null;
|
||
/** Optional community confidence, passed in batched from the parent list/map. */
|
||
community?: SpotCommunityMini;
|
||
}
|
||
|
||
export function SpotCard({ spot, status, community }: SpotCardProps) {
|
||
const { t } = useTranslation();
|
||
const author = useAuthor(spot.pubkey);
|
||
const { user } = useCurrentUser();
|
||
const metadata = author.data?.metadata;
|
||
const displayName = metadata?.name ?? genUserName(spot.pubkey);
|
||
const [showCollectionDialog, setShowCollectionDialog] = useState(false);
|
||
const [showReportDialog, setShowReportDialog] = useState(false);
|
||
|
||
// Determine if this is a shop or a spot
|
||
const isShop = spot.types.includes('shop');
|
||
const isOwner = user?.pubkey === spot.pubkey;
|
||
const route = isShop ? `/shop/${spot.pubkey}/${spot.identifier}` : `/spot/${spot.pubkey}/${spot.identifier}`;
|
||
|
||
// Shops that actually issue access passes get a badge (one shared, cached query).
|
||
const { data: passIndex } = useVenuesWithPasses();
|
||
const hasPasses = isShop && !!passIndex?.coords.has(`${SKATE_SHOP_KIND}:${spot.pubkey}:${spot.identifier}`);
|
||
|
||
// Dim spots the community has reported as gone/demolished.
|
||
const isGone = status?.status === 'gone';
|
||
|
||
return (
|
||
<Link to={route}>
|
||
<Card className={cn(
|
||
"group overflow-hidden hover:shadow-2xl transition-all duration-300 border-2 bg-black/90 backdrop-blur",
|
||
isShop
|
||
? "border-[#ccff00]/30 hover:border-[#ccff00]/70 hover:shadow-[#ccff00]/30"
|
||
: "border-[#ff0040]/20 hover:border-[#ff0040]/60 hover:shadow-[#ff0040]/30",
|
||
isGone && "opacity-60 grayscale hover:opacity-90"
|
||
)}>
|
||
{/* Image */}
|
||
<div className="relative aspect-video overflow-hidden bg-black">
|
||
{spot.images[0] ? (
|
||
<img
|
||
src={spot.images[0]}
|
||
alt={spot.title}
|
||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110 group-hover:brightness-110"
|
||
/>
|
||
) : (
|
||
<div className={cn(
|
||
"w-full h-full flex items-center justify-center bg-gradient-to-br",
|
||
isShop
|
||
? "from-[#ccff00]/20 to-[#00ff88]/20"
|
||
: "from-[#ff0040]/20 to-[#ff6600]/20"
|
||
)}>
|
||
{isShop ? (
|
||
<Store className="h-16 w-16 text-[#ccff00]/40" />
|
||
) : (
|
||
<MapPin className="h-16 w-16 text-[#ff0040]/40" />
|
||
)}
|
||
</div>
|
||
)}
|
||
{/* Community status badge (non-shop) */}
|
||
{!isShop && status?.status && (
|
||
<div className="absolute top-3 right-3">
|
||
<SpotStatusBadge aggregated={status} size="sm" />
|
||
</div>
|
||
)}
|
||
{/* Shop badge or Type badges overlay */}
|
||
<div className="absolute top-3 left-3 flex flex-col gap-1">
|
||
{isShop ? (
|
||
<>
|
||
<Badge className="bg-[#ccff00]/90 text-black backdrop-blur-sm border-2 border-white font-black uppercase text-xs">
|
||
🏪 {t('mapUi.skateShop')}
|
||
</Badge>
|
||
{hasPasses && (
|
||
<Badge className="bg-white/90 text-black backdrop-blur-sm border-2 border-black font-black uppercase text-[10px]">
|
||
🎟️ {t('venueAccess.badge')}
|
||
</Badge>
|
||
)}
|
||
</>
|
||
) : (
|
||
<>
|
||
{spot.accessType && (
|
||
<Badge
|
||
className="bg-black/90 backdrop-blur-sm border-2 font-bold uppercase text-[10px]"
|
||
style={{
|
||
borderColor: ACCESS_TYPE_INFO[spot.accessType].color,
|
||
color: ACCESS_TYPE_INFO[spot.accessType].color
|
||
}}
|
||
>
|
||
{ACCESS_TYPE_INFO[spot.accessType].emoji} {(ACCESS_TYPE_KEYS[spot.accessType] ? t(ACCESS_TYPE_KEYS[spot.accessType]) : ACCESS_TYPE_INFO[spot.accessType].label).split(' - ')[0]}
|
||
</Badge>
|
||
)}
|
||
{spot.shelter && spot.shelter !== 'outdoor' && SHELTER_INFO[spot.shelter] && (
|
||
<Badge
|
||
className="bg-black/90 backdrop-blur-sm border-2 font-bold uppercase text-[10px]"
|
||
style={{
|
||
borderColor: SHELTER_INFO[spot.shelter].color,
|
||
color: SHELTER_INFO[spot.shelter].color,
|
||
}}
|
||
>
|
||
{SHELTER_INFO[spot.shelter].emoji} {SHELTER_SHORT_KEYS[spot.shelter] ? t(SHELTER_SHORT_KEYS[spot.shelter]) : SHELTER_INFO[spot.shelter].short}
|
||
</Badge>
|
||
)}
|
||
{spot.types.length > 0 && (
|
||
<div className="flex flex-wrap gap-1">
|
||
{spot.types.slice(0, 2).map((type) => (
|
||
<Badge
|
||
key={type}
|
||
className="bg-black/80 text-[#ccff00] backdrop-blur-sm border border-[#ccff00]/50 font-bold uppercase text-[10px]"
|
||
>
|
||
{spotTypeLabel(t, type)}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<CardHeader className="pb-2">
|
||
<h3 className={cn(
|
||
"font-bold text-lg leading-tight transition-colors line-clamp-1 uppercase tracking-wide",
|
||
isShop
|
||
? "text-white group-hover:text-[#ccff00]"
|
||
: "text-white group-hover:text-[#ff0040]"
|
||
)}>
|
||
{spot.title}
|
||
</h3>
|
||
{spot.location && (
|
||
<p className="text-sm text-gray-400 flex items-center gap-1 line-clamp-1">
|
||
{isShop ? (
|
||
<Store className="h-3 w-3 flex-shrink-0 text-[#ccff00]" />
|
||
) : (
|
||
<MapPin className="h-3 w-3 flex-shrink-0 text-[#ff0040]" />
|
||
)}
|
||
{spot.location}
|
||
</p>
|
||
)}
|
||
</CardHeader>
|
||
|
||
<CardContent className="pb-3">
|
||
{spot.description && (
|
||
<p className="text-sm text-gray-400 line-clamp-2">
|
||
{spot.description}
|
||
</p>
|
||
)}
|
||
</CardContent>
|
||
|
||
<CardFooter className="pt-0 flex items-center justify-between border-t border-[#ff0040]/20 mt-2 pt-3">
|
||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||
<Avatar className="h-6 w-6 border border-[#ff0040]/30">
|
||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||
<AvatarFallback className="text-xs bg-black text-[#ff0040]">
|
||
{displayName.slice(0, 2).toUpperCase()}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
<span className="text-xs text-gray-400 truncate max-w-[80px]">
|
||
{displayName}
|
||
</span>
|
||
{/* Trust Badge */}
|
||
<TrustBadge pubkey={spot.pubkey} size="sm" />
|
||
{/* Official badge — only shown for verified founder */}
|
||
<OfficialBadge pubkey={spot.pubkey} size="sm" />
|
||
{/* Community confidence — batched in by the parent list/map. */}
|
||
{community?.level === 'community' && (
|
||
<span
|
||
className="inline-flex items-center gap-0.5 rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[9px] font-bold uppercase text-emerald-400"
|
||
title={t('spotUi.communityVerifiedTitle')}
|
||
>
|
||
<ShieldCheck className="h-2.5 w-2.5" /> {t('spotUi.communityChip')}
|
||
</span>
|
||
)}
|
||
{community?.level === 'seen' && (
|
||
<span className="inline-flex items-center rounded-full bg-white/5 px-1.5 py-0.5 text-[9px] font-bold uppercase text-gray-500">
|
||
{t('spotUi.seenChip')}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2" onClick={(e) => e.preventDefault()}>
|
||
{user && !isShop && (
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className="h-8 w-8 text-gray-400 hover:text-[#ccff00] hover:bg-[#ccff00]/10"
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setShowCollectionDialog(true);
|
||
}}
|
||
>
|
||
<Bookmark className="h-4 w-4" />
|
||
</Button>
|
||
)}
|
||
<ZapButton target={spot.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('mapUi.report')}
|
||
</DropdownMenuItem>
|
||
)}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
</CardFooter>
|
||
</Card>
|
||
|
||
{/* Collection Dialog */}
|
||
{!isShop && (
|
||
<AddToCollectionDialog
|
||
open={showCollectionDialog}
|
||
onOpenChange={setShowCollectionDialog}
|
||
spot={spot}
|
||
/>
|
||
)}
|
||
|
||
{/* Report Dialog */}
|
||
<ReportDialog
|
||
open={showReportDialog}
|
||
onOpenChange={setShowReportDialog}
|
||
targetType={isShop ? 'shop' : 'spot'}
|
||
targetPubkey={spot.pubkey}
|
||
targetEventId={spot.id}
|
||
targetTitle={spot.title}
|
||
/>
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
export function SpotCardSkeleton() {
|
||
return (
|
||
<Card className="overflow-hidden">
|
||
<Skeleton className="aspect-video w-full" />
|
||
<CardHeader className="pb-2">
|
||
<Skeleton className="h-6 w-3/4" />
|
||
<Skeleton className="h-4 w-1/2" />
|
||
</CardHeader>
|
||
<CardContent className="pb-3">
|
||
<div className="space-y-2">
|
||
<Skeleton className="h-4 w-full" />
|
||
<Skeleton className="h-4 w-4/5" />
|
||
</div>
|
||
</CardContent>
|
||
<CardFooter className="pt-0">
|
||
<div className="flex items-center gap-2">
|
||
<Skeleton className="h-6 w-6 rounded-full" />
|
||
<Skeleton className="h-4 w-20" />
|
||
</div>
|
||
</CardFooter>
|
||
</Card>
|
||
);
|
||
}
|