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;
|
end?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const [, forceTick] = useState(0);
|
const [tick, forceTick] = useState(0);
|
||||||
|
|
||||||
// Re-render periodically so the countdown stays fresh without a heavy timer.
|
// Re-render periodically so the countdown stays fresh without a heavy timer.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -57,7 +57,8 @@ export function EventCountdown({
|
||||||
return () => clearInterval(id);
|
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> = {
|
const styles: Record<EventStatus, string> = {
|
||||||
live: 'bg-[#ff0040] text-white border-[#ff0040] animate-pulse',
|
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). */
|
/** Standalone helper to get just the status (for conditional styling). */
|
||||||
export function useEventStatus(start: number, end?: number): EventStatus {
|
export function useEventStatus(start: number, end?: number): EventStatus {
|
||||||
const [, forceTick] = useState(0);
|
const [tick, forceTick] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setInterval(() => forceTick((n) => n + 1), 30_000);
|
const id = setInterval(() => forceTick((n) => n + 1), 30_000);
|
||||||
return () => clearInterval(id);
|
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';
|
} 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
|
* Given a calendar event coordinate ("31923:pubkey:d") we fetch every RSVP that
|
||||||
* references it, keep the newest per person, and expose:
|
* references it, keep the newest per person, and expose:
|
||||||
|
|
@ -129,7 +129,7 @@ export function useRsvp() {
|
||||||
|
|
||||||
const tags: string[][] = [
|
const tags: string[][] = [
|
||||||
['d', d],
|
['d', d],
|
||||||
['a', coord, '', ''],
|
['a', coord],
|
||||||
['e', opts.event.id],
|
['e', opts.event.id],
|
||||||
['status', opts.status],
|
['status', opts.status],
|
||||||
// Let the host easily query RSVPs addressed to them.
|
// Let the host easily query RSVPs addressed to them.
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ export function usePostEventUpdate() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const tags: string[][] = [
|
const tags: string[][] = [
|
||||||
['a', coord, '', ''],
|
['a', coord],
|
||||||
['e', opts.event.id],
|
['e', opts.event.id],
|
||||||
['update-kind', kind],
|
['update-kind', kind],
|
||||||
['t', 'skateboarding'],
|
['t', 'skateboarding'],
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ 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 { useEventUpdates } from '@/hooks/useEventUpdates';
|
||||||
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 { cn } from '@/lib/utils';
|
||||||
|
|
@ -37,6 +38,8 @@ import {
|
||||||
Pencil,
|
Pencil,
|
||||||
Music,
|
Music,
|
||||||
Megaphone,
|
Megaphone,
|
||||||
|
XCircle,
|
||||||
|
CalendarClock,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
|
@ -144,6 +147,19 @@ export default function EventDetailPage() {
|
||||||
// NIP-52 RSVPs for this event (counts + attendee avatars).
|
// NIP-52 RSVPs for this event (counts + attendee avatars).
|
||||||
const rsvpSummary = useEventRsvps(event ? eventCoord(event) : undefined);
|
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 isUpcoming = event ? event.start > Date.now() / 1000 : false;
|
||||||
const isPast = 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>
|
</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">
|
<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">
|
||||||
|
|
@ -286,7 +334,7 @@ export default function EventDetailPage() {
|
||||||
alt={event.title}
|
alt={event.title}
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-full w-full object-cover',
|
'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>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<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) ? (
|
{(rsvpSummary.goingCount > 0 || rsvpSummary.tentativeCount > 0) ? (
|
||||||
<AttendeeList going={rsvpSummary.going} tentative={rsvpSummary.tentative} />
|
<AttendeeList going={rsvpSummary.going} tentative={rsvpSummary.tentative} />
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue