- New src/lib/routing.ts: keyless OSRM client, travel modes, distance/skate-time formatters - New RouteDialog: self-contained Leaflet map drawing the route from the user's location to a spot/shop/event, with a mode toggle and distance/time summary - Wire the "Directions" buttons on Spot/Shop/Event detail pages to route in-app instead of opening Google/Apple Maps Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
1035 lines
48 KiB
TypeScript
1035 lines
48 KiB
TypeScript
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 { 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 } 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 lg:grid-cols-3 gap-8">
|
||
<div className="lg:col-span-2 space-y-6">
|
||
<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">
|
||
<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 } = 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 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 = user?.metadata?.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) : '';
|
||
|
||
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 lg:grid-cols-3 gap-8">
|
||
{/* Main Content */}
|
||
<div className="lg:col-span-2 space-y-6 min-w-0">
|
||
{/* 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 ? (
|
||
<>
|
||
<div className="aspect-video rounded-xl overflow-hidden bg-muted relative">
|
||
<img
|
||
src={allImages[0].url}
|
||
alt={spot.title}
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
{!allImages[0].isOwner && (
|
||
<div className="absolute bottom-2 left-2 px-2 py-1 bg-black/70 rounded text-xs text-[#ccff00] flex items-center gap-1">
|
||
<Users className="h-3 w-3" />
|
||
Community
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Additional images */}
|
||
{allImages.length > 1 && (
|
||
<div className="grid grid-cols-4 gap-2">
|
||
{allImages.slice(1, 5).map((img, idx) => (
|
||
<div key={idx} className="aspect-square rounded-lg overflow-hidden bg-muted relative">
|
||
<img src={img.url} alt={`${spot.title} ${idx + 2}`} className="w-full h-full object-cover" />
|
||
{!img.isOwner && (
|
||
<div className="absolute bottom-1 left-1 px-1.5 py-0.5 bg-black/70 rounded text-[10px] text-[#ccff00] flex items-center gap-0.5">
|
||
<Users className="h-2.5 w-2.5" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</>
|
||
) : (
|
||
<div className="aspect-video rounded-xl bg-gradient-to-br from-primary/20 to-accent/20 flex items-center justify-center">
|
||
<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.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 ? (
|
||
<Button className="gap-2 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase w-full">
|
||
<Shield className="h-4 w-4" />
|
||
Claim This Shop
|
||
</Button>
|
||
) : (
|
||
<div>
|
||
<p className="text-xs text-yellow-400 mb-2">⚠️ NIP-05 verification required to claim shops</p>
|
||
<a href="/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>
|
||
</a>
|
||
</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">
|
||
{/* 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"
|
||
>
|
||
<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'}
|
||
/>
|
||
</Layout>
|
||
);
|
||
}
|