- labelsUi.* (221+ keys): spot categories/features/types, access, shelter, event types, gear categories/conditions, price/listings, tours, crews, member-visibility, tag categories, spot status. New src/lib/labelKeys.ts holds the shared value->key maps (one source; the five duplicated EVENT_TYPE_KEYS copies now import it). spotStatus.statusTimeAgo() uses the locale date shim. - formatPrice() now takes an optional t so FREE/Trade/Make Offer translate. - Route helpers: skateTime/distanceReadout/maneuver descriptors/formatAge translated (routeUi.*), consumers updated. - Sasquatch: PRO_TIERS labels/badges + summarizeDraft titles/lines/ctas translated (sasquatchUi.*). - Lab *_LABELS/_DESCRIPTIONS maps remain English (documented out of scope).
644 lines
26 KiB
TypeScript
644 lines
26 KiB
TypeScript
import { dateLocaleCode } from '@/lib/dateFnsI18n';
|
||
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 (
|
||
<>
|
||
<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>
|
||
</>
|
||
);
|
||
}
|
||
|
||
export default function EventDetailPage() {
|
||
const { t } = useTranslation();
|
||
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 <EventDetailSkeleton />;
|
||
}
|
||
|
||
if (error) {
|
||
return <PageError onRetry={() => refetch()} />;
|
||
}
|
||
|
||
if (!event) {
|
||
return <NotFound />;
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<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" />
|
||
{t('placesUi.eventDetail.backToEvents')}
|
||
</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">
|
||
{t('placesUi.eventDetail.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' ? t('placesUi.eventDetail.newLocation') : t('placesUi.eventDetail.timeChanged')} {t('placesUi.eventDetail.checkUpdate')}
|
||
</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={t('placesUi.eventDetail.editEvent')}
|
||
>
|
||
<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={t('placesUi.eventDetail.addToCalendar')}
|
||
>
|
||
<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={t('placesUi.eventDetail.shareEvent')}
|
||
>
|
||
<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"
|
||
>
|
||
{eventTypeLabel(t, 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">
|
||
{user?.pubkey === event.pubkey && <ControllerBadge role="organiser" />}
|
||
{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 || t('placesUi.eventDetail.aCrew')}
|
||
</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" />
|
||
{t('placesUi.eventDetail.live')} {musicAct.name || t('placesUi.eventDetail.liveMusic')}
|
||
</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(dateLocaleCode(), { 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">{t('placesUi.eventDetail.date')}</p>
|
||
<p className="truncate text-sm font-bold text-white">
|
||
{new Date(event.start * 1000).toLocaleDateString(dateLocaleCode(), { 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">{t('placesUi.eventDetail.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">{t('placesUi.eventDetail.where')}</p>
|
||
<p className="truncate text-sm font-bold text-white">
|
||
{event.location || t('placesUi.eventDetail.seeMap')}
|
||
</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]" />
|
||
{t('placesUi.eventDetail.updatesFromHost')}
|
||
</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(dateLocaleCode(), { 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(dateLocaleCode(), { 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" />
|
||
{t('placesUi.addToCalendar.add')}
|
||
</Button>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Crowdfunding goal (only when the host set one) */}
|
||
{goalSats > 0 && (
|
||
<FundingGoalCard event={event.event} goalSats={goalSats} />
|
||
)}
|
||
|
||
{/* 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 ? t('placesUi.eventDetail.whoCame') : t('placesUi.eventDetail.whosGoing')}
|
||
</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">
|
||
{t('placesUi.eventDetail.rsvpsClosed')}
|
||
</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">{t('placesUi.eventDetail.noRsvps')}</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" />
|
||
{t('placesUi.eventDetail.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" />
|
||
{t('placesUi.eventDetail.getDirections')}
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Organizer Card */}
|
||
<Card>
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-lg">{t('placesUi.eventDetail.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">{t('placesUi.eventDetail.eventOrganizer')}</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 ?? t('placesUi.eventDetail.eventFallback')}
|
||
/>
|
||
</>
|
||
);
|
||
}
|