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 { ImageWithFallback } from '@/components/ImageWithFallback';
import { ImageLightbox, type LightboxImage } from '@/components/ImageLightbox';
import { PageError } from '@/components/ErrorState';
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 { SpotChat } from '@/components/SpotChat';
import { CrewsForSpotCard } from '@/components/CrewsForSpotCard';
import { RouteDialog } from '@/components/RouteDialog';
import { useSkateSpot } from '@/hooks/useSkateSpots';
import { useAuthor } from '@/hooks/useAuthor';
import { useDeleteEntity } from '@/hooks/useDeleteEntity';
import { useNotASpotReport } from '@/hooks/useNotASpotReport';
import { useAdminModeration } from '@/hooks/useAdminModeration';
import { isAdminPubkey } from '@/lib/admins';
import { cn } from '@/lib/utils';
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,
SHELTER_INFO,
SKATE_SPOT_KIND,
} from '@/lib/types';
import NotFound from './NotFound';
import {
MapPin,
ArrowLeft,
Share2,
Navigation,
Calendar,
Pencil,
Trash2,
AlertTriangle,
Shield,
Cloud,
Wind,
Droplets,
Sun,
ShoppingBag,
Plus,
Bookmark,
Users,
GraduationCap,
Video,
Flag,
MoreHorizontal,
MessageCircle,
ShieldCheck,
ShieldQuestion,
BadgeCheck,
} from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { ReportDialog } from '@/components/ReportDialog';
import { WhosSkating } from '@/components/WhosSkating';
import { SetStatusDialog } from '@/components/SetStatusDialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
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 { SpotStatusBadge } from '@/components/SpotStatusBadge';
import { ReportSpotStatusDialog } from '@/components/ReportSpotStatusDialog';
import { useSpotStatus } from '@/hooks/useSpotStatus';
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, refetch } = 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, metadata: userMetadata } = useCurrentUser();
const { mutateAsync: deleteEntity } = useDeleteEntity();
const { toast } = useToast();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showCollectionDialog, setShowCollectionDialog] = useState(false);
const [showReportDialog, setShowReportDialog] = useState(false);
// One-tap off-topic report (test entries, airports, malls, private land…).
const {
reportNotASpot,
isPending: reportNotASpotPending,
alreadyReported,
} = useNotASpotReport();
const handleNotASpot = () => {
if (!spot) return;
void reportNotASpot({ eventId: spot.id, pubkey: spot.pubkey, title: spot.title });
};
const [showStatusDialog, setShowStatusDialog] = useState(false);
const [showRouteDialog, setShowRouteDialog] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(null);
const isOwner = user && spot && user.pubkey === spot.pubkey;
const isAdmin = isAdminPubkey(user?.pubkey);
// Owner can delete their own spot; a SkateSpots admin can take down any spot.
const canDelete = !!user && !!spot && (isOwner || isAdmin);
const adminTakedown = isAdmin && !isOwner;
const isShop = spot?.types.includes('shop');
const hasNip05 = userMetadata?.nip05;
// Community contributions
const { data: communityPhotos } = useSpotPhotos(pubkey || '', identifier || '');
const aggregatedTags = useAggregatedSpotTags(pubkey || '', identifier || '');
// Community spot status (active / bust / skate-stopped / closed / gone)
const { aggregated: spotStatus } = useSpotStatus(pubkey || '', identifier || '');
/**
* Community confidence — a signal INDEPENDENT of the weather score, so a
* test/off-topic entry can't look endorsed just because the sun is out.
* Counts things only other skaters can produce: a community photo, or a
* status report ("I was here / bust / skate-stopped").
*/
const communitySignalCount =
(communityPhotos?.length ?? 0) + (spotStatus?.reporterCount ?? 0);
const communityVerified = communitySignalCount > 0;
// Staff verdict (NIP-32 `verified` label) — separate from the community
// signal: a human actually looked at this one.
const moderation = useAdminModeration();
const staffVerified = !!spot && moderation.isVerified({
coord: `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`,
id: spot.id,
});
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 || '');
// npub for linking to the mapper's profile / messaging them
const mapperNpub = spot?.pubkey ? nip19.npubEncode(spot.pubkey) : '';
// Full photo gallery: owner photos first, then community contributions.
const galleryImages: LightboxImage[] = [
...(spot?.images ?? []).map((url) => ({ url, isOwner: true })),
...(communityPhotos ?? []).map((p) => ({ url: p.url, isOwner: false, caption: p.caption ?? undefined })),
];
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) return;
// In-app routing — draws the route on our own map, nothing leaves to Google/Apple.
setShowRouteDialog(true);
};
const handleDelete = async () => {
if (!spot || !user) return;
try {
// Routes through useDeleteEntity: owner → NIP-09 delete; admin → NIP-09
// delete + a moderation label so the spot is hidden across the app.
await deleteEntity({
event: spot.event,
reason: adminTakedown ? `SkateSpots admin removed spot "${spot.title}"` : 'Deleted spot',
});
setShowDeleteDialog(false);
toast({
title: adminTakedown ? 'Spot removed' : 'Spot deleted',
description: adminTakedown
? 'This spot has been taken down and will be hidden across SkateSpots.'
: '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) {
return refetch()} />;
}
if (!spot) {
return ;
}
return (
<>
{/* Back link */}
Back to Map
{/* Main Content */}
{/* Image Gallery - owner photos + community photos; click to view full size */}
{galleryImages.length > 0 ? (
<>
setLightboxIndex(0)}
className="group relative aspect-video w-full cursor-zoom-in overflow-hidden rounded-xl bg-muted"
aria-label="View photo full screen"
>
{galleryImages[0].isOwner === false && (
Community
)}
{/* Additional images */}
{galleryImages.length > 1 && (
{galleryImages.slice(1, 5).map((img, idx) => {
const realIndex = idx + 1;
const remaining = galleryImages.length - 5;
return (
setLightboxIndex(realIndex)}
className="relative aspect-square cursor-zoom-in overflow-hidden rounded-lg bg-muted"
aria-label={`View photo ${realIndex + 1} of ${galleryImages.length}`}
>
{img.isOwner === false && (
)}
{idx === 3 && remaining > 0 && (
+{remaining}
)}
);
})}
)}
>
) : (
)}
{/* Spot Info */}
{spot.title}
{spot.location && (
{spot.location}
)}
{!isShop && spotStatus.status && (
)}
{isOwner && (
)}
{canDelete && (
setShowDeleteDialog(true)}
title={adminTakedown ? 'Remove (admin)' : 'Delete'}
className={cn(
'hover:bg-destructive hover:text-destructive-foreground',
adminTakedown && 'border-red-500/50 text-red-400',
)}
>
)}
{user && !isShop && (
<>
setShowCollectionDialog(true)}
className="hover:bg-[#ccff00]/10 hover:text-[#ccff00] hover:border-[#ccff00]/50"
>
setShowStatusDialog(true)}
className="gap-2 bg-green-500 hover:bg-green-600 text-white font-bold"
>
I'm Here
>
)}
{!isShop && (
)}
{/* More options menu */}
{!isOwner && (
setShowReportDialog(true)}
className="text-red-500 focus:text-red-500"
>
Report {isShop ? 'Shop' : 'Spot'}
)}
{/* One tap: this pin is off-topic (test entry, airport,
mall, private land…). Publishes a NIP-56 report with a
`not-a-skate-spot` tag for the admin queue. */}
{!isOwner && !isShop && (
{alreadyReported ? 'Reported as not a skate spot' : 'Not a skate spot'}
)}
{/* Access Type & Types */}
{spot.accessType && (
{ACCESS_TYPE_INFO[spot.accessType].emoji} {ACCESS_TYPE_INFO[spot.accessType].label}
)}
{spot.shelter && spot.shelter !== 'outdoor' && SHELTER_INFO[spot.shelter] && (
{SHELTER_INFO[spot.shelter].emoji} {SHELTER_INFO[spot.shelter].label}
)}
{/* Staff verdict wins if present: a human reviewed it. */}
{staffVerified && (
Verified by SkateSpots
)}
{/* Community confidence — NOT a quality/weather score. */}
{!staffVerified && (
{communityVerified ? (
<>
Community verified
{communitySignalCount > 1 && ` · ${communitySignalCount}`}
>
) : (
<>
Unverified
>
)}
)}
{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 ? (
To claim this shop, add a NIP-05 identifier that matches the shop's website domain.
Manage NIP-05 to Claim
) : (
⚠️ NIP-05 verification required to claim shops
Add NIP-05 in Settings
)}
)}
{/* 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 && (
List Gear
)}
{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 && (
List Your Gear Here
)}
)}
)}
{/* 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 && (
)}
))}
)}
{/* Filmers */}
{spotServices?.filmers && spotServices.filmers.length > 0 && (
Filmers ({spotServices.filmers.length})
{spotServices.filmers.map((filmer) => (
{filmer.image && }
{filmer.title.slice(0, 2).toUpperCase()}
{filmer.title}
{filmer.skills.slice(0, 3).map((skill) => (
{skill}
))}
{filmer.rate && (
)}
))}
)}
💡 Offer your services at this spot
Coach Here
Film Here
)}
)}
{/* Crews that skate here — reverse link into the Crews scene */}
{!isShop && (
)}
{/* Spot Chat - live local channel for this spot */}
{!isShop && spot.geohash && (
)}
{/* 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}
{/* Conditions score — WEATHER ONLY. Deliberately not called
"Skateability": this is about the sky, not whether the
place is a real, rideable spot. The community signal lives
in the badge next to the spot's type tags. */}
Conditions
weather only
{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 && (
Get Directions
)}
{/* 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!
{/* Message the mapper — e.g. to ask them to add photos.
Hidden when you're the owner (can't message yourself). */}
{!isOwner && (
Message Mapper
)}
{/* Delete Confirmation Dialog */}
{adminTakedown ? 'Remove this spot? (admin)' : 'Delete Spot?'}
{adminTakedown
? `As a SkateSpots admin you're taking down "${spot.title}". It will be hidden across the app for everyone and a deletion request will be sent to relays.`
: `This will permanently delete "${spot.title}" from all relays. This action cannot be undone.`}
Cancel
{adminTakedown ? 'Remove Spot' : 'Delete Spot'}
{/* Add to Collection Dialog */}
{!isShop && (
)}
{/* Report Dialog */}
{/* Set Status Dialog */}
{/* In-app route (no external maps) */}
{/* Full-screen photo viewer */}
setLightboxIndex(null)}
/>
>
);
}