import { useMemo, useState } from 'react'; import { useSeoMeta } from '@unhead/react'; import { useParams, Link, useNavigate } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; import ngeohash from 'ngeohash'; import { Layout } from '@/components/Layout'; import { SpotMap } from '@/components/SpotMap'; import { Card, CardContent, CardHeader, CardTitle } 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 { ZapButton } from '@/components/ZapButton'; import { CommentsSection } from '@/components/comments/CommentsSection'; import { useSkateSpot } from '@/hooks/useSkateSpots'; import { useAuthor } from '@/hooks/useAuthor'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useToast } from '@/hooks/useToast'; import { useSpotWeather } from '@/hooks/useSpotWeather'; import { useMarketplaceAtSpot } from '@/hooks/useMarketplace'; import { useSpotServices } from '@/hooks/useSpotServices'; import { MarketplaceItemCard } from '@/components/MarketplaceItemCard'; import { genUserName } from '@/lib/genUserName'; import { SPOT_TYPE_LABELS, ACCESS_TYPE_INFO } from '@/lib/types'; import NotFound from './NotFound'; import { MapPin, ArrowLeft, Share2, Navigation, Calendar, Pencil, Trash2, AlertTriangle, Shield, Phone, Mail, Globe, Instagram, Cloud, Wind, Droplets, Sun, ShoppingBag, Plus, Bookmark, Users, Camera, Tags, GraduationCap, Video, Star, Flag, MoreHorizontal, } from 'lucide-react'; import { ReportDialog } from '@/components/ReportDialog'; import { WhosSkating } from '@/components/WhosSkating'; import { SetStatusDialog } from '@/components/SetStatusDialog'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { AddToCollectionDialog } from '@/components/AddToCollectionDialog'; import { AddPhotoToSpot } from '@/components/AddPhotoToSpot'; import { AddTagsToSpot } from '@/components/AddTagsToSpot'; import { SpotOwnership } from '@/components/SpotOwnership'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useSpotPhotos, useAggregatedSpotTags } from '@/hooks/useSpotContributions'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; function SpotDetailSkeleton() { return (
); } export default function SpotDetailPage() { const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>(); const { data: spot, isLoading, error } = useSkateSpot(identifier || '', pubkey); const { data: marketplaceItems, isLoading: isLoadingMarketplace } = useMarketplaceAtSpot(pubkey || '', identifier || ''); const { data: spotServices, isLoading: isLoadingServices } = useSpotServices(pubkey, identifier); const author = useAuthor(spot?.pubkey); const { user } = useCurrentUser(); const { mutateAsync: publishEvent } = useNostrPublish(); const { toast } = useToast(); const navigate = useNavigate(); const queryClient = useQueryClient(); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showCollectionDialog, setShowCollectionDialog] = useState(false); const [showReportDialog, setShowReportDialog] = useState(false); const [showStatusDialog, setShowStatusDialog] = useState(false); const isOwner = user && spot && user.pubkey === spot.pubkey; const isShop = spot?.types.includes('shop'); const hasNip05 = user?.metadata?.nip05; // Community contributions const { data: communityPhotos } = useSpotPhotos(pubkey || '', identifier || ''); const aggregatedTags = useAggregatedSpotTags(pubkey || '', identifier || ''); useSeoMeta({ title: spot ? `${spot.title} - SkateSpots` : 'Skate Spot - SkateSpots', description: spot?.description || 'View this skateboarding spot on SkateSpots.', }); const metadata = author.data?.metadata; const displayName = metadata?.name ?? genUserName(spot?.pubkey || ''); const coordinates = useMemo(() => { if (!spot?.geohash) return null; const { latitude, longitude } = ngeohash.decode(spot.geohash); return { lat: latitude, lng: longitude }; }, [spot?.geohash]); // Fetch weather data for this spot const { data: weatherData, isLoading: weatherLoading } = useSpotWeather(spot?.geohash, true); const handleShare = async () => { if (navigator.share) { try { await navigator.share({ title: spot?.title, text: spot?.description || `Check out this skate spot: ${spot?.title}`, url: window.location.href, }); } catch { // User cancelled or share failed } } else { navigator.clipboard.writeText(window.location.href); } }; const handleDirections = () => { if (coordinates) { window.open( `https://www.google.com/maps/dir/?api=1&destination=${coordinates.lat},${coordinates.lng}`, '_blank' ); } }; const handleDelete = async () => { if (!spot || !user) return; try { await publishEvent({ kind: 5, content: 'Deleted spot', tags: [ ['e', spot.id], ['a', `${spot.event.kind}:${spot.pubkey}:${spot.identifier}`], ], }); setShowDeleteDialog(false); toast({ title: 'Spot deleted', description: 'Deletion request sent to relays. Most relays will remove this content.', }); queryClient.invalidateQueries({ queryKey: ['skate-spots'] }); queryClient.invalidateQueries({ queryKey: ['skate-spot', spot.identifier, spot.pubkey] }); queryClient.invalidateQueries({ queryKey: ['user-skate-spots', user.pubkey] }); setTimeout(() => navigate('/map'), 1000); } catch (error) { toast({ title: 'Delete failed', description: error instanceof Error ? error.message : 'Something went wrong', variant: 'destructive', }); setShowDeleteDialog(false); } }; if (isLoading) { return ; } if (error || !spot) { return ; } return (
{/* Back link */} Back to Map
{/* Main Content */}
{/* Image Gallery - Combines owner photos + community photos */} {(() => { const allImages = [ ...spot.images.map(url => ({ url, isOwner: true })), ...(communityPhotos || []).map(p => ({ url: p.url, isOwner: false, caption: p.caption })), ]; return allImages.length > 0 ? ( <>
{spot.title} {!allImages[0].isOwner && (
Community
)}
{/* Additional images */} {allImages.length > 1 && (
{allImages.slice(1, 5).map((img, idx) => (
{`${spot.title} {!img.isOwner && (
)}
))}
)} ) : (
); })()} {/* Spot Info */}

{spot.title}

{spot.location && (

{spot.location}

)}
{isOwner && ( <> )} {user && !isShop && ( <> )} {/* More options menu */} {!isOwner && ( setShowReportDialog(true)} className="text-red-500 focus:text-red-500" > Report {isShop ? 'Shop' : 'Spot'} )}
{/* Access Type & Types */}
{spot.accessType && ( {ACCESS_TYPE_INFO[spot.accessType].emoji} {ACCESS_TYPE_INFO[spot.accessType].label} )} {spot.types.map((type) => ( {SPOT_TYPE_LABELS[type] || type} ))} {/* Community suggested tags */} {aggregatedTags.length > 0 && aggregatedTags.map(({ tag, count }) => ( 1 ? 's' : ''}`} > {SPOT_TYPE_LABELS[tag] || tag} {count > 1 && ({count})} ))}
{/* Community Contributions Section */} {!isShop && (
Community {((communityPhotos?.length || 0) + aggregatedTags.length) > 0 && ( {(communityPhotos?.length || 0) + aggregatedTags.length} )}

Help improve this spot by adding photos or suggesting tags

)} {/* Spot Ownership - Transfer / Claim */} {/* Claim Shop Notice */} {isShop && !isOwner && user && (
🏪

Are You the Shop Owner?

If you own or manage this shop, you can claim it to verify ownership and manage the listing.

{hasNip05 ? ( ) : (

⚠️ NIP-05 verification required to claim shops

)}
)} {/* Contact Info for Private/Locals Spots */} {spot.contactInfo && (spot.accessType === 'private' || spot.accessType === 'locals') && user && (
{spot.accessType === 'private' ? '🔒' : '👥'}

Contact Info

{spot.contactInfo}

{spot.accessType === 'private' ? 'Contact for permission' : 'Ask before skating'}

)} {/* Description */} {spot.description && (

{spot.description}

)} {/* Gear Swap Section - Only show on actual skate spots, not shops */} {!isShop && (
GEAR SWAP {marketplaceItems && marketplaceItems.length > 0 && ( {marketplaceItems.length} )}

Skaters at this spot are selling gear

{user && ( )}
{isLoadingMarketplace ? (
{Array.from({ length: 2 }).map((_, i) => ( ))}
) : marketplaceItems && marketplaceItems.length > 0 ? (
{marketplaceItems.map((item) => ( ))}

💡 Meet at this spot for safe pickup

) : (

NO GEAR LISTED YET

Be the first to list gear at this spot!

{user && ( )}
)}
)} {/* Services Section - Coaches & Filmers */} {!isShop && (spotServices?.coaches.length || spotServices?.filmers.length || isLoadingServices) && ( SERVICES {((spotServices?.coaches.length || 0) + (spotServices?.filmers.length || 0)) > 0 && ( {(spotServices?.coaches.length || 0) + (spotServices?.filmers.length || 0)} )}

Coaches and filmers available at this spot

{isLoadingServices ? (
{Array.from({ length: 2 }).map((_, i) => ( ))}
) : (
{/* Coaches */} {spotServices?.coaches && spotServices.coaches.length > 0 && (

Coaches ({spotServices.coaches.length})

{spotServices.coaches.map((coach) => (
{coach.image && } {coach.title.slice(0, 2).toUpperCase()}

{coach.title}

{coach.skills.slice(0, 3).map((skill) => ( {skill} ))}
{coach.rate && (
{coach.rate}
)}
))}
)} {/* Filmers */} {spotServices?.filmers && spotServices.filmers.length > 0 && (

{spotServices.filmers.map((filmer) => (
{filmer.image && } {filmer.title.slice(0, 2).toUpperCase()}

{filmer.title}

{filmer.skills.slice(0, 3).map((skill) => ( {skill} ))}
{filmer.rate && (
{filmer.rate}
)}
))}
)}

💡 Offer your services at this spot

)}
)} {/* Comments Section */} {spot.event && }
{/* Sidebar */}
{/* Who's Skating - Only show for regular spots, not shops */} {!isShop && ( )} {/* Weather Card */} {weatherData && ( Weather Conditions {/* Current Weather */}
{weatherData.weather.weatherIcon}
{weatherData.weather.temperature}°C
{weatherData.weather.weatherDescription}
{/* Skateability Score */}
Skateability {weatherData.skateability.score}/100
{weatherData.skateability.label}
{weatherData.skateability.reasons.join(' • ')}
{/* Weather Details */}
Wind
{weatherData.weather.windSpeed} km/h {weatherData.weather.windDirectionLabel}
Humidity
{weatherData.weather.humidity}%
{weatherData.weather.uvIndex > 0 && (
UV Index
{weatherData.weather.uvIndex}
)} {weatherData.airQuality && (
Air Quality
{weatherData.airQuality.aqiLabel}
)}
{/* 3-Day Forecast */} {weatherData.forecast?.daily && weatherData.forecast.daily.length > 0 && (
3-Day Forecast
{weatherData.forecast.daily.map((day, idx) => { const date = new Date(day.date); const dayName = idx === 0 ? 'Today' : idx === 1 ? 'Tomorrow' : date.toLocaleDateString('en-US', { weekday: 'short' }); return (
{dayName}
{day.weatherIcon}
{day.maxTemp}°/{day.minTemp}°
{day.precipitationProbability > 0 && (
{day.precipitationProbability}% 🌧️
)}
); })}
)} {/* Feels Like */} {weatherData.weather.feelsLike !== weatherData.weather.temperature && (
Feels like {weatherData.weather.feelsLike}°C
)}
)} {/* Loading Weather */} {weatherLoading && ( Weather Conditions )} {/* Map */} Location
{coordinates && ( )}
{/* Author Card with Tip the Mapper */} ⚡ Tip the Mapper
{displayName.slice(0, 2).toUpperCase()}

{displayName}

{new Date(spot.createdAt * 1000).toLocaleDateString()}

Found this spot useful? Send a zap to thank the mapper!

{/* Delete Confirmation Dialog */} Delete Spot? This will permanently delete "{spot.title}" from all relays. This action cannot be undone. Cancel Delete Spot {/* Add to Collection Dialog */} {!isShop && ( )} {/* Report Dialog */} {/* Set Status Dialog */}
); }