SkateSpots/src/components/SpotCard.tsx
SkateSpots b285db907d feat(map): show community confidence on spot cards
- 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.
2026-10-03 19:28:28 +07:00

306 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}