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:
parent
78f4a6d09a
commit
87c81a63c9
7 changed files with 497 additions and 85 deletions
12
NIP.md
12
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.
|
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)
|
### 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.
|
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.
|
||||||
|
|
|
||||||
96
src/components/EventCountdown.tsx
Normal file
96
src/components/EventCountdown.tsx
Normal 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]);
|
||||||
|
}
|
||||||
98
src/components/EventSponsors.tsx
Normal file
98
src/components/EventSponsors.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -587,10 +587,30 @@ export interface SkateEvent {
|
||||||
image?: string;
|
image?: string;
|
||||||
types: string[];
|
types: string[];
|
||||||
featuredUntil?: number; // Featured event listing expiry (unix seconds), if paid
|
featuredUntil?: number; // Featured event listing expiry (unix seconds), if paid
|
||||||
|
sponsors?: EventSponsor[];
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
event: NostrEvent;
|
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 {
|
export interface FoodSpot {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -840,11 +860,55 @@ export function parseSkateEvent(event: NostrEvent): SkateEvent | null {
|
||||||
image,
|
image,
|
||||||
types,
|
types,
|
||||||
featuredUntil: parseFeaturedUntil(event),
|
featuredUntil: parseFeaturedUntil(event),
|
||||||
|
sponsors: parseEventSponsors(event),
|
||||||
createdAt: event.created_at,
|
createdAt: event.created_at,
|
||||||
event,
|
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) ────────────────────────────────
|
// ─── NIP-52 Calendar Event RSVP (kind 31925) ────────────────────────────────
|
||||||
|
|
||||||
export type RsvpStatus = 'accepted' | 'tentative' | 'declined';
|
export type RsvpStatus = 'accepted' | 'tentative' | 'declined';
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import { useSkateSpots } from '@/hooks/useSkateSpots';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { useTranslation } from '@/contexts/LanguageContext';
|
import { useTranslation } from '@/contexts/LanguageContext';
|
||||||
import { LoginArea } from '@/components/auth/LoginArea';
|
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 { useCrew } from '@/hooks/useCrews';
|
||||||
import { useLabProfile } from '@/hooks/useLabProfiles';
|
import { useLabProfile } from '@/hooks/useLabProfiles';
|
||||||
import { Users } from 'lucide-react';
|
import { Users } from 'lucide-react';
|
||||||
|
|
@ -39,6 +39,8 @@ import {
|
||||||
Map,
|
Map,
|
||||||
Zap,
|
Zap,
|
||||||
Music,
|
Music,
|
||||||
|
Sparkles,
|
||||||
|
Plus,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { FeaturedEventUpgrade } from '@/components/FeaturedEventUpgrade';
|
import { FeaturedEventUpgrade } from '@/components/FeaturedEventUpgrade';
|
||||||
import { usePlatformFee } from '@/hooks/usePlatformFee';
|
import { usePlatformFee } from '@/hooks/usePlatformFee';
|
||||||
|
|
@ -110,6 +112,15 @@ export default function CreateEventPage() {
|
||||||
// Paid event / entry fee
|
// Paid event / entry fee
|
||||||
const [entryFee, setEntryFee] = useState('');
|
const [entryFee, setEntryFee] = useState('');
|
||||||
const isPaidEvent = entryFee.trim().length > 0 && entryFee.trim() !== '0';
|
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.
|
// Optional "Feature this event" upgrade — every event posts for free.
|
||||||
const { isActive: isFeatured, featuredUntilSeconds } = usePlatformFee('featuredEvent');
|
const { isActive: isFeatured, featuredUntilSeconds } = usePlatformFee('featuredEvent');
|
||||||
const [goFeatured, setGoFeatured] = useState(false);
|
const [goFeatured, setGoFeatured] = useState(false);
|
||||||
|
|
@ -367,6 +378,12 @@ export default function CreateEventPage() {
|
||||||
tags.push(['music-act', musicAct.title]);
|
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({
|
await createEvent({
|
||||||
kind: CALENDAR_EVENT_KIND,
|
kind: CALENDAR_EVENT_KIND,
|
||||||
content: description.trim(),
|
content: description.trim(),
|
||||||
|
|
@ -978,6 +995,82 @@ export default function CreateEventPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* Optional featured upgrade — every event posts for free */}
|
||||||
<FeaturedEventUpgrade enabled={goFeatured} onEnabledChange={setGoFeatured} />
|
<FeaturedEventUpgrade enabled={goFeatured} onEnabledChange={setGoFeatured} />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -289,6 +289,12 @@ export default function EditEventPage() {
|
||||||
tags.push(['image', image]);
|
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
|
// Detect the changes attendees care about (time / location) BEFORE we
|
||||||
// overwrite the event, so we can auto-notify RSVPers.
|
// overwrite the event, so we can auto-notify RSVPers.
|
||||||
const newLocation = location.trim();
|
const newLocation = location.trim();
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,15 @@ import { ZapButton } from '@/components/ZapButton';
|
||||||
import { RsvpButtons } from '@/components/RsvpButtons';
|
import { RsvpButtons } from '@/components/RsvpButtons';
|
||||||
import { AttendeeList } from '@/components/AttendeeList';
|
import { AttendeeList } from '@/components/AttendeeList';
|
||||||
import { EventUpdates } from '@/components/EventUpdates';
|
import { EventUpdates } from '@/components/EventUpdates';
|
||||||
|
import { EventCountdown } from '@/components/EventCountdown';
|
||||||
|
import { EventSponsors } from '@/components/EventSponsors';
|
||||||
import { CommentsSection } from '@/components/comments/CommentsSection';
|
import { CommentsSection } from '@/components/comments/CommentsSection';
|
||||||
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
||||||
import { useSkateEvent } from '@/hooks/useSkateEvents';
|
import { useSkateEvent } from '@/hooks/useSkateEvents';
|
||||||
import { useEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
|
import { useEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
import { EVENT_TYPE_LABELS, SKATE_CREW_KIND, SKATE_MUSICIAN_KIND } from '@/lib/types';
|
import { EVENT_TYPE_LABELS, SKATE_CREW_KIND, SKATE_MUSICIAN_KIND } from '@/lib/types';
|
||||||
import { Users } from 'lucide-react';
|
import { Users } from 'lucide-react';
|
||||||
import NotFound from './NotFound';
|
import NotFound from './NotFound';
|
||||||
|
|
@ -37,16 +40,6 @@ import {
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
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 {
|
function formatTime(timestamp: number): string {
|
||||||
const date = new Date(timestamp * 1000);
|
const date = new Date(timestamp * 1000);
|
||||||
return date.toLocaleTimeString('en-US', {
|
return date.toLocaleTimeString('en-US', {
|
||||||
|
|
@ -224,48 +217,94 @@ export default function EventDetailPage() {
|
||||||
<div className="grid lg:grid-cols-3 gap-8">
|
<div className="grid lg:grid-cols-3 gap-8">
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<div className="lg:col-span-2 space-y-6">
|
||||||
{/* Cover Image */}
|
{/* ── Immersive hero ── */}
|
||||||
{event.image ? (
|
<div className="relative overflow-hidden rounded-2xl border border-white/10">
|
||||||
<div className="aspect-video rounded-xl overflow-hidden bg-muted">
|
{/* Cover */}
|
||||||
<img
|
<div className="relative aspect-[16/10] sm:aspect-[16/9] w-full bg-muted">
|
||||||
src={event.image}
|
{event.image ? (
|
||||||
alt={event.title}
|
<img
|
||||||
className="w-full h-full object-cover"
|
src={event.image}
|
||||||
/>
|
alt={event.title}
|
||||||
</div>
|
className={cn(
|
||||||
) : (
|
'h-full w-full object-cover',
|
||||||
<div className="aspect-video rounded-xl bg-gradient-to-br from-primary/20 to-accent/20 flex items-center justify-center">
|
isPast && 'grayscale-[35%] brightness-75',
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<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>
|
</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">
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
{crewHost && (
|
{crewHost && (
|
||||||
<Link
|
<Link
|
||||||
to={`/crew/${crewHost.pubkey}/${crewHost.identifier}`}
|
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" />
|
<Users className="h-3.5 w-3.5" />
|
||||||
Hosted by {crewHost.name || 'a crew'}
|
{crewHost.name || 'a crew'}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{musicAct && (
|
{musicAct && (
|
||||||
<Link
|
<Link
|
||||||
to={`/coach/${musicAct.pubkey}/${musicAct.identifier}`}
|
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" />
|
<Music className="h-3.5 w-3.5" />
|
||||||
Live: {musicAct.name || 'live music'}
|
Live: {musicAct.name || 'live music'}
|
||||||
|
|
@ -273,57 +312,61 @@ export default function EventDetailPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Date & Time */}
|
{/* Meta bar: date · time · location */}
|
||||||
<div className="flex flex-wrap gap-4 text-muted-foreground">
|
<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-2">
|
<div className="flex items-center gap-3 p-4">
|
||||||
<Calendar className="h-5 w-5" />
|
<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>{formatDate(event.start)}</span>
|
<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>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3 p-4">
|
||||||
<Clock className="h-5 w-5" />
|
<div className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-lg border border-white/10 bg-white/5">
|
||||||
<span>
|
<Clock className="h-5 w-5 text-[#ff6600]" />
|
||||||
{formatTime(event.start)}
|
</div>
|
||||||
{event.end && ` - ${formatTime(event.end)}`}
|
<div className="min-w-0">
|
||||||
</span>
|
<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>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Location */}
|
{/* Event body */}
|
||||||
{event.location && (
|
<div className="space-y-4">
|
||||||
<p className="text-muted-foreground flex items-center gap-2">
|
{/* Sponsors */}
|
||||||
<MapPin className="h-5 w-5" />
|
{event.sponsors && event.sponsors.length > 0 && (
|
||||||
{event.location}
|
<Card>
|
||||||
</p>
|
<CardContent className="pt-6">
|
||||||
)}
|
<EventSponsors sponsors={event.sponsors} />
|
||||||
|
</CardContent>
|
||||||
{/* Types */}
|
</Card>
|
||||||
{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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue