- FIX: EventCountdown label never updated (memo ignored the interval tick) — now recomputes every 30s for both EventCountdown and useEventStatus - Add loud cancelled / rescheduled banner on EventDetailPage derived from host updates; close RSVP + dim hero when cancelled so attendees don't show up to a dead jam - Clean up non-standard NIP-52 `a` tags (['a', coord, '', ''] → ['a', coord]) in RSVP and host-update publishing - Correct misleading kind comment in useEventRsvps Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
97 lines
3 KiB
TypeScript
97 lines
3 KiB
TypeScript
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 [tick, 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);
|
|
}, []);
|
|
|
|
// `tick` is a dependency so the label actually recomputes on each interval.
|
|
const info = useMemo(() => computeStatus(start, end), [start, end, tick]);
|
|
|
|
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 [tick, forceTick] = useState(0);
|
|
useEffect(() => {
|
|
const id = setInterval(() => forceTick((n) => n + 1), 30_000);
|
|
return () => clearInterval(id);
|
|
}, []);
|
|
return useMemo(() => computeStatus(start, end).status, [start, end, tick]);
|
|
}
|