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>
This commit is contained in:
parent
29d09eb6ec
commit
25c57cbb2f
4 changed files with 64 additions and 9 deletions
|
|
@ -49,7 +49,7 @@ export function EventCountdown({
|
|||
end?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const [, forceTick] = useState(0);
|
||||
const [tick, forceTick] = useState(0);
|
||||
|
||||
// Re-render periodically so the countdown stays fresh without a heavy timer.
|
||||
useEffect(() => {
|
||||
|
|
@ -57,7 +57,8 @@ export function EventCountdown({
|
|||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const info = useMemo(() => computeStatus(start, end), [start, end]);
|
||||
// `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',
|
||||
|
|
@ -87,10 +88,10 @@ export function EventCountdown({
|
|||
|
||||
/** Standalone helper to get just the status (for conditional styling). */
|
||||
export function useEventStatus(start: number, end?: number): EventStatus {
|
||||
const [, forceTick] = useState(0);
|
||||
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]);
|
||||
return useMemo(() => computeStatus(start, end).status, [start, end, tick]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
} from '@/lib/types';
|
||||
|
||||
/**
|
||||
* NIP-52 RSVP layer for skate calendar events (kind 31925).
|
||||
* NIP-52 RSVP layer for skate calendar events (calendar event kind 31923, RSVP kind 31925).
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* Given a calendar event coordinate ("31923:pubkey:d") we fetch every RSVP that
|
||||
* references it, keep the newest per person, and expose:
|
||||
|
|
@ -129,7 +129,7 @@ export function useRsvp() {
|
|||
|
||||
const tags: string[][] = [
|
||||
['d', d],
|
||||
['a', coord, '', ''],
|
||||
['a', coord],
|
||||
['e', opts.event.id],
|
||||
['status', opts.status],
|
||||
// Let the host easily query RSVPs addressed to them.
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ export function usePostEventUpdate() {
|
|||
);
|
||||
|
||||
const tags: string[][] = [
|
||||
['a', coord, '', ''],
|
||||
['a', coord],
|
||||
['e', opts.event.id],
|
||||
['update-kind', kind],
|
||||
['t', 'skateboarding'],
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { CommentsSection } from '@/components/comments/CommentsSection';
|
|||
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
||||
import { useSkateEvent } from '@/hooks/useSkateEvents';
|
||||
import { useEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
|
||||
import { useEventUpdates } from '@/hooks/useEventUpdates';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
|
@ -37,6 +38,8 @@ import {
|
|||
Pencil,
|
||||
Music,
|
||||
Megaphone,
|
||||
XCircle,
|
||||
CalendarClock,
|
||||
} from 'lucide-react';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
|
@ -144,6 +147,19 @@ export default function EventDetailPage() {
|
|||
// NIP-52 RSVPs for this event (counts + attendee avatars).
|
||||
const rsvpSummary = useEventRsvps(event ? eventCoord(event) : undefined);
|
||||
|
||||
// Host updates → detect if the host cancelled or rescheduled, so we can warn
|
||||
// attendees loudly instead of letting them show up to a dead jam.
|
||||
const { data: eventUpdates } = useEventUpdates(event ? eventCoord(event) : undefined);
|
||||
const latestCancellation = useMemo(
|
||||
() => eventUpdates?.find((u) => u.updateKind === 'cancelled') ?? null,
|
||||
[eventUpdates],
|
||||
);
|
||||
const latestReschedule = useMemo(() => {
|
||||
if (latestCancellation) return null; // cancellation wins
|
||||
return eventUpdates?.find((u) => u.updateKind === 'reschedule' || u.updateKind === 'relocation') ?? null;
|
||||
}, [eventUpdates, latestCancellation]);
|
||||
const isCancelled = !!latestCancellation;
|
||||
|
||||
const isUpcoming = event ? event.start > Date.now() / 1000 : false;
|
||||
const isPast = event ? event.start < Date.now() / 1000 : false;
|
||||
|
||||
|
|
@ -273,6 +289,38 @@ export default function EventDetailPage() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Cancelled / rescheduled banner — loud, above everything */}
|
||||
{isCancelled && (
|
||||
<div className="mb-6 flex items-start gap-3 rounded-xl border-2 border-red-500/60 bg-red-500/10 p-4">
|
||||
<XCircle className="mt-0.5 h-6 w-6 flex-shrink-0 text-red-400" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-base font-black uppercase tracking-wide text-red-300">
|
||||
This event was cancelled
|
||||
</p>
|
||||
{latestCancellation?.message && (
|
||||
<p className="mt-1 whitespace-pre-wrap break-words text-sm text-red-100/90">
|
||||
{latestCancellation.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isCancelled && latestReschedule && (
|
||||
<div className="mb-6 flex items-start gap-3 rounded-xl border-2 border-amber-500/50 bg-amber-500/10 p-4">
|
||||
<CalendarClock className="mt-0.5 h-6 w-6 flex-shrink-0 text-amber-400" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-base font-black uppercase tracking-wide text-amber-300">
|
||||
{latestReschedule.updateKind === 'relocation' ? 'New location' : 'Time changed'} — check the update
|
||||
</p>
|
||||
{latestReschedule.message && (
|
||||
<p className="mt-1 whitespace-pre-wrap break-words text-sm text-amber-100/90">
|
||||
{latestReschedule.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-8">
|
||||
{/* Main Content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
|
|
@ -286,7 +334,7 @@ export default function EventDetailPage() {
|
|||
alt={event.title}
|
||||
className={cn(
|
||||
'h-full w-full object-cover',
|
||||
isPast && 'grayscale-[35%] brightness-75',
|
||||
(isPast || isCancelled) && 'grayscale-[35%] brightness-75',
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
|
|
@ -493,7 +541,13 @@ export default function EventDetailPage() {
|
|||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!isPast && <RsvpButtons event={event} />}
|
||||
{isCancelled ? (
|
||||
<p className="rounded-lg border border-red-500/30 bg-red-500/5 px-3 py-2 text-sm font-medium text-red-300">
|
||||
RSVPs are closed — this event was cancelled by the host.
|
||||
</p>
|
||||
) : (
|
||||
!isPast && <RsvpButtons event={event} />
|
||||
)}
|
||||
|
||||
{(rsvpSummary.goingCount > 0 || rsvpSummary.tentativeCount > 0) ? (
|
||||
<AttendeeList going={rsvpSummary.going} tentative={rsvpSummary.tentative} />
|
||||
|
|
|
|||
Loading…
Reference in a new issue