SkateSpots/src/components/EventCountdown.tsx
shakespeare.diy 25c57cbb2f Fix event UX correctness: live countdown, cancellation banner, NIP-52 tag cleanup
- 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>
2026-09-03 14:33:28 +07:00

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]);
}