import { dateLocaleCode } from '@/lib/dateFnsI18n'; import { labProfilePath } from '@/lib/labRoutes'; import { useMemo, useState } from 'react'; import { useSeoMeta } from '@unhead/react'; import { useParams, Link } from 'react-router-dom'; import ngeohash from 'ngeohash'; import { PageError } from '@/components/ErrorState'; import { SpotMap } from '@/components/SpotMap'; import { RouteDialog } from '@/components/RouteDialog'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; 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 { FundingGoalCard } from '@/components/FundingGoalCard'; 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 { ControllerBadge } from '@/components/ControllerBadge'; import { useToast } from '@/hooks/useToast'; import { useTranslation } from '@/contexts/language'; import { EVENT_TYPE_KEYS } from '@/lib/labelKeys'; function eventTypeLabel(t: (key: string) => string, type: string): string { return EVENT_TYPE_KEYS[type] ? t(EVENT_TYPE_KEYS[type]) : (EVENT_TYPE_LABELS[type] || type); } function formatTime(timestamp: number): string { const date = new Date(timestamp * 1000); return date.toLocaleTimeString(dateLocaleCode(), { hour: 'numeric', minute: '2-digit', }); } function EventDetailSkeleton() { return ( <>
); } export default function EventDetailPage() { const { t } = useTranslation(); const timeZone = typeof Intl !== 'undefined' ? Intl.DateTimeFormat().resolvedOptions().timeZone : ''; const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>(); const { data: event, isLoading, error, refetch } = 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(dateLocaleCode(), { 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); // Optional crowdfunding goal (sats) set by the host as a `goal` tag. const goalSats = Number(event?.event.tags.find(([t]) => t === 'goal')?.[1] ?? 0) || 0; // 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 ? t('placesUi.eventDetail.shareText', { title: event.title }) : t('placesUi.eventDetail.shareTextFallback'); 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: t('placesUi.eventDetail.linkCopied'), description: t('placesUi.eventDetail.linkCopiedDesc'), }); } catch { toast({ title: t('placesUi.eventDetail.copyFailed'), 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) { return refetch()} />; } if (!event) { return ; } return ( <>
{/* Back link + owner/admin controls */}
{t('placesUi.eventDetail.backToEvents')}
{/* Cancelled / rescheduled banner โ€” loud, above everything */} {isCancelled && (

{t('placesUi.eventDetail.cancelled')}

{latestCancellation?.message && (

{latestCancellation.message}

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

{latestReschedule.updateKind === 'relocation' ? t('placesUi.eventDetail.newLocation') : t('placesUi.eventDetail.timeChanged')} {t('placesUi.eventDetail.checkUpdate')}

{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) => ( {eventTypeLabel(t, type)} ))}
)}

{event.title}

{user?.pubkey === event.pubkey && } {crewHost && ( {crewHost.name || t('placesUi.eventDetail.aCrew')} )} {musicAct && ( {t('placesUi.eventDetail.live')} {musicAct.name || t('placesUi.eventDetail.liveMusic')} )}
{/* Meta bar: date ยท time ยท location */}
{new Date(event.start * 1000).toLocaleDateString(dateLocaleCode(), { month: 'short' })} {new Date(event.start * 1000).getDate()}

{t('placesUi.eventDetail.date')}

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

{t('placesUi.eventDetail.time')}

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

{timeZone &&

{timeZone}

}

{t('placesUi.eventDetail.where')}

{event.location || t('placesUi.eventDetail.seeMap')}

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

{event.description}

)} {/* Host Updates */} {t('placesUi.eventDetail.updatesFromHost')} {/* Comments Section */} {event.event && }
{/* Sidebar */}
{/* Date Card */}
{new Date(event.start * 1000).toLocaleDateString(dateLocaleCode(), { month: 'long' })}
{new Date(event.start * 1000).getDate()}
{new Date(event.start * 1000).toLocaleDateString(dateLocaleCode(), { weekday: 'long' })}
{formatTime(event.start)}
{isUpcoming && ( )}
{/* Crowdfunding goal (only when the host set one) */} {goalSats > 0 && ( )} {/* RSVP / Attendance */} {isPast ? t('placesUi.eventDetail.whoCame') : t('placesUi.eventDetail.whosGoing')} {isCancelled ? (

{t('placesUi.eventDetail.rsvpsClosed')}

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

{t('placesUi.eventDetail.noRsvps')}

) )}
{/* Map (if has location) */} {coordinates && ( {t('placesUi.eventDetail.location')}
)} {/* Organizer Card */} {t('placesUi.eventDetail.organizer')}
{displayName.slice(0, 2).toUpperCase()}

{displayName}

{t('placesUi.eventDetail.eventOrganizer')}

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