Event detail hero redesign + live countdown + sponsors

- Immersive EventDetailPage hero: full-bleed cover with legibility gradient,
  bold uppercase title, type pills, host/music-act chips, and a 3-up meta bar
  (date block · time · where). Past events render dimmed.
- EventCountdown: live status pill — "IN 3 DAYS" / "IN 2H 14M" /
  "HAPPENING NOW" (pulsing) / "ENDED", refreshing every 30s.
- Actions (edit/calendar/share) moved into a clean glass overlay on the cover.
- Event sponsors: EventSponsor type + parse/serialize (mirrors TourSponsor),
  EventSponsors "Presented by" strip (title sponsor hero row + logo grid),
  sponsor editor on CreateEventPage, and sponsor preservation on edit.
- Document event sponsor tag in NIP.md.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-03 12:04:07 +07:00
parent 78f4a6d09a
commit 87c81a63c9
7 changed files with 497 additions and 85 deletions

12
NIP.md
View file

@ -342,6 +342,18 @@ Skateboarding and music culture are deeply connected. These event types support
Events can have multiple type tags. For example, a video premiere with a live band would have both `premiere` and `concert` tags.
### Event Sponsors
Events may carry sponsor logos (a "Presented by" strip), mirroring the tour sponsor format:
```json
["sponsor", "<tier>", "<name>", "<logo-url>", "<website-url>"]
```
- `<tier>` is one of `title`, `gold`, `silver`, or `partner` (controls ordering + emphasis; `title` gets a hero row).
- `<name>` is required; `<logo-url>` and `<website-url>` are optional.
- Multiple `sponsor` tags are allowed.
### Event RSVPs (Attendance)
Attendance uses the standard **NIP-52 RSVP** (kind `31925`). A skater responds to a calendar event with `accepted` (going), `tentative` (interested), or `declined` (can't make it). RSVPs are addressable, so publishing a new one with the same `d` replaces the prior status — letting a skater freely switch.

View file

@ -0,0 +1,96 @@
import { useEffect, useMemo, useState } from 'react';
import { cn } from '@/lib/utils';
export type EventStatus = 'soon' | 'upcoming' | 'live' | 'ended';
interface CountdownInfo {
status: EventStatus;
label: string;
}
function computeStatus(start: number, end?: number): CountdownInfo {
const now = Date.now() / 1000;
const effectiveEnd = end ?? start + 3 * 3600; // assume ~3h if no end
if (now >= start && now < effectiveEnd) {
return { status: 'live', label: 'Happening now' };
}
if (now >= effectiveEnd) {
return { status: 'ended', label: 'Ended' };
}
const diff = start - now;
const days = Math.floor(diff / 86400);
const hours = Math.floor((diff % 86400) / 3600);
const mins = Math.floor((diff % 3600) / 60);
if (days >= 1) {
return { status: days <= 3 ? 'soon' : 'upcoming', label: days === 1 ? 'Tomorrow' : `In ${days} days` };
}
if (hours >= 1) {
return { status: 'soon', label: `In ${hours}h ${mins}m` };
}
if (mins >= 1) {
return { status: 'soon', label: `In ${mins} min` };
}
return { status: 'soon', label: 'Starting now' };
}
/**
* Live status pill for an event — the single most valuable at-a-glance signal:
* "IN 3 DAYS" / "IN 2H 14M" / "🔴 HAPPENING NOW" / "ENDED". Ticks every 30s.
*/
export function EventCountdown({
start,
end,
className,
}: {
start: number;
end?: number;
className?: string;
}) {
const [, forceTick] = useState(0);
// Re-render periodically so the countdown stays fresh without a heavy timer.
useEffect(() => {
const id = setInterval(() => forceTick((n) => n + 1), 30_000);
return () => clearInterval(id);
}, []);
const info = useMemo(() => computeStatus(start, end), [start, end]);
const styles: Record<EventStatus, string> = {
live: 'bg-[#ff0040] text-white border-[#ff0040] animate-pulse',
soon: 'bg-[#ccff00] text-black border-[#ccff00]',
upcoming: 'bg-white/10 text-white border-white/20',
ended: 'bg-white/5 text-gray-400 border-white/10',
};
return (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-black uppercase tracking-wide backdrop-blur',
styles[info.status],
className,
)}
>
{info.status === 'live' && (
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-white opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-white" />
</span>
)}
{info.label}
</span>
);
}
/** Standalone helper to get just the status (for conditional styling). */
export function useEventStatus(start: number, end?: number): EventStatus {
const [, forceTick] = useState(0);
useEffect(() => {
const id = setInterval(() => forceTick((n) => n + 1), 30_000);
return () => clearInterval(id);
}, []);
return useMemo(() => computeStatus(start, end).status, [start, end]);
}

View file

@ -0,0 +1,98 @@
import { ExternalLink, Sparkles } from 'lucide-react';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
import { EVENT_SPONSOR_TIERS, type EventSponsor } from '@/lib/types';
function SponsorLogo({ sponsor, large }: { sponsor: EventSponsor; large?: boolean }) {
const safeUrl = sponsor.url ? sanitizeUrl(sponsor.url) : null;
const safeLogo = sponsor.logo ? sanitizeUrl(sponsor.logo) : null;
const inner = (
<div
className={cn(
'group flex flex-col items-center justify-center gap-2 rounded-xl border border-white/10 bg-white/[0.03] p-3 transition-all hover:border-white/25 hover:bg-white/[0.06]',
large ? 'min-h-[96px]' : 'min-h-[72px]',
)}
>
{safeLogo ? (
<img
src={safeLogo}
alt={sponsor.name}
className={cn('object-contain', large ? 'max-h-14' : 'max-h-9')}
loading="lazy"
/>
) : (
<span className={cn('font-black uppercase tracking-wide text-white', large ? 'text-lg' : 'text-sm')}>
{sponsor.name}
</span>
)}
{safeLogo && (
<span className="max-w-full truncate text-[10px] font-bold uppercase tracking-wide text-gray-400">
{sponsor.name}
{safeUrl && (
<ExternalLink className="ml-1 inline h-2.5 w-2.5 opacity-0 transition-opacity group-hover:opacity-100" />
)}
</span>
)}
</div>
);
if (safeUrl) {
return (
<a href={safeUrl} target="_blank" rel="noopener noreferrer nofollow" className="block">
{inner}
</a>
);
}
return inner;
}
/**
* Sponsor strip for an event. Title sponsor gets a hero row; the rest render in
* a responsive grid. Sponsors give events a "this is a real production" feel and
* open a monetization surface for hosts + brands.
*/
export function EventSponsors({
sponsors,
className,
}: {
sponsors: EventSponsor[];
className?: string;
}) {
if (!sponsors || sponsors.length === 0) return null;
const title = sponsors.filter((s) => s.tier === 'title');
const rest = sponsors.filter((s) => s.tier !== 'title');
return (
<div className={cn('space-y-4', className)}>
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-[#ccff00]" />
<h3 className="text-xs font-black uppercase tracking-widest text-gray-400">
Presented by
</h3>
</div>
{title.length > 0 && (
<div className="grid grid-cols-1 gap-3">
{title.map((s, i) => (
<div key={`${s.name}-${i}`} className="space-y-1.5">
<span className="text-[10px] font-black uppercase tracking-widest text-[#ccff00]">
{EVENT_SPONSOR_TIERS.title.label}
</span>
<SponsorLogo sponsor={s} large />
</div>
))}
</div>
)}
{rest.length > 0 && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{rest.map((s, i) => (
<SponsorLogo key={`${s.name}-${i}`} sponsor={s} />
))}
</div>
)}
</div>
);
}

View file

@ -587,10 +587,30 @@ export interface SkateEvent {
image?: string;
types: string[];
featuredUntil?: number; // Featured event listing expiry (unix seconds), if paid
sponsors?: EventSponsor[];
createdAt: number;
event: NostrEvent;
}
/** Event sponsor (mirrors TourSponsor). Rendered as a logo strip on the event. */
export interface EventSponsor {
name: string;
logo?: string;
url?: string;
tier?: 'title' | 'gold' | 'silver' | 'partner';
}
/** Sponsor tier display metadata (ordering + label). */
export const EVENT_SPONSOR_TIERS: Record<
NonNullable<EventSponsor['tier']>,
{ label: string; order: number }
> = {
title: { label: 'Title Sponsor', order: 0 },
gold: { label: 'Gold', order: 1 },
silver: { label: 'Silver', order: 2 },
partner: { label: 'Partner', order: 3 },
};
export interface FoodSpot {
id: string;
name: string;
@ -840,11 +860,55 @@ export function parseSkateEvent(event: NostrEvent): SkateEvent | null {
image,
types,
featuredUntil: parseFeaturedUntil(event),
sponsors: parseEventSponsors(event),
createdAt: event.created_at,
event,
};
}
/**
* Parse event sponsors from tags. Format (mirrors tours):
* ["sponsor", "<tier>", "<name>", "<logo-url>", "<website-url>"]
*/
export function parseEventSponsors(event: NostrEvent): EventSponsor[] | undefined {
const tags = event.tags.filter(([name]) => name === 'sponsor');
if (tags.length === 0) return undefined;
const validTiers = new Set(['title', 'gold', 'silver', 'partner']);
const sponsors: EventSponsor[] = [];
for (const tag of tags) {
const name = clampStr(tag[2], MAX_TITLE_LEN)?.trim();
if (!name) continue;
const tier = validTiers.has(tag[1]) ? (tag[1] as EventSponsor['tier']) : 'partner';
sponsors.push({
tier,
name,
logo: tag[3] || undefined,
url: tag[4] || undefined,
});
}
if (sponsors.length === 0) return undefined;
// Sort by tier importance.
return sponsors.sort(
(a, b) =>
EVENT_SPONSOR_TIERS[a.tier ?? 'partner'].order -
EVENT_SPONSOR_TIERS[b.tier ?? 'partner'].order,
);
}
/** Build sponsor tags for publishing an event. */
export function eventSponsorTags(sponsors: EventSponsor[]): string[][] {
return sponsors
.filter((s) => s.name.trim())
.map((s) => [
'sponsor',
s.tier ?? 'partner',
s.name.trim(),
s.logo?.trim() ?? '',
s.url?.trim() ?? '',
]);
}
// ─── NIP-52 Calendar Event RSVP (kind 31925) ────────────────────────────────
export type RsvpStatus = 'accepted' | 'tentative' | 'declined';

View file

@ -19,7 +19,7 @@ import { useSkateSpots } from '@/hooks/useSkateSpots';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/LanguageContext';
import { LoginArea } from '@/components/auth/LoginArea';
import { CALENDAR_EVENT_KIND, EVENT_TYPE_LABELS, TOUR_CATEGORIES, SKATE_TOUR_KIND, SKATE_SPOT_KIND, SKATE_CREW_KIND, SKATE_MUSICIAN_KIND, SPOT_CATEGORIES, type SkateTour, type SkateSpot } from '@/lib/types';
import { CALENDAR_EVENT_KIND, EVENT_TYPE_LABELS, TOUR_CATEGORIES, SKATE_TOUR_KIND, SKATE_SPOT_KIND, SKATE_CREW_KIND, SKATE_MUSICIAN_KIND, SPOT_CATEGORIES, EVENT_SPONSOR_TIERS, eventSponsorTags, type SkateTour, type SkateSpot, type EventSponsor } from '@/lib/types';
import { useCrew } from '@/hooks/useCrews';
import { useLabProfile } from '@/hooks/useLabProfiles';
import { Users } from 'lucide-react';
@ -39,6 +39,8 @@ import {
Map,
Zap,
Music,
Sparkles,
Plus,
} from 'lucide-react';
import { FeaturedEventUpgrade } from '@/components/FeaturedEventUpgrade';
import { usePlatformFee } from '@/hooks/usePlatformFee';
@ -110,6 +112,15 @@ export default function CreateEventPage() {
// Paid event / entry fee
const [entryFee, setEntryFee] = useState('');
const isPaidEvent = entryFee.trim().length > 0 && entryFee.trim() !== '0';
// Optional sponsors (logo strip on the event page).
const [sponsors, setSponsors] = useState<EventSponsor[]>([]);
const addSponsor = () =>
setSponsors((prev) => [...prev, { tier: 'partner', name: '', logo: '', url: '' }]);
const updateSponsor = (i: number, patch: Partial<EventSponsor>) =>
setSponsors((prev) => prev.map((s, idx) => (idx === i ? { ...s, ...patch } : s)));
const removeSponsor = (i: number) =>
setSponsors((prev) => prev.filter((_, idx) => idx !== i));
// Optional "Feature this event" upgrade — every event posts for free.
const { isActive: isFeatured, featuredUntilSeconds } = usePlatformFee('featuredEvent');
const [goFeatured, setGoFeatured] = useState(false);
@ -367,6 +378,12 @@ export default function CreateEventPage() {
tags.push(['music-act', musicAct.title]);
}
// Sponsors — a logo strip on the event page.
const namedSponsors = sponsors.filter((s) => s.name.trim());
if (namedSponsors.length > 0) {
tags.push(...eventSponsorTags(namedSponsors));
}
await createEvent({
kind: CALENDAR_EVENT_KIND,
content: description.trim(),
@ -978,6 +995,82 @@ export default function CreateEventPage() {
/>
</div>
{/* Sponsors (optional) */}
<div className="space-y-3 rounded-xl border border-white/10 bg-white/[0.02] p-4">
<div className="flex items-center justify-between gap-2">
<Label className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-[#ccff00]" />
Sponsors
<span className="text-xs font-normal text-gray-500">(optional)</span>
</Label>
<Button
type="button"
size="sm"
variant="outline"
onClick={addSponsor}
className="h-8 gap-1 border-[#ccff00]/30 text-[#ccff00] hover:bg-[#ccff00]/10"
>
<Plus className="h-3.5 w-3.5" />
Add
</Button>
</div>
{sponsors.length === 0 ? (
<p className="text-xs text-gray-500">
Got a shop or brand backing this? Add their logo — it shows as a "Presented by" strip on the event.
</p>
) : (
<div className="space-y-3">
{sponsors.map((s, i) => (
<div key={i} className="space-y-2 rounded-lg border border-white/10 bg-black/30 p-3">
<div className="flex items-center gap-2">
<select
value={s.tier}
onChange={(e) => updateSponsor(i, { tier: e.target.value as EventSponsor['tier'] })}
className="h-9 rounded-md border border-white/20 bg-black/50 px-2 text-sm text-white"
>
{Object.entries(EVENT_SPONSOR_TIERS).map(([key, { label }]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
<Input
placeholder="Sponsor name"
value={s.name}
onChange={(e) => updateSponsor(i, { name: e.target.value })}
className="flex-1 bg-black/50 border-white/20"
/>
<Button
type="button"
size="icon"
variant="ghost"
onClick={() => removeSponsor(i)}
className="h-9 w-9 flex-shrink-0 text-gray-500 hover:text-red-400"
aria-label="Remove sponsor"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<Input
placeholder="Logo URL (optional)"
value={s.logo ?? ''}
onChange={(e) => updateSponsor(i, { logo: e.target.value })}
className="bg-black/50 border-white/20 text-sm"
/>
<Input
placeholder="Website URL (optional)"
value={s.url ?? ''}
onChange={(e) => updateSponsor(i, { url: e.target.value })}
className="bg-black/50 border-white/20 text-sm"
/>
</div>
</div>
))}
</div>
)}
</div>
{/* Optional featured upgrade — every event posts for free */}
<FeaturedEventUpgrade enabled={goFeatured} onEnabledChange={setGoFeatured} />

View file

@ -289,6 +289,12 @@ export default function EditEventPage() {
tags.push(['image', image]);
}
// Preserve sponsors from the original event (the edit form doesn't manage
// them yet, so carry them through untouched instead of dropping them).
for (const sponsorTag of event.event.tags.filter(([name]) => name === 'sponsor')) {
tags.push(sponsorTag);
}
// Detect the changes attendees care about (time / location) BEFORE we
// overwrite the event, so we can auto-notify RSVPers.
const newLocation = location.trim();

View file

@ -14,12 +14,15 @@ 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 { 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';
@ -37,16 +40,6 @@ import {
} from 'lucide-react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
function formatDate(timestamp: number): string {
const date = new Date(timestamp * 1000);
return date.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
function formatTime(timestamp: number): string {
const date = new Date(timestamp * 1000);
return date.toLocaleTimeString('en-US', {
@ -224,48 +217,94 @@ export default function EventDetailPage() {
<div className="grid lg:grid-cols-3 gap-8">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
{/* Cover Image */}
{event.image ? (
<div className="aspect-video rounded-xl overflow-hidden bg-muted">
<img
src={event.image}
alt={event.title}
className="w-full h-full object-cover"
/>
</div>
) : (
<div className="aspect-video rounded-xl bg-gradient-to-br from-primary/20 to-accent/20 flex items-center justify-center">
<Calendar className="h-16 w-16 text-primary/40" />
</div>
)}
{/* Event Info */}
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2 mb-2">
{isUpcoming && (
<Badge className="bg-green-500">Upcoming</Badge>
)}
{isPast && (
<Badge variant="secondary">Past Event</Badge>
{/* ── 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 && '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>
<h1 className="text-3xl font-bold">{event.title}</h1>
)}
{/* 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-500/40 bg-teal-500/10 px-3 py-1 text-sm font-bold text-teal-300 transition-colors hover:bg-teal-500/20"
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" />
Hosted by {crewHost.name || 'a crew'}
{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-500/40 bg-fuchsia-500/10 px-3 py-1 text-sm font-bold text-fuchsia-300 transition-colors hover:bg-fuchsia-500/20"
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'}
@ -273,57 +312,61 @@ export default function EventDetailPage() {
)}
</div>
</div>
<div className="flex items-center gap-2">
{isOwner && (
<Link to={`/edit-event/${event.pubkey}/${event.identifier}`}>
<Button variant="outline" size="icon">
<Pencil className="h-4 w-4" />
</Button>
</Link>
)}
<Button variant="outline" size="icon" onClick={handleShare}>
<Share2 className="h-4 w-4" />
</Button>
{isUpcoming && (
<Button variant="outline" size="icon" onClick={handleAddToCalendar}>
<CalendarPlus className="h-4 w-4" />
</Button>
)}
</div>
</div>
{/* Date & Time */}
<div className="flex flex-wrap gap-4 text-muted-foreground">
<div className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
<span>{formatDate(event.start)}</span>
{/* 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-2">
<Clock className="h-5 w-5" />
<span>
{formatTime(event.start)}
{event.end && ` - ${formatTime(event.end)}`}
</span>
<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>
{/* Location */}
{event.location && (
<p className="text-muted-foreground flex items-center gap-2">
<MapPin className="h-5 w-5" />
{event.location}
</p>
)}
{/* Types */}
{event.types.length > 0 && (
<div className="flex flex-wrap gap-2">
{event.types.map((type) => (
<Badge key={type} variant="secondary">
{EVENT_TYPE_LABELS[type] || type}
</Badge>
))}
</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 */}