import { useMemo, useState } from 'react'; import { useSeoMeta } from '@unhead/react'; import { useParams, Link } from 'react-router-dom'; import ngeohash from 'ngeohash'; import { Layout } from '@/components/Layout'; import { SpotMap } from '@/components/SpotMap'; import { RouteDialog } from '@/components/RouteDialog'; 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 { RsvpButtons } from '@/components/RsvpButtons'; import { AttendeeList } from '@/components/AttendeeList'; import { EventUpdates } from '@/components/EventUpdates'; import { EventCountdown } from '@/components/EventCountdown'; import { EventSponsors } from '@/components/EventSponsors'; import { CommentsSection } from '@/components/comments/CommentsSection'; import { DeleteEntityDialog } from '@/components/DeleteEntityDialog'; import { useSkateEvent } from '@/hooks/useSkateEvents'; import { useEventRsvps, eventCoord } from '@/hooks/useEventRsvps'; import { useEventUpdates } from '@/hooks/useEventUpdates'; import { useAuthor } from '@/hooks/useAuthor'; import { genUserName } from '@/lib/genUserName'; import { cn } from '@/lib/utils'; import { EVENT_TYPE_LABELS, SKATE_CREW_KIND, SKATE_MUSICIAN_KIND } from '@/lib/types'; import { Users } from 'lucide-react'; import NotFound from './NotFound'; import { Calendar, Clock, MapPin, ArrowLeft, Share2, Navigation, CalendarPlus, Pencil, Music, Megaphone, XCircle, CalendarClock, } from 'lucide-react'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useToast } from '@/hooks/useToast'; function formatTime(timestamp: number): string { const date = new Date(timestamp * 1000); return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', }); } function EventDetailSkeleton() { return (
); } export default function EventDetailPage() { const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>(); const { data: event, isLoading, error } = useSkateEvent(identifier || '', pubkey); const author = useAuthor(event?.pubkey); const { user } = useCurrentUser(); const { toast } = useToast(); const isOwner = user && event && user.pubkey === event.pubkey; // Canonical, shareable URL for this event (HashRouter โ†’ keep the /#/ prefix). const shareUrl = event ? `https://skate-spots.org/#/event/${event.pubkey}/${encodeURIComponent(event.identifier)}` : 'https://skate-spots.org'; // og:image must be an absolute URL. Fall back to the site social card when the // event has no cover so shared links never render blank. const ogImage = event?.image || 'https://skate-spots.org/images/icon-1024.png'; // Compose a punchy share description: date + location, then the blurb. const shareDescription = (() => { if (!event) return 'View this skateboarding event on SkateSpots.'; const bits: string[] = []; if (event.start) { bits.push( new Date(event.start * 1000).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric', }), ); } if (event.location) bits.push(`๐Ÿ“ ${event.location}`); const prefix = bits.length ? `${bits.join(' ยท ')} โ€” ` : ''; const body = event.description?.trim() || 'A skateboarding event on SkateSpots.'; return `${prefix}${body}`.slice(0, 200); })(); const shareTitle = event ? `${event.title} ยท SkateSpots` : 'Skate Event ยท SkateSpots'; useSeoMeta({ title: shareTitle, description: shareDescription, ogTitle: shareTitle, ogDescription: shareDescription, ogType: 'article', ogUrl: shareUrl, ogImage, ogSiteName: 'SkateSpots', twitterCard: 'summary_large_image', twitterTitle: shareTitle, twitterDescription: shareDescription, twitterImage: ogImage, }); const metadata = author.data?.metadata; const displayName = metadata?.name ?? genUserName(event?.pubkey || ''); const [showRouteDialog, setShowRouteDialog] = useState(false); const coordinates = useMemo(() => { if (!event?.geohash) return null; const { latitude, longitude } = ngeohash.decode(event.geohash); return { lat: latitude, lng: longitude }; }, [event?.geohash]); // NIP-52 RSVPs for this event (counts + attendee avatars). const rsvpSummary = useEventRsvps(event ? eventCoord(event) : undefined); // Host updates โ†’ detect if the host cancelled or rescheduled, so we can warn // attendees loudly instead of letting them show up to a dead jam. const { data: eventUpdates } = useEventUpdates(event ? eventCoord(event) : undefined); const latestCancellation = useMemo( () => eventUpdates?.find((u) => u.updateKind === 'cancelled') ?? null, [eventUpdates], ); const latestReschedule = useMemo(() => { if (latestCancellation) return null; // cancellation wins return eventUpdates?.find((u) => u.updateKind === 'reschedule' || u.updateKind === 'relocation') ?? null; }, [eventUpdates, latestCancellation]); const isCancelled = !!latestCancellation; const isUpcoming = event ? event.start > Date.now() / 1000 : false; const isPast = event ? event.start < Date.now() / 1000 : false; // Crew host โ€” an `a` tag pointing at a crew coordinate (35006:pubkey:d). const crewHost = useMemo(() => { if (!event) return null; const ref = event.event.tags .filter(([t]) => t === 'a') .map(([, v]) => v) .find((v) => v?.startsWith(`${SKATE_CREW_KIND}:`)); if (!ref) return null; const [, hPubkey, hId] = ref.split(':'); const name = event.event.tags.find(([t]) => t === 'crew-name')?.[1]; return hPubkey && hId ? { pubkey: hPubkey, identifier: hId, name } : null; }, [event]); // Featured musician โ€” an `a` tag pointing at a musician coordinate (35011:pubkey:d). const musicAct = useMemo(() => { if (!event) return null; const ref = event.event.tags .filter(([t]) => t === 'a') .map(([, v]) => v) .find((v) => v?.startsWith(`${SKATE_MUSICIAN_KIND}:`)); if (!ref) return null; const [, mPubkey, mId] = ref.split(':'); const name = event.event.tags.find(([t]) => t === 'music-act')?.[1]; return mPubkey && mId ? { pubkey: mPubkey, identifier: mId, name } : null; }, [event]); const handleShare = async () => { const shareText = event ? `${event.title} โ€” a skate event on SkateSpots` : 'Check out this skate event on SkateSpots'; if (navigator.share) { try { await navigator.share({ title: event?.title, text: shareText, url: shareUrl, }); return; } catch { // User cancelled or share failed โ€” fall through to clipboard. } } try { await navigator.clipboard.writeText(shareUrl); toast({ title: 'Link copied', description: 'Event link copied to your clipboard โ€” paste it anywhere.', }); } catch { toast({ title: 'Copy failed', description: shareUrl, variant: 'destructive', }); } }; const handleDirections = () => { if (coordinates) { // In-app routing โ€” nothing leaves to Google/Apple Maps. setShowRouteDialog(true); } }; const handleAddToCalendar = () => { if (!event) return; const startDate = new Date(event.start * 1000); const endDate = event.end ? new Date(event.end * 1000) : new Date(event.start * 1000 + 3600000); // Default 1 hour const formatICSDate = (date: Date) => { return date.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; }; const icsContent = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'BEGIN:VEVENT', `DTSTART:${formatICSDate(startDate)}`, `DTEND:${formatICSDate(endDate)}`, `SUMMARY:${event.title}`, `DESCRIPTION:${event.description || ''}`, `LOCATION:${event.location || ''}`, 'END:VEVENT', 'END:VCALENDAR', ].join('\n'); const blob = new Blob([icsContent], { type: 'text/calendar' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `${event.title.replace(/[^a-z0-9]/gi, '-')}.ics`; link.click(); URL.revokeObjectURL(url); }; if (isLoading) { return ; } if (error || !event) { return ; } return (
{/* Back link + owner/admin controls */}
Back to Events
{/* Cancelled / rescheduled banner โ€” loud, above everything */} {isCancelled && (

This event was cancelled

{latestCancellation?.message && (

{latestCancellation.message}

)}
)} {!isCancelled && latestReschedule && (

{latestReschedule.updateKind === 'relocation' ? 'New location' : 'Time changed'} โ€” check the update

{latestReschedule.message && (

{latestReschedule.message}

)}
)}
{/* Main Content */}
{/* โ”€โ”€ Immersive hero โ”€โ”€ */}
{/* Cover */}
{event.image ? ( {event.title} ) : (
)} {/* Legibility gradient */}
{/* Top row: status pill + actions */}
{isOwner && ( )} {isUpcoming && ( )}
{/* Bottom: title block */}
{event.types.length > 0 && (
{event.types.slice(0, 3).map((type) => ( {EVENT_TYPE_LABELS[type] || type} ))}
)}

{event.title}

{crewHost && ( {crewHost.name || 'a crew'} )} {musicAct && ( Live: {musicAct.name || 'live music'} )}
{/* Meta bar: date ยท time ยท location */}
{new Date(event.start * 1000).toLocaleDateString(undefined, { month: 'short' })} {new Date(event.start * 1000).getDate()}

Date

{new Date(event.start * 1000).toLocaleDateString(undefined, { weekday: 'long' })}

Time

{formatTime(event.start)} {event.end && ` โ€“ ${formatTime(event.end)}`}

Where

{event.location || 'See map'}

{/* Event body */}
{/* Sponsors */} {event.sponsors && event.sponsors.length > 0 && ( )} {/* Description */} {event.description && (

{event.description}

)} {/* Host Updates */} Updates from the host {/* Comments Section */} {event.event && }
{/* Sidebar */}
{/* Date Card */}
{new Date(event.start * 1000).toLocaleDateString('en-US', { month: 'long' })}
{new Date(event.start * 1000).getDate()}
{new Date(event.start * 1000).toLocaleDateString('en-US', { weekday: 'long' })}
{formatTime(event.start)}
{isUpcoming && ( )}
{/* RSVP / Attendance */} {isPast ? 'Who came' : 'Who\u2019s going'} {isCancelled ? (

RSVPs are closed โ€” this event was cancelled by the host.

) : ( !isPast && )} {(rsvpSummary.goingCount > 0 || rsvpSummary.tentativeCount > 0) ? ( ) : ( !rsvpSummary.isLoading && isPast && (

No RSVPs were recorded.

) )}
{/* Map (if has location) */} {coordinates && ( Location
)} {/* Organizer Card */} Organizer
{displayName.slice(0, 2).toUpperCase()}

{displayName}

Event Organizer

{/* In-app route (no external maps) */} ); }