SkateSpots/src/pages/EventDetailPage.tsx
SkateSpots e357833557 i18n(es): batch 3 part 3 — shared label maps + leftover routing/Sasquatch strings
- 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).
2026-10-02 15:31:27 +07:00

644 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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')}
/>
</>
);
}