SkateSpots/src/components/RsvpButtons.tsx
shakespeare.diy 7439b980bb Events Phase 1: NIP-52 RSVPs, attendee stacks, date filter, host notifications
- Adopt NIP-52 RSVP (kind 31925): parseEventRsvp + RsvpStatus types
- useEventRsvps (counts by status, attendee pubkeys, my status) + useRsvp
  mutation that replaces the user's prior RSVP via a stable d-tag
- RsvpButtons (Going / Interested / Can't make it) + AttendeeStack avatar row
- EventDetailPage: "Who's going" card with RSVP control + attendee avatars
- EventCard: going count on both compact and full cards
- EventsPage: quick date-window chips (Today / Weekend / Week / Month)
- Activity bell: host RSVP notifications (new 'rsvp' activity type)
- Document RSVP schema in NIP.md

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-03 10:47:57 +07:00

138 lines
4.4 KiB
TypeScript

import { Check, HelpCircle, X, Loader2 } from 'lucide-react';
import { LoginArea } from '@/components/auth/LoginArea';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast';
import { useEventRsvps, useRsvp } from '@/hooks/useEventRsvps';
import { eventCoord } from '@/hooks/useEventRsvps';
import { cn } from '@/lib/utils';
import type { RsvpStatus, SkateEvent } from '@/lib/types';
const OPTIONS: {
status: RsvpStatus;
label: string;
shortLabel: string;
icon: typeof Check;
activeClass: string;
}[] = [
{
status: 'accepted',
label: "I'm Going",
shortLabel: 'Going',
icon: Check,
activeClass: 'bg-green-500 border-green-500 text-black',
},
{
status: 'tentative',
label: 'Interested',
shortLabel: 'Maybe',
icon: HelpCircle,
activeClass: 'bg-[#ccff00] border-[#ccff00] text-black',
},
{
status: 'declined',
label: "Can't make it",
shortLabel: "Can't",
icon: X,
activeClass: 'bg-red-500/80 border-red-500 text-white',
},
];
/**
* NIP-52 RSVP control. A segmented Going / Interested / Can't-make-it toggle
* that publishes (or replaces) the user's RSVP. Tapping the active option again
* is a no-op. Logged-out users see a login prompt instead.
*/
export function RsvpButtons({ event, className }: { event: SkateEvent; className?: string }) {
const { user } = useCurrentUser();
const { toast } = useToast();
const coord = eventCoord(event);
const { myRsvp, myStatus, goingCount, tentativeCount } = useEventRsvps(coord);
const { mutateAsync: rsvp, isPending } = useRsvp();
const isHost = user?.pubkey === event.pubkey;
if (!user) {
return (
<div className={cn('rounded-xl border border-white/10 bg-white/[0.03] p-4 text-center', className)}>
<p className="mb-3 text-sm font-bold text-gray-300">
Are you going?{' '}
<span className="text-muted-foreground font-normal">Log in to RSVP.</span>
</p>
<LoginArea className="w-full justify-center" />
</div>
);
}
const handleClick = async (status: RsvpStatus) => {
if (status === myStatus || isPending) return;
try {
await rsvp({
event,
status,
existingIdentifier: myRsvp?.identifier,
});
const msg =
status === 'accepted'
? "You're going! 🛹"
: status === 'tentative'
? 'Marked as interested.'
: 'Marked as not going.';
toast({ title: msg });
} catch (err) {
toast({
title: 'Could not RSVP',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
}
};
return (
<div className={cn('space-y-2', className)}>
{isHost && (
<p className="text-xs text-muted-foreground">
You're hosting this — but you can still set your status.
</p>
)}
<div className="grid grid-cols-3 gap-2">
{OPTIONS.map(({ status, label, shortLabel, icon: Icon, activeClass }) => {
const active = myStatus === status;
return (
<button
key={status}
type="button"
disabled={isPending}
onClick={() => handleClick(status)}
aria-pressed={active}
className={cn(
'flex flex-col items-center justify-center gap-1 rounded-xl border-2 py-2.5 text-xs font-black uppercase tracking-wide transition-all active:scale-95 disabled:opacity-60',
active
? activeClass
: 'border-white/10 bg-white/5 text-gray-400 hover:border-white/20 hover:text-white',
)}
>
{isPending && active ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Icon className="h-4 w-4" />
)}
<span className="hidden sm:inline">{label}</span>
<span className="sm:hidden">{shortLabel}</span>
</button>
);
})}
</div>
{(goingCount > 0 || tentativeCount > 0) && (
<p className="text-center text-xs text-muted-foreground">
<span className="font-bold text-green-400">{goingCount}</span> going
{tentativeCount > 0 && (
<>
{' · '}
<span className="font-bold text-[#ccff00]">{tentativeCount}</span> interested
</>
)}
</p>
)}
</div>
);
}