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>
This commit is contained in:
parent
453adc4604
commit
7439b980bb
10 changed files with 605 additions and 8 deletions
26
NIP.md
26
NIP.md
|
|
@ -342,6 +342,32 @@ Skateboarding and music culture are deeply connected. These event types support
|
||||||
|
|
||||||
Events can have multiple type tags. For example, a video premiere with a live band would have both `premiere` and `concert` tags.
|
Events can have multiple type tags. For example, a video premiere with a live band would have both `premiere` and `concert` tags.
|
||||||
|
|
||||||
|
### Event RSVPs (Attendance)
|
||||||
|
|
||||||
|
Attendance uses the standard **NIP-52 RSVP** (kind `31925`). A skater responds to a calendar event with `accepted` (going), `tentative` (interested), or `declined` (can't make it). RSVPs are addressable, so publishing a new one with the same `d` replaces the prior status — letting a skater freely switch.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": 31925,
|
||||||
|
"content": "<optional note>",
|
||||||
|
"tags": [
|
||||||
|
["d", "<unique-id>"],
|
||||||
|
["a", "31923:<event-author-pubkey>:<event-d>", "", ""],
|
||||||
|
["e", "<event-id>"],
|
||||||
|
["status", "accepted"],
|
||||||
|
["p", "<event-author-pubkey>"],
|
||||||
|
["fb", "busy"],
|
||||||
|
["alt", "RSVP (accepted) to a skate event"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- The `a` tag references the calendar event coordinate being responded to (required).
|
||||||
|
- The `p` tag targets the host so hosts can query RSVPs addressed to them (and get notified).
|
||||||
|
- `fb` (`free`/`busy`) is omitted for `declined` per NIP-52.
|
||||||
|
|
||||||
|
Clients query an event's RSVPs with `{ kinds: [31925], "#a": ["31923:<host>:<d>"] }` and keep the newest per pubkey. Hosts see incoming RSVPs in their notification center via `{ kinds: [31925], "#p": ["<host-pubkey>"] }`.
|
||||||
|
|
||||||
## Social Interaction
|
## Social Interaction
|
||||||
|
|
||||||
Comments and reactions to skate spots and events use standard Nostr kinds:
|
Comments and reactions to skate spots and events use standard Nostr kinds:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { Heart, Repeat2, Zap, AtSign, MessageCircle } from 'lucide-react';
|
import { Heart, Repeat2, Zap, AtSign, MessageCircle, CalendarCheck } from 'lucide-react';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
|
|
@ -21,6 +21,7 @@ const TYPE_META: Record<
|
||||||
zap: { icon: Zap, color: 'text-yellow-400', ring: 'bg-yellow-500/15' },
|
zap: { icon: Zap, color: 'text-yellow-400', ring: 'bg-yellow-500/15' },
|
||||||
mention: { icon: AtSign, color: 'text-sky-400', ring: 'bg-sky-500/15' },
|
mention: { icon: AtSign, color: 'text-sky-400', ring: 'bg-sky-500/15' },
|
||||||
reply: { icon: MessageCircle, color: 'text-teal-400', ring: 'bg-teal-500/15' },
|
reply: { icon: MessageCircle, color: 'text-teal-400', ring: 'bg-teal-500/15' },
|
||||||
|
rsvp: { icon: CalendarCheck, color: 'text-emerald-400', ring: 'bg-emerald-500/15' },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
71
src/components/AttendeeStack.tsx
Normal file
71
src/components/AttendeeStack.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
|
import { genUserName } from '@/lib/genUserName';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function StackAvatar({ pubkey, index }: { pubkey: string; index: number }) {
|
||||||
|
const author = useAuthor(pubkey);
|
||||||
|
const name = author.data?.metadata?.name ?? genUserName(pubkey);
|
||||||
|
const pic = author.data?.metadata?.picture;
|
||||||
|
|
||||||
|
const npub = (() => {
|
||||||
|
try {
|
||||||
|
return nip19.npubEncode(pubkey);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const avatar = (
|
||||||
|
<Avatar
|
||||||
|
className="h-7 w-7 border-2 border-background ring-0"
|
||||||
|
style={{ zIndex: 20 - index }}
|
||||||
|
title={name}
|
||||||
|
>
|
||||||
|
<AvatarImage src={pic} alt={name} />
|
||||||
|
<AvatarFallback className="text-[9px] font-bold">
|
||||||
|
{name.slice(0, 2).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!npub) return avatar;
|
||||||
|
return (
|
||||||
|
<Link to={`/${npub}`} className="transition-transform hover:-translate-y-0.5">
|
||||||
|
{avatar}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A horizontal, overlapping stack of attendee avatars with a "+N" overflow.
|
||||||
|
* Purely presentational — pass a list of pubkeys.
|
||||||
|
*/
|
||||||
|
export function AttendeeStack({
|
||||||
|
pubkeys,
|
||||||
|
max = 5,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
pubkeys: string[];
|
||||||
|
max?: number;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
if (pubkeys.length === 0) return null;
|
||||||
|
const shown = pubkeys.slice(0, max);
|
||||||
|
const overflow = pubkeys.length - shown.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center', className)}>
|
||||||
|
<div className="flex -space-x-2">
|
||||||
|
{shown.map((pk, i) => (
|
||||||
|
<StackAvatar key={pk} pubkey={pk} index={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{overflow > 0 && (
|
||||||
|
<span className="ml-2 text-xs font-bold text-muted-foreground">+{overflow}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -11,10 +11,11 @@ import {
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { Calendar, Clock, MapPin, MoreVertical, Flag } from 'lucide-react';
|
import { Calendar, Clock, MapPin, MoreVertical, Flag, Users } from 'lucide-react';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
|
import { useEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
|
||||||
import { ZapButton } from '@/components/ZapButton';
|
import { ZapButton } from '@/components/ZapButton';
|
||||||
import { TrustBadge } from '@/components/TrustBadge';
|
import { TrustBadge } from '@/components/TrustBadge';
|
||||||
import { OfficialBadge } from '@/components/OfficialBadge';
|
import { OfficialBadge } from '@/components/OfficialBadge';
|
||||||
|
|
@ -73,6 +74,7 @@ export function EventCard({ event, compact = false }: EventCardProps) {
|
||||||
const relativeTime = getRelativeTime(event.start);
|
const relativeTime = getRelativeTime(event.start);
|
||||||
const isUpcoming = event.start > Date.now() / 1000;
|
const isUpcoming = event.start > Date.now() / 1000;
|
||||||
const isOwner = user?.pubkey === event.pubkey;
|
const isOwner = user?.pubkey === event.pubkey;
|
||||||
|
const { goingCount } = useEventRsvps(eventCoord(event));
|
||||||
|
|
||||||
if (compact) {
|
if (compact) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -119,6 +121,12 @@ export function EventCard({ event, compact = false }: EventCardProps) {
|
||||||
<span className="truncate">{event.location}</span>
|
<span className="truncate">{event.location}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{goingCount > 0 && (
|
||||||
|
<span className="flex items-center gap-1 text-green-400 font-bold">
|
||||||
|
<Users className="h-3 w-3" />
|
||||||
|
{goingCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<TrustBadge pubkey={event.pubkey} size="sm" />
|
<TrustBadge pubkey={event.pubkey} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -228,7 +236,15 @@ export function EventCard({ event, compact = false }: EventCardProps) {
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-end mt-4 pt-4 border-t border-[#ccff00]/20">
|
<div className="flex items-center justify-between mt-4 pt-4 border-t border-[#ccff00]/20">
|
||||||
|
{goingCount > 0 ? (
|
||||||
|
<span className="flex items-center gap-1.5 text-sm font-bold text-green-400">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
{goingCount} going
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-2" onClick={(e) => e.preventDefault()}>
|
<div className="flex items-center gap-2" onClick={(e) => e.preventDefault()}>
|
||||||
<ZapButton target={event.event} className="text-gray-400 hover:text-[#ccff00]" />
|
<ZapButton target={event.event} className="text-gray-400 hover:text-[#ccff00]" />
|
||||||
{/* More options menu */}
|
{/* More options menu */}
|
||||||
|
|
|
||||||
138
src/components/RsvpButtons.tsx
Normal file
138
src/components/RsvpButtons.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -16,7 +16,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
* load minimal (a single filter) per the project's query-efficiency rules.
|
* load minimal (a single filter) per the project's query-efficiency rules.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type ActivityType = 'reaction' | 'repost' | 'zap' | 'mention' | 'reply';
|
export type ActivityType = 'reaction' | 'repost' | 'zap' | 'mention' | 'reply' | 'rsvp';
|
||||||
|
|
||||||
export interface ActivityNotification {
|
export interface ActivityNotification {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -32,6 +32,8 @@ export interface ActivityNotification {
|
||||||
targetEventId?: string;
|
targetEventId?: string;
|
||||||
/** Sats amount for zaps, parsed from the bolt11/description when available. */
|
/** Sats amount for zaps, parsed from the bolt11/description when available. */
|
||||||
amountSats?: number;
|
amountSats?: number;
|
||||||
|
/** For RSVPs: accepted / tentative / declined. */
|
||||||
|
rsvpStatus?: 'accepted' | 'tentative' | 'declined';
|
||||||
}
|
}
|
||||||
|
|
||||||
const REACTION_KIND = 7;
|
const REACTION_KIND = 7;
|
||||||
|
|
@ -39,6 +41,7 @@ const REPOST_KINDS = [6, 16];
|
||||||
const ZAP_RECEIPT_KIND = 9735;
|
const ZAP_RECEIPT_KIND = 9735;
|
||||||
const TEXT_NOTE_KIND = 1;
|
const TEXT_NOTE_KIND = 1;
|
||||||
const COMMENT_KIND = 1111;
|
const COMMENT_KIND = 1111;
|
||||||
|
const RSVP_KIND = 31925;
|
||||||
|
|
||||||
/** Parse the sats amount out of a zap receipt (9735) if we can. */
|
/** Parse the sats amount out of a zap receipt (9735) if we can. */
|
||||||
function parseZapAmount(event: NostrEvent): number | undefined {
|
function parseZapAmount(event: NostrEvent): number | undefined {
|
||||||
|
|
@ -102,9 +105,10 @@ export function useActivityNotifications() {
|
||||||
ZAP_RECEIPT_KIND,
|
ZAP_RECEIPT_KIND,
|
||||||
TEXT_NOTE_KIND,
|
TEXT_NOTE_KIND,
|
||||||
COMMENT_KIND,
|
COMMENT_KIND,
|
||||||
|
RSVP_KIND,
|
||||||
],
|
],
|
||||||
'#p': [pubkey],
|
'#p': [pubkey],
|
||||||
limit: 100,
|
limit: 120,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
{ signal },
|
{ signal },
|
||||||
|
|
@ -171,6 +175,20 @@ export function useActivityNotifications() {
|
||||||
content: event.content,
|
content: event.content,
|
||||||
targetEventId,
|
targetEventId,
|
||||||
});
|
});
|
||||||
|
} else if (event.kind === RSVP_KIND) {
|
||||||
|
// Someone RSVP'd to an event I'm hosting (RSVP p-tags the host).
|
||||||
|
const status = event.tags.find(([t]) => t === 'status')?.[1];
|
||||||
|
if (status !== 'accepted' && status !== 'tentative' && status !== 'declined') continue;
|
||||||
|
out.push({
|
||||||
|
id: event.id,
|
||||||
|
type: 'rsvp',
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
createdAt: event.created_at,
|
||||||
|
event,
|
||||||
|
content: event.content,
|
||||||
|
targetEventId,
|
||||||
|
rsvpStatus: status,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -199,6 +217,12 @@ export function activityVerb(n: ActivityNotification): string {
|
||||||
return 'mentioned you';
|
return 'mentioned you';
|
||||||
case 'reply':
|
case 'reply':
|
||||||
return 'replied to you';
|
return 'replied to you';
|
||||||
|
case 'rsvp':
|
||||||
|
return n.rsvpStatus === 'accepted'
|
||||||
|
? "is going to your event"
|
||||||
|
: n.rsvpStatus === 'tentative'
|
||||||
|
? 'is interested in your event'
|
||||||
|
: "can't make your event";
|
||||||
default:
|
default:
|
||||||
return 'interacted with you';
|
return 'interacted with you';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
155
src/hooks/useEventRsvps.ts
Normal file
155
src/hooks/useEventRsvps.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||||
|
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||||
|
import {
|
||||||
|
CALENDAR_EVENT_KIND,
|
||||||
|
CALENDAR_RSVP_KIND,
|
||||||
|
parseEventRsvp,
|
||||||
|
type EventRsvp,
|
||||||
|
type RsvpStatus,
|
||||||
|
type SkateEvent,
|
||||||
|
} from '@/lib/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-52 RSVP layer for skate calendar events (kind 31925).
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* Given a calendar event coordinate ("31923:pubkey:d") we fetch every RSVP that
|
||||||
|
* references it, keep the newest per person, and expose:
|
||||||
|
* • counts by status (going / interested / declined)
|
||||||
|
* • the ordered lists of pubkeys (for avatar stacks)
|
||||||
|
* • the logged-in user's own current status
|
||||||
|
*
|
||||||
|
* Publishing a new RSVP reuses the person's existing `d` identifier so it
|
||||||
|
* REPLACES their prior RSVP (addressable), letting them freely switch between
|
||||||
|
* Going / Interested / Can't make it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface EventRsvpSummary {
|
||||||
|
going: string[];
|
||||||
|
tentative: string[];
|
||||||
|
declined: string[];
|
||||||
|
goingCount: number;
|
||||||
|
tentativeCount: number;
|
||||||
|
/** All RSVPs, newest per person. */
|
||||||
|
rsvps: EventRsvp[];
|
||||||
|
/** The logged-in user's current RSVP, if any. */
|
||||||
|
myRsvp: EventRsvp | null;
|
||||||
|
myStatus: RsvpStatus | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the addressable coordinate for a calendar event. */
|
||||||
|
export function eventCoord(event: Pick<SkateEvent, 'pubkey' | 'identifier'>): string {
|
||||||
|
return `${CALENDAR_EVENT_KIND}:${event.pubkey}:${event.identifier}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEventRsvps(coord: string | undefined): EventRsvpSummary {
|
||||||
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ['event-rsvps', coord, getQueryKeySuffix()],
|
||||||
|
enabled: !!coord,
|
||||||
|
staleTime: 30_000,
|
||||||
|
queryFn: async (c) => {
|
||||||
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
|
||||||
|
const events = await querySkateContent(
|
||||||
|
[{ kinds: [CALENDAR_RSVP_KIND], '#a': [coord!], limit: 500 }],
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keep the newest RSVP per pubkey (an addressable replace can arrive as
|
||||||
|
// multiple copies across relays / revisions).
|
||||||
|
const byPubkey = new Map<string, EventRsvp>();
|
||||||
|
for (const ev of events) {
|
||||||
|
const rsvp = parseEventRsvp(ev);
|
||||||
|
if (!rsvp || rsvp.eventCoord !== coord) continue;
|
||||||
|
const existing = byPubkey.get(rsvp.pubkey);
|
||||||
|
if (!existing || rsvp.createdAt > existing.createdAt) {
|
||||||
|
byPubkey.set(rsvp.pubkey, rsvp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(byPubkey.values());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const rsvps = query.data ?? [];
|
||||||
|
const going: string[] = [];
|
||||||
|
const tentative: string[] = [];
|
||||||
|
const declined: string[] = [];
|
||||||
|
|
||||||
|
// Newest first so avatar stacks show the most recent RSVPs.
|
||||||
|
const sorted = [...rsvps].sort((a, b) => b.createdAt - a.createdAt);
|
||||||
|
for (const r of sorted) {
|
||||||
|
if (r.status === 'accepted') going.push(r.pubkey);
|
||||||
|
else if (r.status === 'tentative') tentative.push(r.pubkey);
|
||||||
|
else if (r.status === 'declined') declined.push(r.pubkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const myRsvp = user ? rsvps.find((r) => r.pubkey === user.pubkey) ?? null : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
going,
|
||||||
|
tentative,
|
||||||
|
declined,
|
||||||
|
goingCount: going.length,
|
||||||
|
tentativeCount: tentative.length,
|
||||||
|
rsvps: sorted,
|
||||||
|
myRsvp,
|
||||||
|
myStatus: myRsvp?.status ?? null,
|
||||||
|
isLoading: query.isLoading,
|
||||||
|
};
|
||||||
|
}, [query.data, query.isLoading, user]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish (or replace) the logged-in user's RSVP to a calendar event.
|
||||||
|
* Reuses an existing RSVP `d` so switching status replaces the prior one.
|
||||||
|
*/
|
||||||
|
export function useRsvp() {
|
||||||
|
const { mutateAsync: publish, isPending } = useNostrPublish();
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async (opts: {
|
||||||
|
event: SkateEvent;
|
||||||
|
status: RsvpStatus;
|
||||||
|
/** Reuse the existing RSVP identifier when the user already RSVP'd. */
|
||||||
|
existingIdentifier?: string;
|
||||||
|
note?: string;
|
||||||
|
}) => {
|
||||||
|
if (!user) throw new Error('You must be logged in to RSVP.');
|
||||||
|
|
||||||
|
const coord = eventCoord(opts.event);
|
||||||
|
const d = opts.existingIdentifier || crypto.randomUUID();
|
||||||
|
|
||||||
|
const tags: string[][] = [
|
||||||
|
['d', d],
|
||||||
|
['a', coord, '', ''],
|
||||||
|
['e', opts.event.id],
|
||||||
|
['status', opts.status],
|
||||||
|
// Let the host easily query RSVPs addressed to them.
|
||||||
|
['p', opts.event.pubkey],
|
||||||
|
// Free/busy hint (omit for declined per NIP-52).
|
||||||
|
...(opts.status !== 'declined' ? [['fb', 'busy']] : []),
|
||||||
|
['alt', `RSVP (${opts.status}) to a skate event`],
|
||||||
|
];
|
||||||
|
|
||||||
|
return publish({
|
||||||
|
kind: CALENDAR_RSVP_KIND,
|
||||||
|
content: opts.note ?? '',
|
||||||
|
tags,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
const coord = eventCoord(variables.event);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['event-rsvps', coord] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...mutation, isPending: isPending || mutation.isPending };
|
||||||
|
}
|
||||||
|
|
@ -46,6 +46,7 @@ function clampStr(s: string | undefined, max: number): string | undefined {
|
||||||
export const SKATE_SPOT_KIND = 30818; // Addressable event - editable per pubkey+kind+d-tag
|
export const SKATE_SPOT_KIND = 30818; // Addressable event - editable per pubkey+kind+d-tag
|
||||||
export const SKATE_SHOP_KIND = 30017; // NIP-15 Stall (marketplace)
|
export const SKATE_SHOP_KIND = 30017; // NIP-15 Stall (marketplace)
|
||||||
export const CALENDAR_EVENT_KIND = 31923;
|
export const CALENDAR_EVENT_KIND = 31923;
|
||||||
|
export const CALENDAR_RSVP_KIND = 31925; // NIP-52 Calendar Event RSVP (accepted/tentative/declined)
|
||||||
export const SKATE_SKATER_KIND = 35000; // Skater profile — the app-native "I skate here" identity
|
export const SKATE_SKATER_KIND = 35000; // Skater profile — the app-native "I skate here" identity
|
||||||
export const SKATE_COACH_KIND = 35001;
|
export const SKATE_COACH_KIND = 35001;
|
||||||
export const SKATE_FILMER_KIND = 35002; // Skate filmer (video) profiles
|
export const SKATE_FILMER_KIND = 35002; // Skate filmer (video) profiles
|
||||||
|
|
@ -843,6 +844,53 @@ export function parseSkateEvent(event: NostrEvent): SkateEvent | null {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── NIP-52 Calendar Event RSVP (kind 31925) ────────────────────────────────
|
||||||
|
|
||||||
|
export type RsvpStatus = 'accepted' | 'tentative' | 'declined';
|
||||||
|
|
||||||
|
export interface EventRsvp {
|
||||||
|
id: string;
|
||||||
|
/** The pubkey of the person RSVP'ing. */
|
||||||
|
pubkey: string;
|
||||||
|
/** The RSVP's own `d` identifier. */
|
||||||
|
identifier: string;
|
||||||
|
/** Coordinate of the calendar event being responded to ("31923:pubkey:d"). */
|
||||||
|
eventCoord: string;
|
||||||
|
status: RsvpStatus;
|
||||||
|
/** Optional free-form note. */
|
||||||
|
note?: string;
|
||||||
|
createdAt: number;
|
||||||
|
event: NostrEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a NIP-52 RSVP (kind 31925). Requires a valid `a` coordinate to a
|
||||||
|
* calendar event and a recognized `status`.
|
||||||
|
*/
|
||||||
|
export function parseEventRsvp(event: NostrEvent): EventRsvp | null {
|
||||||
|
if (event.kind !== CALENDAR_RSVP_KIND) return null;
|
||||||
|
|
||||||
|
const d = event.tags.find(([name]) => name === 'd')?.[1];
|
||||||
|
const eventCoord = event.tags.find(([name]) => name === 'a')?.[1];
|
||||||
|
const status = event.tags.find(([name]) => name === 'status')?.[1] as RsvpStatus | undefined;
|
||||||
|
|
||||||
|
if (!d || !eventCoord || !status) return null;
|
||||||
|
if (!['accepted', 'tentative', 'declined'].includes(status)) return null;
|
||||||
|
// Must reference a calendar event coordinate.
|
||||||
|
if (!eventCoord.startsWith(`${CALENDAR_EVENT_KIND}:`)) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
identifier: d,
|
||||||
|
eventCoord,
|
||||||
|
status,
|
||||||
|
note: event.content?.slice(0, MAX_DESCRIPTION_LEN) || undefined,
|
||||||
|
createdAt: event.created_at,
|
||||||
|
event,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Main spot categories (mutually exclusive - user picks ONE)
|
// Main spot categories (mutually exclusive - user picks ONE)
|
||||||
export const SPOT_CATEGORIES = {
|
export const SPOT_CATEGORIES = {
|
||||||
street: { label: 'Street', emoji: '🛣️', description: 'Random found spots, urban terrain' },
|
street: { label: 'Street', emoji: '🛣️', description: 'Random found spots, urban terrain' },
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,12 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ZapButton } from '@/components/ZapButton';
|
import { ZapButton } from '@/components/ZapButton';
|
||||||
|
import { RsvpButtons } from '@/components/RsvpButtons';
|
||||||
|
import { AttendeeStack } from '@/components/AttendeeStack';
|
||||||
import { CommentsSection } from '@/components/comments/CommentsSection';
|
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 { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
import { EVENT_TYPE_LABELS, SKATE_CREW_KIND, SKATE_MUSICIAN_KIND } from '@/lib/types';
|
import { EVENT_TYPE_LABELS, SKATE_CREW_KIND, SKATE_MUSICIAN_KIND } from '@/lib/types';
|
||||||
|
|
@ -101,6 +104,9 @@ export default function EventDetailPage() {
|
||||||
return { lat: latitude, lng: longitude };
|
return { lat: latitude, lng: longitude };
|
||||||
}, [event?.geohash]);
|
}, [event?.geohash]);
|
||||||
|
|
||||||
|
// NIP-52 RSVPs for this event (counts + attendee avatars).
|
||||||
|
const rsvpSummary = useEventRsvps(event ? eventCoord(event) : undefined);
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
|
|
@ -361,6 +367,40 @@ export default function EventDetailPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* RSVP / Attendance */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Users className="h-5 w-5 text-primary" />
|
||||||
|
{isPast ? 'Who came' : 'Who\u2019s going'}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{!isPast && <RsvpButtons event={event} />}
|
||||||
|
|
||||||
|
{rsvpSummary.goingCount > 0 ? (
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-black uppercase tracking-widest text-muted-foreground">
|
||||||
|
Going · {rsvpSummary.goingCount}
|
||||||
|
</span>
|
||||||
|
{rsvpSummary.tentativeCount > 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{rsvpSummary.tentativeCount} interested
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<AttendeeStack pubkeys={rsvpSummary.going} max={8} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
!rsvpSummary.isLoading &&
|
||||||
|
isPast && (
|
||||||
|
<p className="text-sm text-muted-foreground">No RSVPs were recorded.</p>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Map (if has location) */}
|
{/* Map (if has location) */}
|
||||||
{coordinates && (
|
{coordinates && (
|
||||||
<Card>
|
<Card>
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,50 @@ const ACCENT = {
|
||||||
|
|
||||||
type MainView = 'events' | 'tours';
|
type MainView = 'events' | 'tours';
|
||||||
|
|
||||||
|
// ─── Date window filter (skaters plan by weekend) ─────────────────────────────
|
||||||
|
|
||||||
|
type DateWindow = 'all' | 'today' | 'weekend' | 'week' | 'month';
|
||||||
|
|
||||||
|
const DATE_WINDOW_LABELS: Record<Exclude<DateWindow, 'all'>, string> = {
|
||||||
|
today: 'Today',
|
||||||
|
weekend: 'This Weekend',
|
||||||
|
week: 'This Week',
|
||||||
|
month: 'This Month',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns [startSec, endSec) for a date window, in local time. `weekend` covers
|
||||||
|
* the upcoming Sat+Sun (or the current weekend if it's already Sat/Sun).
|
||||||
|
*/
|
||||||
|
function dateWindowRange(window: Exclude<DateWindow, 'all'>): [number, number] {
|
||||||
|
const now = new Date();
|
||||||
|
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
const sec = (d: Date) => Math.floor(d.getTime() / 1000);
|
||||||
|
const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
|
||||||
|
|
||||||
|
switch (window) {
|
||||||
|
case 'today':
|
||||||
|
return [sec(startOfToday), sec(addDays(startOfToday, 1))];
|
||||||
|
case 'weekend': {
|
||||||
|
// day: 0=Sun … 6=Sat. Find the coming Saturday (or today if Sat/Sun).
|
||||||
|
const day = startOfToday.getDay();
|
||||||
|
let satOffset: number;
|
||||||
|
if (day === 6) satOffset = 0; // Saturday
|
||||||
|
else if (day === 0) satOffset = -1; // Sunday → weekend started yesterday
|
||||||
|
else satOffset = 6 - day; // upcoming Saturday
|
||||||
|
const sat = addDays(startOfToday, satOffset);
|
||||||
|
const end = addDays(sat, 2); // Sat 00:00 → Mon 00:00
|
||||||
|
// Never start the window in the past.
|
||||||
|
const start = sec(sat) < sec(startOfToday) ? sec(startOfToday) : sec(sat);
|
||||||
|
return [start, sec(end)];
|
||||||
|
}
|
||||||
|
case 'week':
|
||||||
|
return [sec(startOfToday), sec(addDays(startOfToday, 7))];
|
||||||
|
case 'month':
|
||||||
|
return [sec(startOfToday), sec(addDays(startOfToday, 30))];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── FAB — create event / tour ────────────────────────────────────────────────
|
// ─── FAB — create event / tour ────────────────────────────────────────────────
|
||||||
|
|
||||||
function CreateFAB({ mainView }: { mainView: MainView }) {
|
function CreateFAB({ mainView }: { mainView: MainView }) {
|
||||||
|
|
@ -735,6 +779,7 @@ export default function EventsPage() {
|
||||||
const [activeTab, setActiveTab] = useState<'upcoming' | 'past'>('upcoming');
|
const [activeTab, setActiveTab] = useState<'upcoming' | 'past'>('upcoming');
|
||||||
const [trustFilter, setTrustFilter] = useState<TrustFilterLevel>(smartDefault);
|
const [trustFilter, setTrustFilter] = useState<TrustFilterLevel>(smartDefault);
|
||||||
const [sortBy, setSortBy] = useState<SortOption>('recent');
|
const [sortBy, setSortBy] = useState<SortOption>('recent');
|
||||||
|
const [dateWindow, setDateWindow] = useState<DateWindow>('all');
|
||||||
|
|
||||||
// ── Tour filters ─────────────────────────────────────────────────────────────
|
// ── Tour filters ─────────────────────────────────────────────────────────────
|
||||||
const [tourSearchQuery, setTourSearchQuery] = useState('');
|
const [tourSearchQuery, setTourSearchQuery] = useState('');
|
||||||
|
|
@ -811,6 +856,12 @@ export default function EventsPage() {
|
||||||
let upcoming = filtered.filter((e) => e.start >= now);
|
let upcoming = filtered.filter((e) => e.start >= now);
|
||||||
let past = filtered.filter((e) => e.start < now);
|
let past = filtered.filter((e) => e.start < now);
|
||||||
|
|
||||||
|
// Date window applies to upcoming events only.
|
||||||
|
if (dateWindow !== 'all') {
|
||||||
|
const [ws, we] = dateWindowRange(dateWindow);
|
||||||
|
upcoming = upcoming.filter((e) => e.start >= ws && e.start < we);
|
||||||
|
}
|
||||||
|
|
||||||
const sortFn = (a: typeof events[0], b: typeof events[0]) => {
|
const sortFn = (a: typeof events[0], b: typeof events[0]) => {
|
||||||
if (sortBy === 'trust') {
|
if (sortBy === 'trust') {
|
||||||
const diff = (getTrustScore(b.pubkey) || 0) - (getTrustScore(a.pubkey) || 0);
|
const diff = (getTrustScore(b.pubkey) || 0) - (getTrustScore(a.pubkey) || 0);
|
||||||
|
|
@ -829,7 +880,7 @@ export default function EventsPage() {
|
||||||
past.sort((a, b) => sortBy === 'recent' ? b.start - a.start : sortFn(a, b));
|
past.sort((a, b) => sortBy === 'recent' ? b.start - a.start : sortFn(a, b));
|
||||||
|
|
||||||
return { upcoming, past };
|
return { upcoming, past };
|
||||||
}, [events, searchQuery, locationQuery, selectedTypes, trustFilter, trustScores, sortBy, now, allComments]);
|
}, [events, searchQuery, locationQuery, selectedTypes, trustFilter, trustScores, sortBy, dateWindow, now, allComments]);
|
||||||
|
|
||||||
// ── Filter tours ──────────────────────────────────────────────────────────────
|
// ── Filter tours ──────────────────────────────────────────────────────────────
|
||||||
const filteredTours = useMemo(() => {
|
const filteredTours = useMemo(() => {
|
||||||
|
|
@ -885,7 +936,8 @@ export default function EventsPage() {
|
||||||
const activeEventFilterCount =
|
const activeEventFilterCount =
|
||||||
selectedTypes.length +
|
selectedTypes.length +
|
||||||
(trustFilter !== 'all' ? 1 : 0) +
|
(trustFilter !== 'all' ? 1 : 0) +
|
||||||
(sortBy !== 'recent' ? 1 : 0);
|
(sortBy !== 'recent' ? 1 : 0) +
|
||||||
|
(dateWindow !== 'all' ? 1 : 0);
|
||||||
|
|
||||||
const activeTourFilterCount =
|
const activeTourFilterCount =
|
||||||
(tourCategory !== 'all' ? 1 : 0) +
|
(tourCategory !== 'all' ? 1 : 0) +
|
||||||
|
|
@ -901,6 +953,7 @@ export default function EventsPage() {
|
||||||
setSelectedTypes([]);
|
setSelectedTypes([]);
|
||||||
setTrustFilter('all');
|
setTrustFilter('all');
|
||||||
setSortBy('recent');
|
setSortBy('recent');
|
||||||
|
setDateWindow('all');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearTourFilters = () => {
|
const handleClearTourFilters = () => {
|
||||||
|
|
@ -933,7 +986,7 @@ export default function EventsPage() {
|
||||||
const handleSearchChange = mainView === 'events' ? setSearchQuery : setTourSearchQuery;
|
const handleSearchChange = mainView === 'events' ? setSearchQuery : setTourSearchQuery;
|
||||||
const handleLocationChange = mainView === 'events' ? setLocationQuery : setTourLocationQuery;
|
const handleLocationChange = mainView === 'events' ? setLocationQuery : setTourLocationQuery;
|
||||||
|
|
||||||
const hasEventFilters = !!(searchQuery || locationQuery || selectedTypes.length > 0 || trustFilter !== 'all' || sortBy !== 'recent');
|
const hasEventFilters = !!(searchQuery || locationQuery || selectedTypes.length > 0 || trustFilter !== 'all' || sortBy !== 'recent' || dateWindow !== 'all');
|
||||||
const hasTourFilters = !!(tourSearchQuery || tourLocationQuery || tourCategory !== 'all' || tourSkillLevel !== 'all' || tourTrustFilter !== 'all' || tourSortBy !== 'recent');
|
const hasTourFilters = !!(tourSearchQuery || tourLocationQuery || tourCategory !== 'all' || tourSkillLevel !== 'all' || tourTrustFilter !== 'all' || tourSortBy !== 'recent');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -989,6 +1042,31 @@ export default function EventsPage() {
|
||||||
totalCount={totalCount}
|
totalCount={totalCount}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* ── Quick date-window chips (events · upcoming) ── */}
|
||||||
|
{mainView === 'events' && activeTab === 'upcoming' && (
|
||||||
|
<div className="mt-4 flex items-center gap-2 overflow-x-auto pb-1 scrollbar-none">
|
||||||
|
{(['all', 'today', 'weekend', 'week', 'month'] as DateWindow[]).map((w) => {
|
||||||
|
const active = dateWindow === w;
|
||||||
|
const label = w === 'all' ? 'Anytime' : DATE_WINDOW_LABELS[w];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={w}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDateWindow(w)}
|
||||||
|
className={cn(
|
||||||
|
'flex-shrink-0 rounded-full border px-3 py-1.5 text-xs font-bold uppercase tracking-wide transition-all active:scale-95',
|
||||||
|
active
|
||||||
|
? 'border-[#ccff00] bg-[#ccff00] text-black'
|
||||||
|
: 'border-white/10 bg-white/5 text-gray-400 hover:border-white/20 hover:text-white',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Results ── */}
|
{/* ── Results ── */}
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue