The spot page (and the other detail pages) used `grid lg:grid-cols-3` with
no explicit mobile column. Below the `lg` breakpoint the grid falls back to
a single implicit `auto` track, whose minimum is the min-content of its
items. A long mapper name ("Tip the Mapper" card) gave the sidebar a
~559px min-content, so the whole grid widened past the viewport and was
clipped by the global `html, body { overflow-x: hidden }` — content cut off
on the right with no way to scroll to it.
Fixes:
- Add `grid-cols-1` to detail-page grids so the mobile track is
`minmax(0, 1fr)` and can never exceed the container. Applied to Spot,
Shop, Event, NIP-19, Crew and Coach detail pages.
- Add `min-w-0` to their sidebar/main columns so long content can shrink
and truncate instead of forcing the track wider.
- Make the Spot Chat header wrap (`flex-wrap` + `gap-y`) so the trust
filter and channel badge no longer get clipped on narrow phones.
633 lines
25 KiB
TypeScript
633 lines
25 KiB
TypeScript
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 (
|
||
<Layout>
|
||
<div className="container px-4 py-8">
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||
<div className="lg:col-span-2 space-y-6 min-w-0">
|
||
<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 min-w-0">
|
||
<Skeleton className="h-48 w-full rounded-xl" />
|
||
<Skeleton className="h-32 w-full rounded-xl" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Layout>
|
||
);
|
||
}
|
||
|
||
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 <EventDetailSkeleton />;
|
||
}
|
||
|
||
if (error || !event) {
|
||
return <NotFound />;
|
||
}
|
||
|
||
return (
|
||
<Layout>
|
||
<div className="container px-4 py-8">
|
||
{/* Back link + owner/admin controls */}
|
||
<div className="mb-6 flex items-center justify-between gap-2">
|
||
<Link
|
||
to="/events"
|
||
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors"
|
||
>
|
||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||
Back to Events
|
||
</Link>
|
||
<DeleteEntityDialog
|
||
event={event.event}
|
||
entityLabel="event"
|
||
entityName={event.title}
|
||
redirectTo="/events"
|
||
/>
|
||
</div>
|
||
|
||
{/* Cancelled / rescheduled banner — loud, above everything */}
|
||
{isCancelled && (
|
||
<div className="mb-6 flex items-start gap-3 rounded-xl border-2 border-red-500/60 bg-red-500/10 p-4">
|
||
<XCircle className="mt-0.5 h-6 w-6 flex-shrink-0 text-red-400" />
|
||
<div className="min-w-0">
|
||
<p className="text-base font-black uppercase tracking-wide text-red-300">
|
||
This event was cancelled
|
||
</p>
|
||
{latestCancellation?.message && (
|
||
<p className="mt-1 whitespace-pre-wrap break-words text-sm text-red-100/90">
|
||
{latestCancellation.message}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{!isCancelled && latestReschedule && (
|
||
<div className="mb-6 flex items-start gap-3 rounded-xl border-2 border-amber-500/50 bg-amber-500/10 p-4">
|
||
<CalendarClock className="mt-0.5 h-6 w-6 flex-shrink-0 text-amber-400" />
|
||
<div className="min-w-0">
|
||
<p className="text-base font-black uppercase tracking-wide text-amber-300">
|
||
{latestReschedule.updateKind === 'relocation' ? 'New location' : 'Time changed'} — check the update
|
||
</p>
|
||
{latestReschedule.message && (
|
||
<p className="mt-1 whitespace-pre-wrap break-words text-sm text-amber-100/90">
|
||
{latestReschedule.message}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||
{/* Main Content */}
|
||
<div className="lg:col-span-2 space-y-6 min-w-0">
|
||
{/* ── Immersive hero ── */}
|
||
<div className="relative overflow-hidden rounded-2xl border border-white/10">
|
||
{/* Cover */}
|
||
<div className="relative aspect-[16/10] sm:aspect-[16/9] w-full bg-muted">
|
||
{event.image ? (
|
||
<img
|
||
src={event.image}
|
||
alt={event.title}
|
||
className={cn(
|
||
'h-full w-full object-cover',
|
||
(isPast || isCancelled) && 'grayscale-[35%] brightness-75',
|
||
)}
|
||
/>
|
||
) : (
|
||
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-[#ccff00]/20 via-black to-[#ff0040]/20">
|
||
<Calendar className="h-16 w-16 text-white/20" />
|
||
</div>
|
||
)}
|
||
{/* Legibility gradient */}
|
||
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-black/10" />
|
||
|
||
{/* Top row: status pill + actions */}
|
||
<div className="absolute inset-x-0 top-0 flex items-start justify-between gap-2 p-4">
|
||
<EventCountdown start={event.start} end={event.end} />
|
||
<div className="flex items-center gap-1.5">
|
||
{isOwner && (
|
||
<Link to={`/edit-event/${event.pubkey}/${event.identifier}`}>
|
||
<Button
|
||
size="icon"
|
||
className="h-9 w-9 bg-black/50 text-white backdrop-blur hover:bg-black/70"
|
||
aria-label="Edit event"
|
||
>
|
||
<Pencil className="h-4 w-4" />
|
||
</Button>
|
||
</Link>
|
||
)}
|
||
{isUpcoming && (
|
||
<Button
|
||
size="icon"
|
||
onClick={handleAddToCalendar}
|
||
className="h-9 w-9 bg-black/50 text-white backdrop-blur hover:bg-black/70"
|
||
aria-label="Add to calendar"
|
||
>
|
||
<CalendarPlus className="h-4 w-4" />
|
||
</Button>
|
||
)}
|
||
<Button
|
||
size="icon"
|
||
onClick={handleShare}
|
||
className="h-9 w-9 bg-black/50 text-white backdrop-blur hover:bg-black/70"
|
||
aria-label="Share event"
|
||
>
|
||
<Share2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Bottom: title block */}
|
||
<div className="absolute inset-x-0 bottom-0 p-4 sm:p-6">
|
||
{event.types.length > 0 && (
|
||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||
{event.types.slice(0, 3).map((type) => (
|
||
<span
|
||
key={type}
|
||
className="rounded-full bg-[#ccff00] px-2.5 py-0.5 text-[10px] font-black uppercase tracking-wide text-black"
|
||
>
|
||
{EVENT_TYPE_LABELS[type] || type}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
<h1 className="text-3xl font-black uppercase leading-none tracking-tight text-white sm:text-4xl drop-shadow-lg">
|
||
{event.title}
|
||
</h1>
|
||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||
{crewHost && (
|
||
<Link
|
||
to={`/crew/${crewHost.pubkey}/${crewHost.identifier}`}
|
||
className="inline-flex items-center gap-1.5 rounded-full border border-teal-400/50 bg-teal-500/20 px-3 py-1 text-sm font-bold text-teal-200 backdrop-blur transition-colors hover:bg-teal-500/30"
|
||
>
|
||
<Users className="h-3.5 w-3.5" />
|
||
{crewHost.name || 'a crew'}
|
||
</Link>
|
||
)}
|
||
{musicAct && (
|
||
<Link
|
||
to={`/coach/${musicAct.pubkey}/${musicAct.identifier}`}
|
||
className="inline-flex items-center gap-1.5 rounded-full border border-fuchsia-400/50 bg-fuchsia-500/20 px-3 py-1 text-sm font-bold text-fuchsia-200 backdrop-blur transition-colors hover:bg-fuchsia-500/30"
|
||
>
|
||
<Music className="h-3.5 w-3.5" />
|
||
Live: {musicAct.name || 'live music'}
|
||
</Link>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Meta bar: date · time · location */}
|
||
<div className="grid grid-cols-1 divide-y divide-white/10 bg-white/[0.03] sm:grid-cols-3 sm:divide-x sm:divide-y-0">
|
||
<div className="flex items-center gap-3 p-4">
|
||
<div className="flex h-11 w-11 flex-shrink-0 flex-col items-center justify-center rounded-lg border-2 border-[#ccff00]/40 bg-[#ccff00]/10">
|
||
<span className="text-[9px] font-black uppercase leading-none text-[#ccff00]">
|
||
{new Date(event.start * 1000).toLocaleDateString(undefined, { month: 'short' })}
|
||
</span>
|
||
<span className="text-lg font-black leading-none text-white">
|
||
{new Date(event.start * 1000).getDate()}
|
||
</span>
|
||
</div>
|
||
<div className="min-w-0">
|
||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500">Date</p>
|
||
<p className="truncate text-sm font-bold text-white">
|
||
{new Date(event.start * 1000).toLocaleDateString(undefined, { weekday: 'long' })}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-3 p-4">
|
||
<div className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-lg border border-white/10 bg-white/5">
|
||
<Clock className="h-5 w-5 text-[#ff6600]" />
|
||
</div>
|
||
<div className="min-w-0">
|
||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500">Time</p>
|
||
<p className="truncate text-sm font-bold text-white">
|
||
{formatTime(event.start)}
|
||
{event.end && ` – ${formatTime(event.end)}`}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-3 p-4">
|
||
<div className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-lg border border-white/10 bg-white/5">
|
||
<MapPin className="h-5 w-5 text-[#ff0040]" />
|
||
</div>
|
||
<div className="min-w-0">
|
||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500">Where</p>
|
||
<p className="truncate text-sm font-bold text-white">
|
||
{event.location || 'See map'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Event body */}
|
||
<div className="space-y-4">
|
||
{/* Sponsors */}
|
||
{event.sponsors && event.sponsors.length > 0 && (
|
||
<Card>
|
||
<CardContent className="pt-6">
|
||
<EventSponsors sponsors={event.sponsors} />
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Description */}
|
||
{event.description && (
|
||
<Card>
|
||
<CardContent className="pt-6">
|
||
<p className="whitespace-pre-wrap">{event.description}</p>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Host Updates */}
|
||
<Card>
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-lg flex items-center gap-2">
|
||
<Megaphone className="h-5 w-5 text-[#ccff00]" />
|
||
Updates from the host
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<EventUpdates event={event} />
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Comments Section */}
|
||
{event.event && <CommentsSection root={event.event} />}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Sidebar */}
|
||
<div className="space-y-6 min-w-0">
|
||
{/* Date Card */}
|
||
<Card className="bg-gradient-to-br from-primary/5 to-accent/5">
|
||
<CardContent className="pt-6">
|
||
<div className="text-center">
|
||
<div className="text-sm font-medium text-primary uppercase mb-1">
|
||
{new Date(event.start * 1000).toLocaleDateString('en-US', { month: 'long' })}
|
||
</div>
|
||
<div className="text-5xl font-bold mb-2">
|
||
{new Date(event.start * 1000).getDate()}
|
||
</div>
|
||
<div className="text-muted-foreground">
|
||
{new Date(event.start * 1000).toLocaleDateString('en-US', { weekday: 'long' })}
|
||
</div>
|
||
<div className="text-lg font-medium mt-2">
|
||
{formatTime(event.start)}
|
||
</div>
|
||
</div>
|
||
|
||
{isUpcoming && (
|
||
<Button className="w-full mt-4 gap-2" onClick={handleAddToCalendar}>
|
||
<CalendarPlus className="h-4 w-4" />
|
||
Add to Calendar
|
||
</Button>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* RSVP / Attendance */}
|
||
<Card>
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-lg flex items-center gap-2">
|
||
<Users className="h-5 w-5 text-primary" />
|
||
{isPast ? 'Who came' : 'Who\u2019s going'}
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{isCancelled ? (
|
||
<p className="rounded-lg border border-red-500/30 bg-red-500/5 px-3 py-2 text-sm font-medium text-red-300">
|
||
RSVPs are closed — this event was cancelled by the host.
|
||
</p>
|
||
) : (
|
||
!isPast && <RsvpButtons event={event} />
|
||
)}
|
||
|
||
{(rsvpSummary.goingCount > 0 || rsvpSummary.tentativeCount > 0) ? (
|
||
<AttendeeList going={rsvpSummary.going} tentative={rsvpSummary.tentative} />
|
||
) : (
|
||
!rsvpSummary.isLoading &&
|
||
isPast && (
|
||
<p className="text-sm text-muted-foreground">No RSVPs were recorded.</p>
|
||
)
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Map (if has location) */}
|
||
{coordinates && (
|
||
<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={[]}
|
||
center={[coordinates.lat, coordinates.lng]}
|
||
zoom={15}
|
||
className="h-full"
|
||
/>
|
||
</div>
|
||
<Button
|
||
variant="outline"
|
||
className="w-full mt-4 gap-2"
|
||
onClick={handleDirections}
|
||
>
|
||
<Navigation className="h-4 w-4" />
|
||
Get Directions
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Organizer Card */}
|
||
<Card>
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-lg">Organizer</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="flex items-center gap-3">
|
||
<Avatar className="h-12 w-12">
|
||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||
<AvatarFallback>
|
||
{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">Event Organizer</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 flex items-center gap-2">
|
||
<ZapButton
|
||
target={event.event}
|
||
className="flex-1 justify-center"
|
||
/>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* In-app route (no external maps) */}
|
||
<RouteDialog
|
||
open={showRouteDialog}
|
||
onOpenChange={setShowRouteDialog}
|
||
destination={coordinates}
|
||
destinationLabel={event?.title ?? 'Event'}
|
||
/>
|
||
</Layout>
|
||
);
|
||
}
|