SkateSpots/src/pages/SpotDetailPage.tsx
shakespeare.diy e29d35f1e4 fix: restore full skate-spots source tree from misplaced src/src/ copy
The entire skate-spots codebase was accidentally copied into src/src/ and
public/public/ instead of src/ and public/ directly. This left the real
src/ directory as blank MKStack template stubs, explaining why the landing
page showed the default template instead of SkateSpots.

Changes made:
- Copied all files from src/src/ up to src/ (App.tsx, AppRouter.tsx, main.tsx,
  all pages, components, hooks, contexts, lib, i18n, test)
- Copied all files from public/public/ up to public/ (images, manifest, etc.)
- Deleted src/src/ and public/public/ nested directories
- Removed HelloWorld.tsx template stub from pages/
- Retained the previously fixed index.css (Tailwind v3 directives)

Full skate-spots feature set is now active:
- All 30+ routes (map, events, spots, coaches, marketplace, wallet, etc.)
- NWCProvider, LanguageProvider, PWAInstallPrompt, AmberCallbackHandler
- Skate community relays (goskatespots.nostr1.com, wotr.relatr.xyz, etc.)
- Outfit Variable font, Amber pre-check in main.tsx
- All hooks (useSkateSpots, useCoaches, useZaps, etc.)
- i18n support (en/es)

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-06-15 22:18:28 +07:00

961 lines
44 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 { 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 (
<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: 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 <SpotDetailSkeleton />;
}
if (error || !spot) {
return <NotFound />;
}
return (
<Layout>
<div className="container px-4 py-8 overflow-x-hidden">
{/* Back link */}
<Link
to="/map"
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors mb-6"
>
<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>
)}
</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>
<Button
variant="outline"
size="icon"
onClick={() => setShowDeleteDialog(true)}
className="hover:bg-destructive hover:text-destructive-foreground"
>
<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>
</>
)}
<Button variant="outline" size="icon" onClick={handleShare}>
<Share2 className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={handleDirections}>
<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>
)}
{/* 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>
<div className="flex items-center gap-3 mb-4">
<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">{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>
</div>
<p className="text-xs text-gray-400 mb-3">
Found this spot useful? Send a zap to thank the mapper!
</p>
<ZapButton
target={spot.event}
className="w-full justify-center bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold"
/>
</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" />
Delete Spot?
</AlertDialogTitle>
<AlertDialogDescription className="text-gray-300">
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"
>
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}
/>
</Layout>
);
}