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 ? (

) : (
)}
{/* 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) */}
>
);
}