SkateSpots/src/pages/SpotDetailPage.tsx
SkateSpots f4192e2f3d fix(ux): Batch A part 1 — dead flows
- SpotDetailPage: 'Claim This Shop' was a no-op button; wire to NIP-05 settings
  (and stop the full-page <a href> reload)
- TourDetailPage: 'Contact Guide to Book' used /messages?to= which nothing read,
  so booking silently opened an empty inbox -> /messages/:npub
- ContactsPage: row 'Message' went to the generic inbox -> the contact's thread
- SettingsPage: ?section=follows|language|stash rendered a blank dead end;
  unknown sections now fall back to the menu
- DirectMessages/Conversation: logged-out states were dead ends -> add LoginArea
- Conversation: failed sends were silent -> error toast
2026-09-26 17:43:19 +07:00

1088 lines
50 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 { 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 { ImageWithFallback } from '@/components/ImageWithFallback';
import { ImageLightbox, type LightboxImage } from '@/components/ImageLightbox';
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 { 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 } 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,
MessageCircle,
} 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,
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 { 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 (
<Layout>
<div className="container px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-6 min-w-0">
<Skeleton className="aspect-video w-full rounded-xl" />
<div className="space-y-4">
<Skeleton className="h-10 w-3/4" />
<Skeleton className="h-6 w-1/2" />
<div className="flex gap-2">
<Skeleton className="h-6 w-20" />
<Skeleton className="h-6 w-20" />
</div>
<Skeleton className="h-24 w-full" />
</div>
</div>
<div className="space-y-6 min-w-0">
<Skeleton className="h-64 w-full rounded-xl" />
<Skeleton className="h-32 w-full rounded-xl" />
</div>
</div>
</div>
</Layout>
);
}
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, 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);
const [showStatusDialog, setShowStatusDialog] = useState(false);
const [showRouteDialog, setShowRouteDialog] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(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 || '');
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 <SpotDetailSkeleton />;
}
if (error || !spot) {
return <NotFound />;
}
return (
<Layout>
<div className="container px-4 pt-4 pb-8 sm:py-8 overflow-x-hidden">
{/* Back link */}
<Link
to="/map"
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors mb-4"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Map
</Link>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6 min-w-0">
{/* Image Gallery - owner photos + community photos; click to view full size */}
{galleryImages.length > 0 ? (
<>
<button
type="button"
onClick={() => setLightboxIndex(0)}
className="group relative aspect-video w-full cursor-zoom-in overflow-hidden rounded-xl bg-muted"
aria-label="View photo full screen"
>
<ImageWithFallback
src={galleryImages[0].url}
alt={spot.title}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.02]"
/>
{galleryImages[0].isOwner === false && (
<div className="absolute bottom-2 left-2 flex items-center gap-1 rounded bg-black/70 px-2 py-1 text-xs text-[#ccff00]">
<Users className="h-3 w-3" />
Community
</div>
)}
</button>
{/* Additional images */}
{galleryImages.length > 1 && (
<div className="grid grid-cols-4 gap-2">
{galleryImages.slice(1, 5).map((img, idx) => {
const realIndex = idx + 1;
const remaining = galleryImages.length - 5;
return (
<button
key={`${img.url}-${realIndex}`}
type="button"
onClick={() => setLightboxIndex(realIndex)}
className="relative aspect-square cursor-zoom-in overflow-hidden rounded-lg bg-muted"
aria-label={`View photo ${realIndex + 1} of ${galleryImages.length}`}
>
<ImageWithFallback
src={img.url}
alt={`${spot.title} ${realIndex + 1}`}
className="h-full w-full object-cover"
/>
{img.isOwner === false && (
<div className="absolute bottom-1 left-1 flex items-center gap-0.5 rounded bg-black/70 px-1.5 py-0.5 text-[10px] text-[#ccff00]">
<Users className="h-2.5 w-2.5" />
</div>
)}
{idx === 3 && remaining > 0 && (
<div className="absolute inset-0 flex items-center justify-center bg-black/70 text-sm font-bold text-white">
+{remaining}
</div>
)}
</button>
);
})}
</div>
)}
</>
) : (
<div className="flex aspect-video items-center justify-center rounded-xl bg-gradient-to-br from-primary/20 to-accent/20">
<MapPin className="h-16 w-16 text-primary/40" />
</div>
)}
{/* Spot Info */}
<div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div className="min-w-0 flex-1">
<h1 className="text-2xl sm:text-3xl font-bold break-words">{spot.title}</h1>
{spot.location && (
<p className="text-muted-foreground flex items-start gap-2 mt-2 text-sm sm:text-base">
<MapPin className="h-4 w-4 flex-shrink-0 mt-0.5" />
<span className="break-words">{spot.location}</span>
</p>
)}
{!isShop && spotStatus.status && (
<div className="mt-2">
<SpotStatusBadge aggregated={spotStatus} />
</div>
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
{isOwner && (
<Link to={`/edit-spot/${spot.pubkey}/${spot.identifier}`}>
<Button variant="outline" size="icon">
<Pencil className="h-4 w-4" />
</Button>
</Link>
)}
{canDelete && (
<Button
variant="outline"
size="icon"
onClick={() => setShowDeleteDialog(true)}
title={adminTakedown ? 'Remove (admin)' : 'Delete'}
className={cn(
'hover:bg-destructive hover:text-destructive-foreground',
adminTakedown && 'border-red-500/50 text-red-400',
)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
{user && !isShop && (
<>
<Button
variant="outline"
size="icon"
onClick={() => setShowCollectionDialog(true)}
className="hover:bg-[#ccff00]/10 hover:text-[#ccff00] hover:border-[#ccff00]/50"
>
<Bookmark className="h-4 w-4" />
</Button>
<Button
onClick={() => setShowStatusDialog(true)}
className="gap-2 bg-green-500 hover:bg-green-600 text-white font-bold"
>
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-white opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-white" />
</span>
I'm Here
</Button>
</>
)}
{!isShop && (
<ReportSpotStatusDialog
spotPubkey={spot.pubkey}
spotIdentifier={spot.identifier}
spotTitle={spot.title}
/>
)}
<Button variant="outline" size="icon" onClick={handleShare}>
<Share2 className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={handleDirections} aria-label="Get directions">
<Navigation className="h-4 w-4" />
</Button>
{/* More options menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{!isOwner && (
<DropdownMenuItem
onClick={() => setShowReportDialog(true)}
className="text-red-500 focus:text-red-500"
>
<Flag className="h-4 w-4 mr-2" />
Report {isShop ? 'Shop' : 'Spot'}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Access Type & Types */}
<div className="flex flex-wrap gap-2">
{spot.accessType && (
<Badge
className="border-2 font-bold uppercase"
style={{
borderColor: ACCESS_TYPE_INFO[spot.accessType].color,
color: ACCESS_TYPE_INFO[spot.accessType].color,
backgroundColor: `${ACCESS_TYPE_INFO[spot.accessType].color}20`
}}
>
{ACCESS_TYPE_INFO[spot.accessType].emoji} {ACCESS_TYPE_INFO[spot.accessType].label}
</Badge>
)}
{spot.shelter && spot.shelter !== 'outdoor' && SHELTER_INFO[spot.shelter] && (
<Badge
className="border-2 font-bold uppercase"
style={{
borderColor: SHELTER_INFO[spot.shelter].color,
color: SHELTER_INFO[spot.shelter].color,
backgroundColor: `${SHELTER_INFO[spot.shelter].color}20`,
}}
>
{SHELTER_INFO[spot.shelter].emoji} {SHELTER_INFO[spot.shelter].label}
</Badge>
)}
{spot.types.map((type) => (
<Badge key={type} className="bg-[#ccff00]/20 text-[#ccff00] border border-[#ccff00]/50 font-bold uppercase">
{SPOT_TYPE_LABELS[type] || type}
</Badge>
))}
{/* Community suggested tags */}
{aggregatedTags.length > 0 && aggregatedTags.map(({ tag, count }) => (
<Badge
key={tag}
className="bg-blue-500/10 text-blue-400 border border-blue-500/30 font-medium"
title={`Suggested by ${count} user${count > 1 ? 's' : ''}`}
>
<Users className="h-3 w-3 mr-1" />
{SPOT_TYPE_LABELS[tag] || tag}
{count > 1 && <span className="ml-1 text-xs opacity-70">({count})</span>}
</Badge>
))}
</div>
{/* Community Contributions Section */}
{!isShop && (
<Card className="bg-black/90 border-2 border-blue-500/30 overflow-hidden">
<CardContent className="p-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<Users className="h-5 w-5 text-blue-400 flex-shrink-0" />
<span className="font-bold text-blue-400 uppercase text-sm whitespace-nowrap">Community</span>
{((communityPhotos?.length || 0) + aggregatedTags.length) > 0 && (
<Badge variant="secondary" className="bg-blue-500/20 text-blue-400 border-blue-500/50">
{(communityPhotos?.length || 0) + aggregatedTags.length}
</Badge>
)}
</div>
<div className="flex gap-2 flex-shrink-0">
<AddPhotoToSpot
spotPubkey={spot.pubkey}
spotIdentifier={spot.identifier}
spotTitle={spot.title}
/>
<AddTagsToSpot
spotPubkey={spot.pubkey}
spotIdentifier={spot.identifier}
spotTitle={spot.title}
existingTags={spot.tags}
/>
</div>
</div>
<p className="text-xs text-gray-500 mt-2">
Help improve this spot by adding photos or suggesting tags
</p>
</CardContent>
</Card>
)}
{/* Spot Ownership - Transfer / Claim */}
<SpotOwnership spot={spot} />
{/* Claim Shop Notice */}
{isShop && !isOwner && user && (
<Card className="bg-[#ccff00]/10 border-2 border-[#ccff00]/50">
<CardContent className="p-6">
<div className="flex items-start gap-3">
<div className="text-2xl">🏪</div>
<div className="flex-1">
<h3 className="font-bold text-[#ccff00] mb-2 uppercase">Are You the Shop Owner?</h3>
<p className="text-sm text-gray-300 mb-4">
If you own or manage this shop, you can claim it to verify ownership and manage the listing.
</p>
{hasNip05 ? (
<div>
<p className="text-xs text-gray-400 mb-3">
To claim this shop, add a NIP-05 identifier that matches the shop's website domain.
</p>
<Link to="/settings">
<Button className="gap-2 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase w-full">
<Shield className="h-4 w-4" />
Manage NIP-05 to Claim
</Button>
</Link>
</div>
) : (
<div>
<p className="text-xs text-yellow-400 mb-2">⚠️ NIP-05 verification required to claim shops</p>
<Link to="/settings">
<Button variant="outline" size="sm" className="border-yellow-500/50 hover:bg-yellow-500/10 text-yellow-400 font-bold">
Add NIP-05 in Settings
</Button>
</Link>
</div>
)}
</div>
</div>
</CardContent>
</Card>
)}
{/* Contact Info for Private/Locals Spots */}
{spot.contactInfo && (spot.accessType === 'private' || spot.accessType === 'locals') && user && (
<Card className="bg-[#ff6600]/10 border-2 border-[#ff6600]/50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="text-xl">{spot.accessType === 'private' ? '🔒' : '👥'}</div>
<div>
<h4 className="font-bold text-[#ff6600] mb-1 uppercase text-sm">Contact Info</h4>
<p className="text-sm text-gray-300">{spot.contactInfo}</p>
<p className="text-xs text-gray-500 mt-1">
{spot.accessType === 'private' ? 'Contact for permission' : 'Ask before skating'}
</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Description */}
{spot.description && (
<Card className="bg-black/90 border-2 border-gray-700 overflow-hidden">
<CardContent className="pt-6 px-4 sm:px-6">
<p className="whitespace-pre-wrap text-gray-300 break-words">{spot.description}</p>
</CardContent>
</Card>
)}
{/* Gear Swap Section - Only show on actual skate spots, not shops */}
{!isShop && (
<Card className="bg-black/90 border-2 border-orange-500/30 overflow-hidden">
<CardHeader className="border-b border-orange-500/20 px-4 sm:px-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="min-w-0">
<CardTitle className="text-lg sm:text-2xl font-black uppercase flex items-center gap-2 flex-wrap">
<ShoppingBag className="h-5 w-5 sm:h-6 sm:w-6 text-orange-500 flex-shrink-0" />
<span className="neon-orange">GEAR SWAP</span>
{marketplaceItems && marketplaceItems.length > 0 && (
<Badge variant="secondary" className="bg-orange-500/20 text-orange-400 border-orange-500/50">
{marketplaceItems.length}
</Badge>
)}
</CardTitle>
<p className="text-sm text-gray-400 mt-2">
Skaters at this spot are selling gear
</p>
</div>
{user && (
<Link to={`/create-listing?spot=${pubkey}:${identifier}`} className="flex-shrink-0">
<Button
size="sm"
className="gap-2 bg-orange-500 hover:bg-orange-600 font-bold uppercase w-full sm:w-auto"
>
<Plus className="h-4 w-4" />
List Gear
</Button>
</Link>
)}
</div>
</CardHeader>
<CardContent className="pt-6">
{isLoadingMarketplace ? (
<div className="space-y-4">
{Array.from({ length: 2 }).map((_, i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
) : marketplaceItems && marketplaceItems.length > 0 ? (
<div className="space-y-4">
{marketplaceItems.map((item) => (
<MarketplaceItemCard key={item.id} item={item} />
))}
<div className="pt-4 border-t border-orange-500/20 text-center">
<p className="text-sm text-gray-400 mb-2">
💡 Meet at this spot for safe pickup
</p>
</div>
</div>
) : (
<div className="text-center py-12">
<ShoppingBag className="h-12 w-12 mx-auto mb-4 text-orange-500/50" />
<h3 className="text-lg font-bold mb-2">NO GEAR LISTED YET</h3>
<p className="text-gray-400 mb-6">
Be the first to list gear at this spot!
</p>
{user && (
<Link to={`/create-listing?spot=${pubkey}:${identifier}`}>
<Button className="gap-2 bg-orange-500 hover:bg-orange-600 font-bold uppercase">
<Plus className="h-4 w-4" />
List Your Gear Here
</Button>
</Link>
)}
</div>
)}
</CardContent>
</Card>
)}
{/* Services Section - Coaches & Filmers */}
{!isShop && (spotServices?.coaches.length || spotServices?.filmers.length || isLoadingServices) && (
<Card className="bg-black/90 border-2 border-lime-500/30 overflow-hidden">
<CardHeader className="border-b border-lime-500/20 px-4 sm:px-6">
<CardTitle className="text-lg sm:text-2xl font-black uppercase flex items-center gap-2 flex-wrap">
<GraduationCap className="h-5 w-5 sm:h-6 sm:w-6 text-lime-400 flex-shrink-0" />
<span className="neon-lime">SERVICES</span>
{((spotServices?.coaches.length || 0) + (spotServices?.filmers.length || 0)) > 0 && (
<Badge variant="secondary" className="bg-lime-500/20 text-lime-400 border-lime-500/50">
{(spotServices?.coaches.length || 0) + (spotServices?.filmers.length || 0)}
</Badge>
)}
</CardTitle>
<p className="text-sm text-gray-400 mt-2">
Coaches and filmers available at this spot
</p>
</CardHeader>
<CardContent className="pt-6">
{isLoadingServices ? (
<div className="space-y-4">
{Array.from({ length: 2 }).map((_, i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
) : (
<div className="space-y-6">
{/* Coaches */}
{spotServices?.coaches && spotServices.coaches.length > 0 && (
<div>
<h3 className="text-sm font-bold text-lime-400 uppercase tracking-wide mb-3 flex items-center gap-2">
<GraduationCap className="h-4 w-4" />
Coaches ({spotServices.coaches.length})
</h3>
<div className="space-y-3">
{spotServices.coaches.map((coach) => (
<Link
key={coach.id}
to={`/coach/${coach.pubkey}/${coach.identifier}`}
className="block"
>
<div className="flex items-center gap-4 p-4 rounded-lg bg-lime-500/5 border border-lime-500/20 hover:bg-lime-500/10 hover:border-lime-500/40 transition-colors">
<Avatar className="h-12 w-12 border-2 border-lime-500/30">
{coach.image && <AvatarImage src={coach.image} alt={coach.title} />}
<AvatarFallback className="bg-lime-500/20 text-lime-400">
{coach.title.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<h4 className="font-bold truncate">{coach.title}</h4>
<div className="flex flex-wrap gap-1 mt-1">
{coach.skills.slice(0, 3).map((skill) => (
<Badge key={skill} variant="outline" className="text-[10px] border-lime-500/30 text-lime-400">
{skill}
</Badge>
))}
</div>
</div>
{coach.rate && (
<div className="text-right">
<div className="text-sm font-bold text-lime-400">{coach.rate}</div>
</div>
)}
</div>
</Link>
))}
</div>
</div>
)}
{/* Filmers */}
{spotServices?.filmers && spotServices.filmers.length > 0 && (
<div>
<h3 className="text-sm font-bold text-purple-400 uppercase tracking-wide mb-3 flex items-center gap-2">
<Video className="h-4 w-4" />
Filmers ({spotServices.filmers.length})
</h3>
<div className="space-y-3">
{spotServices.filmers.map((filmer) => (
<Link
key={filmer.id}
to={`/coach/${filmer.pubkey}/${filmer.identifier}`}
className="block"
>
<div className="flex items-center gap-4 p-4 rounded-lg bg-purple-500/5 border border-purple-500/20 hover:bg-purple-500/10 hover:border-purple-500/40 transition-colors">
<Avatar className="h-12 w-12 border-2 border-purple-500/30">
{filmer.image && <AvatarImage src={filmer.image} alt={filmer.title} />}
<AvatarFallback className="bg-purple-500/20 text-purple-400">
{filmer.title.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<h4 className="font-bold truncate">{filmer.title}</h4>
<div className="flex flex-wrap gap-1 mt-1">
{filmer.skills.slice(0, 3).map((skill) => (
<Badge key={skill} variant="outline" className="text-[10px] border-purple-500/30 text-purple-400">
{skill}
</Badge>
))}
</div>
</div>
{filmer.rate && (
<div className="text-right">
<div className="text-sm font-bold text-purple-400">{filmer.rate}</div>
</div>
)}
</div>
</Link>
))}
</div>
</div>
)}
<div className="pt-4 border-t border-lime-500/20">
<p className="text-sm text-gray-400 text-center mb-3">
💡 Offer your services at this spot
</p>
<div className="flex gap-2 justify-center">
<Link to="/become/coach">
<Button size="sm" variant="outline" className="gap-2 border-lime-500/50 text-lime-400 hover:bg-lime-500/10">
<GraduationCap className="h-4 w-4" />
Coach Here
</Button>
</Link>
<Link to="/become/filmer">
<Button size="sm" variant="outline" className="gap-2 border-purple-500/50 text-purple-400 hover:bg-purple-500/10">
<Video className="h-4 w-4" />
Film Here
</Button>
</Link>
</div>
</div>
</div>
)}
</CardContent>
</Card>
)}
{/* Crews that skate here — reverse link into the Crews scene */}
{!isShop && (
<CrewsForSpotCard spotPubkey={spot.pubkey} spotIdentifier={spot.identifier} />
)}
{/* Spot Chat - live local channel for this spot */}
{!isShop && spot.geohash && (
<SpotChat geohash={spot.geohash} defaultPrecision="block" />
)}
{/* Comments Section */}
{spot.event && <CommentsSection root={spot.event} />}
</div>
</div>
{/* Sidebar */}
<div className="space-y-6 min-w-0">
{/* Who's Skating - Only show for regular spots, not shops */}
{!isShop && (
<WhosSkating
spotPubkey={spot.pubkey}
spotIdentifier={spot.identifier}
/>
)}
{/* Weather Card */}
{weatherData && (
<Card className="bg-gradient-to-br from-blue-500/10 to-purple-500/10 border-2 border-blue-500/30">
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<Cloud className="h-5 w-5 text-blue-400" />
Weather Conditions
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Current Weather */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-5xl">{weatherData.weather.weatherIcon}</span>
<div>
<div className="text-3xl font-bold">{weatherData.weather.temperature}°C</div>
<div className="text-sm text-muted-foreground">{weatherData.weather.weatherDescription}</div>
</div>
</div>
</div>
{/* Skateability Score */}
<div className="p-3 rounded-lg" style={{ backgroundColor: `${weatherData.skateability.color}20`, border: `2px solid ${weatherData.skateability.color}` }}>
<div className="flex items-center justify-between mb-2">
<span className="font-semibold" style={{ color: weatherData.skateability.color }}>
Skateability
</span>
<span className="text-2xl font-bold" style={{ color: weatherData.skateability.color }}>
{weatherData.skateability.score}/100
</span>
</div>
<div className="text-sm font-bold uppercase" style={{ color: weatherData.skateability.color }}>
{weatherData.skateability.label}
</div>
<div className="mt-2 text-xs text-muted-foreground">
{weatherData.skateability.reasons.join(' • ')}
</div>
</div>
{/* Weather Details */}
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="flex items-center gap-2">
<Wind className="h-4 w-4 text-blue-400" />
<div>
<div className="text-xs text-muted-foreground">Wind</div>
<div className="font-medium">{weatherData.weather.windSpeed} km/h {weatherData.weather.windDirectionLabel}</div>
</div>
</div>
<div className="flex items-center gap-2">
<Droplets className="h-4 w-4 text-blue-400" />
<div>
<div className="text-xs text-muted-foreground">Humidity</div>
<div className="font-medium">{weatherData.weather.humidity}%</div>
</div>
</div>
{weatherData.weather.uvIndex > 0 && (
<div className="flex items-center gap-2">
<Sun className="h-4 w-4 text-yellow-400" />
<div>
<div className="text-xs text-muted-foreground">UV Index</div>
<div className="font-medium">{weatherData.weather.uvIndex}</div>
</div>
</div>
)}
{weatherData.airQuality && (
<div className="flex items-center gap-2">
<Cloud className="h-4 w-4" style={{ color: weatherData.airQuality.aqiColor }} />
<div>
<div className="text-xs text-muted-foreground">Air Quality</div>
<div className="font-medium">{weatherData.airQuality.aqiLabel}</div>
</div>
</div>
)}
</div>
{/* 3-Day Forecast */}
{weatherData.forecast?.daily && weatherData.forecast.daily.length > 0 && (
<div className="pt-3 border-t border-border">
<div className="text-sm font-semibold mb-3">3-Day Forecast</div>
<div className="grid grid-cols-3 gap-2">
{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 (
<div key={day.date} className="text-center p-2 rounded-lg bg-background/50">
<div className="text-xs font-semibold text-muted-foreground mb-1">{dayName}</div>
<div className="text-2xl my-1">{day.weatherIcon}</div>
<div className="text-sm font-bold">
{day.maxTemp}°<span className="text-muted-foreground">/{day.minTemp}°</span>
</div>
{day.precipitationProbability > 0 && (
<div className="text-xs text-blue-400 mt-1">
{day.precipitationProbability}% 🌧️
</div>
)}
</div>
);
})}
</div>
</div>
)}
{/* Feels Like */}
{weatherData.weather.feelsLike !== weatherData.weather.temperature && (
<div className="text-xs text-muted-foreground text-center pt-2 border-t border-border">
Feels like {weatherData.weather.feelsLike}°C
</div>
)}
</CardContent>
</Card>
)}
{/* Loading Weather */}
{weatherLoading && (
<Card className="bg-gradient-to-br from-blue-500/10 to-purple-500/10 border-2 border-blue-500/30">
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<Cloud className="h-5 w-5 text-blue-400" />
Weather Conditions
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-16 w-full" />
</CardContent>
</Card>
)}
{/* Map */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<MapPin className="h-5 w-5 text-primary" />
Location
</CardTitle>
</CardHeader>
<CardContent>
<div className="aspect-square rounded-lg overflow-hidden">
<SpotMap
spots={[spot]}
center={coordinates ? [coordinates.lat, coordinates.lng] : undefined}
zoom={15}
className="h-full"
/>
</div>
{coordinates && (
<Button
variant="outline"
className="w-full mt-4 gap-2"
onClick={handleDirections}
>
<Navigation className="h-4 w-4" />
Get Directions
</Button>
)}
</CardContent>
</Card>
{/* Author Card with Tip the Mapper */}
<Card className="bg-gradient-to-br from-[#ccff00]/5 to-orange-500/5 border-2 border-[#ccff00]/30">
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<span className="text-[#ccff00]">⚡</span>
Tip the Mapper
</CardTitle>
</CardHeader>
<CardContent>
<Link
to={`/${mapperNpub}`}
className="flex items-center gap-3 mb-4 group rounded-xl -m-1 p-1 hover:bg-white/5 transition-colors min-w-0"
>
<Avatar className="h-12 w-12 border-2 border-[#ccff00]/30">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-[#ccff00]/10 text-[#ccff00]">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="font-medium truncate group-hover:text-[#ccff00] transition-colors">
{displayName}
</p>
<p className="text-sm text-muted-foreground flex items-center gap-1">
<Calendar className="h-3 w-3" />
{new Date(spot.createdAt * 1000).toLocaleDateString()}
</p>
</div>
</Link>
<p className="text-xs text-gray-400 mb-3">
Found this spot useful? Send a zap to thank the mapper!
</p>
<div className="space-y-2">
<ZapButton
target={spot.event}
className="w-full justify-center bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold"
/>
{/* Message the mapper — e.g. to ask them to add photos.
Hidden when you're the owner (can't message yourself). */}
{!isOwner && (
<Button
asChild
variant="outline"
className="w-full justify-center border-[#ccff00]/40 text-[#ccff00] hover:bg-[#ccff00]/10 hover:text-[#ccff00] font-bold"
>
<Link to={`/messages/${mapperNpub}`}>
<MessageCircle className="h-4 w-4 mr-2" />
Message Mapper
</Link>
</Button>
)}
</div>
</CardContent>
</Card>
</div>
</div>
</div>
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent className="bg-black border-2 border-red-500/50">
<AlertDialogHeader>
<AlertDialogTitle className="text-white flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-red-500" />
{adminTakedown ? 'Remove this spot? (admin)' : 'Delete Spot?'}
</AlertDialogTitle>
<AlertDialogDescription className="text-gray-300">
{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.`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="border-gray-600">Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-red-500 hover:bg-red-600 text-white"
>
{adminTakedown ? 'Remove Spot' : 'Delete Spot'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Add to 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}
/>
{/* Set Status Dialog */}
<SetStatusDialog
open={showStatusDialog}
onOpenChange={setShowStatusDialog}
preselectedSpot={spot}
/>
{/* In-app route (no external maps) */}
<RouteDialog
open={showRouteDialog}
onOpenChange={setShowRouteDialog}
destination={coordinates}
destinationLabel={spot?.title ?? 'Spot'}
/>
{/* Full-screen photo viewer */}
<ImageLightbox
images={galleryImages}
index={lightboxIndex}
onIndexChange={setLightboxIndex}
onClose={() => setLightboxIndex(null)}
/>
</Layout>
);
}