From 87c81a63c9750bd97a1a93ccde306f2a5101ef37 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Thu, 3 Sep 2026 12:04:07 +0700 Subject: [PATCH] Event detail hero redesign + live countdown + sponsors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- NIP.md | 12 ++ src/components/EventCountdown.tsx | 96 ++++++++++++++ src/components/EventSponsors.tsx | 98 ++++++++++++++ src/lib/types.ts | 64 +++++++++ src/pages/CreateEventPage.tsx | 95 +++++++++++++- src/pages/EditEventPage.tsx | 6 + src/pages/EventDetailPage.tsx | 211 ++++++++++++++++++------------ 7 files changed, 497 insertions(+), 85 deletions(-) create mode 100644 src/components/EventCountdown.tsx create mode 100644 src/components/EventSponsors.tsx diff --git a/NIP.md b/NIP.md index 42ab3f1..9c396dc 100644 --- a/NIP.md +++ b/NIP.md @@ -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", "", "", "", ""] +``` + +- `` is one of `title`, `gold`, `silver`, or `partner` (controls ordering + emphasis; `title` gets a hero row). +- `` is required; `` and `` 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. diff --git a/src/components/EventCountdown.tsx b/src/components/EventCountdown.tsx new file mode 100644 index 0000000..cee1255 --- /dev/null +++ b/src/components/EventCountdown.tsx @@ -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 = { + 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 ( + + {info.status === 'live' && ( + + + + + )} + {info.label} + + ); +} + +/** 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]); +} diff --git a/src/components/EventSponsors.tsx b/src/components/EventSponsors.tsx new file mode 100644 index 0000000..6def824 --- /dev/null +++ b/src/components/EventSponsors.tsx @@ -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 = ( +
+ {safeLogo ? ( + {sponsor.name} + ) : ( + + {sponsor.name} + + )} + {safeLogo && ( + + {sponsor.name} + {safeUrl && ( + + )} + + )} +
+ ); + + if (safeUrl) { + return ( + + {inner} + + ); + } + 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 ( +
+
+ +

+ Presented by +

+
+ + {title.length > 0 && ( +
+ {title.map((s, i) => ( +
+ + {EVENT_SPONSOR_TIERS.title.label} + + +
+ ))} +
+ )} + + {rest.length > 0 && ( +
+ {rest.map((s, i) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 54d06d5..ddd97d4 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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, + { 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", "", "", "", ""] + */ +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'; diff --git a/src/pages/CreateEventPage.tsx b/src/pages/CreateEventPage.tsx index a107b62..2bd27bd 100644 --- a/src/pages/CreateEventPage.tsx +++ b/src/pages/CreateEventPage.tsx @@ -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([]); + const addSponsor = () => + setSponsors((prev) => [...prev, { tier: 'partner', name: '', logo: '', url: '' }]); + const updateSponsor = (i: number, patch: Partial) => + 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() { /> + {/* Sponsors (optional) */} +
+
+ + +
+ {sponsors.length === 0 ? ( +

+ Got a shop or brand backing this? Add their logo — it shows as a "Presented by" strip on the event. +

+ ) : ( +
+ {sponsors.map((s, i) => ( +
+
+ + updateSponsor(i, { name: e.target.value })} + className="flex-1 bg-black/50 border-white/20" + /> + +
+
+ updateSponsor(i, { logo: e.target.value })} + className="bg-black/50 border-white/20 text-sm" + /> + updateSponsor(i, { url: e.target.value })} + className="bg-black/50 border-white/20 text-sm" + /> +
+
+ ))} +
+ )} +
+ {/* Optional featured upgrade — every event posts for free */} diff --git a/src/pages/EditEventPage.tsx b/src/pages/EditEventPage.tsx index bc94aa8..bddaa55 100644 --- a/src/pages/EditEventPage.tsx +++ b/src/pages/EditEventPage.tsx @@ -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(); diff --git a/src/pages/EventDetailPage.tsx b/src/pages/EventDetailPage.tsx index 18838ae..3342c72 100644 --- a/src/pages/EventDetailPage.tsx +++ b/src/pages/EventDetailPage.tsx @@ -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() {
{/* Main Content */}
- {/* Cover Image */} - {event.image ? ( -
- {event.title} -
- ) : ( -
- -
- )} - - {/* Event Info */} -
-
-
-
- {isUpcoming && ( - Upcoming - )} - {isPast && ( - Past Event + {/* ── Immersive hero ── */} +
+ {/* Cover */} +
+ {event.image ? ( + {event.title} + ) : ( +
+
-

{event.title}

+ )} + {/* Legibility gradient */} +
+ + {/* Top row: status pill + actions */} +
+ +
+ {isOwner && ( + + + + )} + {isUpcoming && ( + + )} + +
+
+ + {/* Bottom: title block */} +
+ {event.types.length > 0 && ( +
+ {event.types.slice(0, 3).map((type) => ( + + {EVENT_TYPE_LABELS[type] || type} + + ))} +
+ )} +

+ {event.title} +

{crewHost && ( - Hosted by {crewHost.name || 'a crew'} + {crewHost.name || 'a crew'} )} {musicAct && ( Live: {musicAct.name || 'live music'} @@ -273,57 +312,61 @@ export default function EventDetailPage() { )}
-
- {isOwner && ( - - - - )} - - {isUpcoming && ( - - )} -
- {/* Date & Time */} -
-
- - {formatDate(event.start)} + {/* Meta bar: date · time · location */} +
+
+
+ + {new Date(event.start * 1000).toLocaleDateString(undefined, { month: 'short' })} + + + {new Date(event.start * 1000).getDate()} + +
+
+

Date

+

+ {new Date(event.start * 1000).toLocaleDateString(undefined, { weekday: 'long' })} +

+
-
- - - {formatTime(event.start)} - {event.end && ` - ${formatTime(event.end)}`} - +
+
+ +
+
+

Time

+

+ {formatTime(event.start)} + {event.end && ` – ${formatTime(event.end)}`} +

+
+
+
+
+ +
+
+

Where

+

+ {event.location || 'See map'} +

+
+
- {/* Location */} - {event.location && ( -

- - {event.location} -

- )} - - {/* Types */} - {event.types.length > 0 && ( -
- {event.types.map((type) => ( - - {EVENT_TYPE_LABELS[type] || type} - - ))} -
+ {/* Event body */} +
+ {/* Sponsors */} + {event.sponsors && event.sponsors.length > 0 && ( + + + + + )} {/* Description */}