Host tools: attendee list w/ trust, event updates, edit-alerts, Hosting tab
- AttendeeList: tabbed Going/Interested with faces + names + a "who you trust" signal (reuses batch trust scores); replaces the plain avatar stack - Event Updates (kind 7103): usePostEventUpdate + useEventUpdates; host-only composer with update/reschedule/relocation/cancelled categories that p-tags every RSVPer so they get notified; public feed on the event page - EditEventPage: auto-post a reschedule/relocation update to notify attendees when the host changes time or location - Activity bell: new 'event-update' notification type for RSVPers - EventsPage: "Hosting" tab (logged-in) with a HostingDashboard listing the user's upcoming/past events, live RSVP counts, and quick edit/update actions - Document Event Update kind 7103 in NIP.md Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
7439b980bb
commit
78f4a6d09a
11 changed files with 819 additions and 31 deletions
26
NIP.md
26
NIP.md
|
|
@ -368,6 +368,32 @@ Attendance uses the standard **NIP-52 RSVP** (kind `31925`). A skater responds t
|
|||
|
||||
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>"] }`.
|
||||
|
||||
### Event Updates (Host broadcasts)
|
||||
|
||||
Hosts broadcast changes ("rain — moved to Sunday", "meet at the back ledge") with a **SkateSpots-native Event Update** (kind `7103`, regular event) pinned to a calendar event. Every current RSVPer is `p`-tagged so they get a notification; anyone viewing the event also sees the update thread.
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 7103,
|
||||
"content": "Rain — moved to Sunday 2pm. Meet at the back ledge.",
|
||||
"tags": [
|
||||
["a", "31923:<event-author-pubkey>:<event-d>", "", ""],
|
||||
["e", "<event-id>"],
|
||||
["update-kind", "reschedule"],
|
||||
["t", "skateboarding"],
|
||||
["p", "<rsvper-pubkey>"],
|
||||
["p", "<rsvper-pubkey>"],
|
||||
["alt", "Update to a skate event"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `update-kind` is one of `update` (default), `reschedule`, `relocation`, or `cancelled` — used for iconography/emphasis.
|
||||
- Only the event's own author should post updates (clients enforce this).
|
||||
- When a host edits an event's **time or location**, the client auto-publishes a `reschedule`/`relocation` update to notify RSVPers.
|
||||
|
||||
Clients query an event's updates with `{ kinds: [7103], "#a": ["31923:<host>:<d>"] }`.
|
||||
|
||||
## Social Interaction
|
||||
|
||||
Comments and reactions to skate spots and events use standard Nostr kinds:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Link } from 'react-router-dom';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { Heart, Repeat2, Zap, AtSign, MessageCircle, CalendarCheck } from 'lucide-react';
|
||||
import { Heart, Repeat2, Zap, AtSign, MessageCircle, CalendarCheck, Megaphone } from 'lucide-react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
|
|
@ -22,6 +22,7 @@ const TYPE_META: Record<
|
|||
mention: { icon: AtSign, color: 'text-sky-400', ring: 'bg-sky-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' },
|
||||
'event-update': { icon: Megaphone, color: 'text-[#ccff00]', ring: 'bg-[#ccff00]/15' },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -58,7 +59,10 @@ export function ActivityNotificationCard({
|
|||
});
|
||||
|
||||
const snippet =
|
||||
(notification.type === 'reply' || notification.type === 'mention') && notification.content
|
||||
(notification.type === 'reply' ||
|
||||
notification.type === 'mention' ||
|
||||
notification.type === 'event-update') &&
|
||||
notification.content
|
||||
? notification.content.slice(0, 120)
|
||||
: null;
|
||||
|
||||
|
|
|
|||
150
src/components/AttendeeList.tsx
Normal file
150
src/components/AttendeeList.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { ShieldCheck, Users } from 'lucide-react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const TRUSTED_THRESHOLD = 60;
|
||||
|
||||
function AttendeeRow({ pubkey, trusted }: { pubkey: string; trusted: boolean }) {
|
||||
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 inner = (
|
||||
<div className="flex items-center gap-3 rounded-lg px-2 py-1.5 transition-colors hover:bg-white/5">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={pic} alt={name} />
|
||||
<AvatarFallback className="text-[10px] font-bold">
|
||||
{name.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-gray-200">{name}</span>
|
||||
{trusted && (
|
||||
<span
|
||||
className="flex items-center gap-1 rounded-full bg-[#ccff00]/15 px-2 py-0.5 text-[10px] font-black uppercase tracking-wide text-[#ccff00]"
|
||||
title="Someone you follow or a trusted mapper"
|
||||
>
|
||||
<ShieldCheck className="h-3 w-3" />
|
||||
Trusted
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!npub) return inner;
|
||||
return <Link to={`/${npub}`}>{inner}</Link>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full attendee list for a host: tabbed Going / Interested, each row showing a
|
||||
* face + name, plus a "trust signal" summarizing how many of the Going crowd
|
||||
* are people the viewer follows or trusted mappers (skate events live or die on
|
||||
* whether real locals show up).
|
||||
*/
|
||||
export function AttendeeList({
|
||||
going,
|
||||
tentative,
|
||||
className,
|
||||
}: {
|
||||
going: string[];
|
||||
tentative: string[];
|
||||
className?: string;
|
||||
}) {
|
||||
const [tab, setTab] = useState<'going' | 'tentative'>('going');
|
||||
|
||||
const allPubkeys = useMemo(() => [...new Set([...going, ...tentative])], [going, tentative]);
|
||||
const { data: trustScores } = useBatchTrustScores(allPubkeys);
|
||||
|
||||
const trustedGoing = useMemo(
|
||||
() => going.filter((pk) => (trustScores?.get(pk) ?? 0) >= TRUSTED_THRESHOLD).length,
|
||||
[going, trustScores],
|
||||
);
|
||||
|
||||
if (going.length === 0 && tentative.length === 0) {
|
||||
return (
|
||||
<p className={cn('text-sm text-muted-foreground', className)}>
|
||||
No RSVPs yet — be the first to say you're going.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const list = tab === 'going' ? going : tentative;
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-3', className)}>
|
||||
{/* Trust signal */}
|
||||
{going.length > 0 && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-[#ccff00]/20 bg-[#ccff00]/5 px-3 py-2">
|
||||
<Users className="h-4 w-4 flex-shrink-0 text-[#ccff00]" />
|
||||
<p className="text-xs text-gray-300">
|
||||
<span className="font-black text-white">{going.length}</span> going
|
||||
{trustedGoing > 0 && (
|
||||
<>
|
||||
{' · '}
|
||||
<span className="font-black text-[#ccff00]">{trustedGoing}</span> you trust
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('going')}
|
||||
className={cn(
|
||||
'flex-1 rounded-lg py-1.5 text-xs font-black uppercase tracking-wide transition-all',
|
||||
tab === 'going'
|
||||
? 'bg-green-500 text-black'
|
||||
: 'bg-white/5 text-gray-400 hover:text-white',
|
||||
)}
|
||||
>
|
||||
Going ({going.length})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('tentative')}
|
||||
className={cn(
|
||||
'flex-1 rounded-lg py-1.5 text-xs font-black uppercase tracking-wide transition-all',
|
||||
tab === 'tentative'
|
||||
? 'bg-[#ccff00] text-black'
|
||||
: 'bg-white/5 text-gray-400 hover:text-white',
|
||||
)}
|
||||
>
|
||||
Interested ({tentative.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
{list.length > 0 ? (
|
||||
<div className="max-h-72 space-y-0.5 overflow-y-auto pr-1">
|
||||
{list.map((pk) => (
|
||||
<AttendeeRow
|
||||
key={pk}
|
||||
pubkey={pk}
|
||||
trusted={(trustScores?.get(pk) ?? 0) >= TRUSTED_THRESHOLD}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
{tab === 'going' ? 'Nobody has confirmed yet.' : 'Nobody marked interested yet.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
201
src/components/EventUpdates.tsx
Normal file
201
src/components/EventUpdates.tsx
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { useState } from 'react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { Megaphone, Loader2, Send, CalendarClock, MapPin, XCircle, Info } from 'lucide-react';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useEventUpdates, usePostEventUpdate } from '@/hooks/useEventUpdates';
|
||||
import { useEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { EventUpdate, EventUpdateKind, SkateEvent } from '@/lib/types';
|
||||
|
||||
const UPDATE_KIND_META: Record<
|
||||
EventUpdateKind,
|
||||
{ icon: typeof Info; label: string; color: string; ring: string }
|
||||
> = {
|
||||
update: { icon: Info, label: 'Update', color: 'text-sky-400', ring: 'bg-sky-500/15' },
|
||||
reschedule: { icon: CalendarClock, label: 'New time', color: 'text-amber-400', ring: 'bg-amber-500/15' },
|
||||
relocation: { icon: MapPin, label: 'New spot', color: 'text-[#ff0040]', ring: 'bg-[#ff0040]/15' },
|
||||
cancelled: { icon: XCircle, label: 'Cancelled', color: 'text-red-400', ring: 'bg-red-500/15' },
|
||||
};
|
||||
|
||||
function UpdateItem({ update }: { update: EventUpdate }) {
|
||||
const author = useAuthor(update.pubkey);
|
||||
const name = author.data?.metadata?.name ?? genUserName(update.pubkey);
|
||||
const pic = author.data?.metadata?.picture;
|
||||
const meta = UPDATE_KIND_META[update.updateKind];
|
||||
const Icon = meta.icon;
|
||||
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-shrink-0">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={pic} alt={name} />
|
||||
<AvatarFallback className="text-[10px] font-bold">
|
||||
{name.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -bottom-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full ring-2 ring-background',
|
||||
meta.ring,
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('h-2.5 w-2.5', meta.color)} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-bold text-white">{name}</span>
|
||||
{update.updateKind !== 'update' && (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-1.5 py-0.5 text-[9px] font-black uppercase tracking-wide',
|
||||
meta.ring,
|
||||
meta.color,
|
||||
)}
|
||||
>
|
||||
{meta.label}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[11px] text-gray-600">
|
||||
{formatDistanceToNow(new Date(update.createdAt * 1000), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 whitespace-pre-wrap break-words text-sm text-gray-300">
|
||||
{update.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Host update composer (only for the event author) + a public feed of updates.
|
||||
* Posting p-tags every RSVPer so they get a notification.
|
||||
*/
|
||||
export function EventUpdates({ event }: { event: SkateEvent }) {
|
||||
const { user } = useCurrentUser();
|
||||
const { toast } = useToast();
|
||||
const coord = eventCoord(event);
|
||||
const { data: updates, isLoading } = useEventUpdates(coord);
|
||||
const { going, tentative } = useEventRsvps(coord);
|
||||
const { mutateAsync: post, isPending } = usePostEventUpdate();
|
||||
|
||||
const [message, setMessage] = useState('');
|
||||
const [kind, setKind] = useState<EventUpdateKind>('update');
|
||||
|
||||
const isHost = user?.pubkey === event.pubkey;
|
||||
|
||||
const handlePost = async () => {
|
||||
const text = message.trim();
|
||||
if (!text) return;
|
||||
try {
|
||||
await post({
|
||||
event,
|
||||
message: text,
|
||||
updateKind: kind,
|
||||
notifyPubkeys: [...going, ...tentative],
|
||||
});
|
||||
setMessage('');
|
||||
setKind('update');
|
||||
const reach = new Set([...going, ...tentative]).size;
|
||||
toast({
|
||||
title: 'Update posted',
|
||||
description: reach > 0 ? `${reach} ${reach === 1 ? 'person' : 'people'} notified.` : 'Everyone will see it on the event.',
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Could not post update',
|
||||
description: err instanceof Error ? err.message : 'Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const hasUpdates = (updates?.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Composer — host only */}
|
||||
{isHost && (
|
||||
<div className="rounded-xl border border-[#ccff00]/25 bg-[#ccff00]/[0.04] p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-black uppercase tracking-wide text-[#ccff00]">
|
||||
<Megaphone className="h-4 w-4" />
|
||||
Post an update
|
||||
</div>
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{(Object.keys(UPDATE_KIND_META) as EventUpdateKind[]).map((k) => {
|
||||
const m = UPDATE_KIND_META[k];
|
||||
const Icon = m.icon;
|
||||
const active = kind === k;
|
||||
return (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
onClick={() => setKind(k)}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide transition-all active:scale-95',
|
||||
active
|
||||
? cn('border-transparent', m.ring, m.color)
|
||||
: 'border-white/10 bg-white/5 text-gray-500 hover:text-white',
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
{m.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Rain — moved to Sunday 2pm. Meet at the back ledge…"
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
className="resize-none border-white/10 bg-black/40 text-sm"
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<span className="text-[11px] text-gray-500">
|
||||
{going.length + tentative.length > 0
|
||||
? `Notifies ${new Set([...going, ...tentative]).size} RSVP${new Set([...going, ...tentative]).size === 1 ? '' : "'s"}`
|
||||
: 'Visible to everyone on this event'}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handlePost}
|
||||
disabled={isPending || !message.trim()}
|
||||
className="gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
||||
>
|
||||
{isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />}
|
||||
Post
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feed */}
|
||||
{isLoading && !hasUpdates ? (
|
||||
<div className="flex items-center gap-2 py-4 text-sm text-gray-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Loading updates…
|
||||
</div>
|
||||
) : hasUpdates ? (
|
||||
<div className="space-y-4">
|
||||
{updates!.map((u) => (
|
||||
<UpdateItem key={u.id} update={u} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
!isHost && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No updates from the host yet. RSVP and you'll be notified if anything changes.
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
171
src/components/HostingDashboard.tsx
Normal file
171
src/components/HostingDashboard.tsx
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Calendar, Users, Pencil, Megaphone, Plus, CalendarClock } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useSkateEvents } from '@/hooks/useSkateEvents';
|
||||
import { useEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { SkateEvent } from '@/lib/types';
|
||||
|
||||
function formatWhen(ts: number): string {
|
||||
return new Date(ts * 1000).toLocaleDateString(undefined, {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
/** A single hosted-event row with live RSVP counts + quick actions. */
|
||||
function HostedEventRow({ event, isPast }: { event: SkateEvent; isPast: boolean }) {
|
||||
const { goingCount, tentativeCount } = useEventRsvps(eventCoord(event));
|
||||
const to = `/event/${event.pubkey}/${event.identifier}`;
|
||||
|
||||
return (
|
||||
<Card className={cn('border-white/10 transition-colors hover:border-[#ccff00]/40', isPast && 'opacity-70')}>
|
||||
<CardContent className="flex items-center gap-4 p-4">
|
||||
{/* Date chip */}
|
||||
<div className="flex-shrink-0 w-14 text-center">
|
||||
<div className="rounded-lg border-2 border-[#ccff00]/40 bg-[#ccff00]/10 p-1.5">
|
||||
<div className="text-[10px] font-black uppercase text-[#ccff00]">
|
||||
{new Date(event.start * 1000).toLocaleDateString(undefined, { month: 'short' })}
|
||||
</div>
|
||||
<div className="text-xl font-black text-white leading-none">
|
||||
{new Date(event.start * 1000).getDate()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<Link to={to} className="min-w-0 flex-1">
|
||||
<h3 className="truncate font-bold uppercase tracking-wide text-white hover:text-[#ccff00]">
|
||||
{event.title}
|
||||
</h3>
|
||||
<p className="flex items-center gap-1 text-xs text-gray-500">
|
||||
<CalendarClock className="h-3 w-3" />
|
||||
{formatWhen(event.start)}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-3 text-xs">
|
||||
<span className="flex items-center gap-1 font-bold text-green-400">
|
||||
<Users className="h-3 w-3" />
|
||||
{goingCount} going
|
||||
</span>
|
||||
{tentativeCount > 0 && (
|
||||
<span className="text-[#ccff00]">{tentativeCount} interested</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Quick actions */}
|
||||
<div className="flex flex-shrink-0 items-center gap-1.5">
|
||||
<Link to={to} title="Post update">
|
||||
<Button size="icon" variant="ghost" className="h-8 w-8 text-gray-400 hover:text-[#ccff00]">
|
||||
<Megaphone className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to={`/edit-event/${event.pubkey}/${event.identifier}`} title="Edit">
|
||||
<Button size="icon" variant="ghost" className="h-8 w-8 text-gray-400 hover:text-white">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Hosting" dashboard — the events the logged-in user created, split into
|
||||
* upcoming and past, each with live RSVP counts and quick edit/update actions.
|
||||
* The host's home base.
|
||||
*/
|
||||
export function HostingDashboard() {
|
||||
const { user } = useCurrentUser();
|
||||
const { data: events, isLoading } = useSkateEvents(200);
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const mine = useMemo(() => {
|
||||
if (!user || !events) return { upcoming: [], past: [] };
|
||||
const owned = events.filter((e) => e.pubkey === user.pubkey);
|
||||
return {
|
||||
upcoming: owned.filter((e) => e.start >= now).sort((a, b) => a.start - b.start),
|
||||
past: owned.filter((e) => e.start < now).sort((a, b) => b.start - a.start),
|
||||
};
|
||||
}, [user, events, now]);
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-12 text-center">
|
||||
<Calendar className="mx-auto mb-3 h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Log in to see the events you're hosting.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<Skeleton key={i} className="h-20 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const total = mine.upcoming.length + mine.past.length;
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-12 text-center">
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-[#ccff00]/10">
|
||||
<Calendar className="h-7 w-7 text-[#ccff00]" />
|
||||
</div>
|
||||
<h3 className="mb-1 font-black uppercase tracking-wide text-white">You're not hosting anything yet</h3>
|
||||
<p className="mx-auto mb-5 max-w-xs text-sm text-muted-foreground">
|
||||
Run a jam, comp or session. Create an event and skaters can RSVP, get updates, and roll through.
|
||||
</p>
|
||||
<Link to="/create-event">
|
||||
<Button className="gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90">
|
||||
<Plus className="h-4 w-4" />
|
||||
Create an event
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{mine.upcoming.length > 0 && (
|
||||
<div className="space-y-2.5">
|
||||
<h2 className="text-xs font-black uppercase tracking-widest text-[#ccff00]">
|
||||
Upcoming · {mine.upcoming.length}
|
||||
</h2>
|
||||
{mine.upcoming.map((e) => (
|
||||
<HostedEventRow key={e.id} event={e} isPast={false} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mine.past.length > 0 && (
|
||||
<div className="space-y-2.5">
|
||||
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500">
|
||||
Past · {mine.past.length}
|
||||
</h2>
|
||||
{mine.past.map((e) => (
|
||||
<HostedEventRow key={e.id} event={e} isPast />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|||
* load minimal (a single filter) per the project's query-efficiency rules.
|
||||
*/
|
||||
|
||||
export type ActivityType = 'reaction' | 'repost' | 'zap' | 'mention' | 'reply' | 'rsvp';
|
||||
export type ActivityType = 'reaction' | 'repost' | 'zap' | 'mention' | 'reply' | 'rsvp' | 'event-update';
|
||||
|
||||
export interface ActivityNotification {
|
||||
id: string;
|
||||
|
|
@ -42,6 +42,7 @@ const ZAP_RECEIPT_KIND = 9735;
|
|||
const TEXT_NOTE_KIND = 1;
|
||||
const COMMENT_KIND = 1111;
|
||||
const RSVP_KIND = 31925;
|
||||
const EVENT_UPDATE_KIND = 7103;
|
||||
|
||||
/** Parse the sats amount out of a zap receipt (9735) if we can. */
|
||||
function parseZapAmount(event: NostrEvent): number | undefined {
|
||||
|
|
@ -106,6 +107,7 @@ export function useActivityNotifications() {
|
|||
TEXT_NOTE_KIND,
|
||||
COMMENT_KIND,
|
||||
RSVP_KIND,
|
||||
EVENT_UPDATE_KIND,
|
||||
],
|
||||
'#p': [pubkey],
|
||||
limit: 120,
|
||||
|
|
@ -189,6 +191,17 @@ export function useActivityNotifications() {
|
|||
targetEventId,
|
||||
rsvpStatus: status,
|
||||
});
|
||||
} else if (event.kind === EVENT_UPDATE_KIND) {
|
||||
// A host posted an update to an event I RSVP'd to (I'm p-tagged).
|
||||
out.push({
|
||||
id: event.id,
|
||||
type: 'event-update',
|
||||
pubkey: event.pubkey,
|
||||
createdAt: event.created_at,
|
||||
event,
|
||||
content: event.content,
|
||||
targetEventId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -223,6 +236,8 @@ export function activityVerb(n: ActivityNotification): string {
|
|||
: n.rsvpStatus === 'tentative'
|
||||
? 'is interested in your event'
|
||||
: "can't make your event";
|
||||
case 'event-update':
|
||||
return 'posted an event update';
|
||||
default:
|
||||
return 'interacted with you';
|
||||
}
|
||||
|
|
|
|||
97
src/hooks/useEventUpdates.ts
Normal file
97
src/hooks/useEventUpdates.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||
import {
|
||||
EVENT_UPDATE_KIND,
|
||||
parseEventUpdate,
|
||||
type EventUpdate,
|
||||
type EventUpdateKind,
|
||||
type SkateEvent,
|
||||
} from '@/lib/types';
|
||||
import { eventCoord } from '@/hooks/useEventRsvps';
|
||||
|
||||
/**
|
||||
* Host "Event Updates" (kind 7103) — a public broadcast pinned to a calendar
|
||||
* event. Hosts use it to say "rain, moved to Sunday" / "meet at the back ledge".
|
||||
* Everyone who RSVP'd is p-tagged so they get a notification; anyone browsing
|
||||
* the event page also sees the update thread.
|
||||
*/
|
||||
|
||||
export function useEventUpdates(coord: string | undefined) {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['event-updates', coord, getQueryKeySuffix()],
|
||||
enabled: !!coord,
|
||||
staleTime: 30_000,
|
||||
queryFn: async (c) => {
|
||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
|
||||
const events = await querySkateContent(
|
||||
[{ kinds: [EVENT_UPDATE_KIND], '#a': [coord!], limit: 100 }],
|
||||
{ signal },
|
||||
);
|
||||
|
||||
const updates: EventUpdate[] = [];
|
||||
for (const ev of events) {
|
||||
const u = parseEventUpdate(ev);
|
||||
if (u && u.eventCoord === coord) updates.push(u);
|
||||
}
|
||||
// Newest first.
|
||||
return updates.sort((a, b) => b.createdAt - a.createdAt);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a host update. Only the event's own author should call this (the UI
|
||||
* gates it). All `notifyPubkeys` (RSVPers) are p-tagged so they get pinged.
|
||||
*/
|
||||
export function usePostEventUpdate() {
|
||||
const { mutateAsync: publish, isPending } = useNostrPublish();
|
||||
const { user } = useCurrentUser();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (opts: {
|
||||
event: SkateEvent;
|
||||
message: string;
|
||||
updateKind?: EventUpdateKind;
|
||||
/** RSVPer pubkeys to notify (deduped, minus the host). */
|
||||
notifyPubkeys?: string[];
|
||||
}) => {
|
||||
if (!user) throw new Error('You must be logged in to post an update.');
|
||||
if (user.pubkey !== opts.event.pubkey) {
|
||||
throw new Error('Only the event host can post updates.');
|
||||
}
|
||||
|
||||
const coord = eventCoord(opts.event);
|
||||
const kind = opts.updateKind ?? 'update';
|
||||
|
||||
const notify = Array.from(
|
||||
new Set((opts.notifyPubkeys ?? []).filter((pk) => pk && pk !== user.pubkey)),
|
||||
);
|
||||
|
||||
const tags: string[][] = [
|
||||
['a', coord, '', ''],
|
||||
['e', opts.event.id],
|
||||
['update-kind', kind],
|
||||
['t', 'skateboarding'],
|
||||
['alt', `Update to a skate event: ${opts.event.title}`],
|
||||
...notify.map((pk) => ['p', pk]),
|
||||
];
|
||||
|
||||
return publish({
|
||||
kind: EVENT_UPDATE_KIND,
|
||||
content: opts.message.trim(),
|
||||
tags,
|
||||
});
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
const coord = eventCoord(variables.event);
|
||||
queryClient.invalidateQueries({ queryKey: ['event-updates', coord] });
|
||||
},
|
||||
});
|
||||
|
||||
return { ...mutation, isPending: isPending || mutation.isPending };
|
||||
}
|
||||
|
|
@ -47,6 +47,7 @@ export const SKATE_SPOT_KIND = 30818; // Addressable event - editable per pubkey
|
|||
export const SKATE_SHOP_KIND = 30017; // NIP-15 Stall (marketplace)
|
||||
export const CALENDAR_EVENT_KIND = 31923;
|
||||
export const CALENDAR_RSVP_KIND = 31925; // NIP-52 Calendar Event RSVP (accepted/tentative/declined)
|
||||
export const EVENT_UPDATE_KIND = 7103; // Regular event - host broadcast/update pinned to a calendar event. SkateSpots-native.
|
||||
export const SKATE_SKATER_KIND = 35000; // Skater profile — the app-native "I skate here" identity
|
||||
export const SKATE_COACH_KIND = 35001;
|
||||
export const SKATE_FILMER_KIND = 35002; // Skate filmer (video) profiles
|
||||
|
|
@ -891,6 +892,55 @@ export function parseEventRsvp(event: NostrEvent): EventRsvp | null {
|
|||
};
|
||||
}
|
||||
|
||||
// ─── Event Update (kind 7103) — host broadcast pinned to a calendar event ────
|
||||
|
||||
export type EventUpdateKind = 'update' | 'reschedule' | 'relocation' | 'cancelled';
|
||||
|
||||
export interface EventUpdate {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
/** Coordinate of the calendar event ("31923:pubkey:d"). */
|
||||
eventCoord: string;
|
||||
/** The calendar event's raw id, if referenced. */
|
||||
eventId?: string;
|
||||
message: string;
|
||||
/** Category of update, for iconography / emphasis. */
|
||||
updateKind: EventUpdateKind;
|
||||
createdAt: number;
|
||||
event: NostrEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a host Event Update (kind 7103). Requires an `a` coordinate to a
|
||||
* calendar event and non-empty content.
|
||||
*/
|
||||
export function parseEventUpdate(event: NostrEvent): EventUpdate | null {
|
||||
if (event.kind !== EVENT_UPDATE_KIND) return null;
|
||||
|
||||
const eventCoord = event.tags.find(([name]) => name === 'a')?.[1];
|
||||
if (!eventCoord || !eventCoord.startsWith(`${CALENDAR_EVENT_KIND}:`)) return null;
|
||||
|
||||
const message = event.content?.trim();
|
||||
if (!message) return null;
|
||||
|
||||
const rawKind = event.tags.find(([name]) => name === 'update-kind')?.[1];
|
||||
const updateKind: EventUpdateKind =
|
||||
rawKind === 'reschedule' || rawKind === 'relocation' || rawKind === 'cancelled'
|
||||
? rawKind
|
||||
: 'update';
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
eventCoord,
|
||||
eventId: event.tags.find(([name]) => name === 'e')?.[1],
|
||||
message: message.slice(0, MAX_DESCRIPTION_LEN),
|
||||
updateKind,
|
||||
createdAt: event.created_at,
|
||||
event,
|
||||
};
|
||||
}
|
||||
|
||||
// Main spot categories (mutually exclusive - user picks ONE)
|
||||
export const SPOT_CATEGORIES = {
|
||||
street: { label: 'Street', emoji: '🛣️', description: 'Random found spots, urban terrain' },
|
||||
|
|
|
|||
|
|
@ -15,8 +15,10 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useSkateEvent } from '@/hooks/useSkateEvents';
|
||||
import { useEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
|
||||
import { usePostEventUpdate } from '@/hooks/useEventUpdates';
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { CALENDAR_EVENT_KIND, EVENT_TYPE_LABELS } from '@/lib/types';
|
||||
import { CALENDAR_EVENT_KIND, EVENT_TYPE_LABELS, type EventUpdateKind } from '@/lib/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Calendar,
|
||||
|
|
@ -78,6 +80,10 @@ export default function EditEventPage() {
|
|||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||||
const { toast } = useToast();
|
||||
|
||||
// RSVP audience + host-update poster, so edits can auto-notify attendees.
|
||||
const rsvpSummary = useEventRsvps(event ? eventCoord(event) : undefined);
|
||||
const { mutateAsync: postUpdate } = usePostEventUpdate();
|
||||
|
||||
// Form state
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
|
|
@ -283,15 +289,49 @@ export default function EditEventPage() {
|
|||
tags.push(['image', image]);
|
||||
}
|
||||
|
||||
// Detect the changes attendees care about (time / location) BEFORE we
|
||||
// overwrite the event, so we can auto-notify RSVPers.
|
||||
const newLocation = location.trim();
|
||||
const timeChanged = startTimestamp !== event.start || endTimestamp !== event.end;
|
||||
const locationChanged = newLocation !== (event.location || '');
|
||||
|
||||
await createEvent({
|
||||
kind: CALENDAR_EVENT_KIND,
|
||||
content: description.trim(),
|
||||
tags,
|
||||
});
|
||||
|
||||
// Auto-alert attendees when the when/where changed.
|
||||
const audience = [...rsvpSummary.going, ...rsvpSummary.tentative];
|
||||
if ((timeChanged || locationChanged) && audience.length > 0) {
|
||||
const updateKind: EventUpdateKind = timeChanged ? 'reschedule' : 'relocation';
|
||||
const parts: string[] = [];
|
||||
if (timeChanged) {
|
||||
parts.push(
|
||||
`New time: ${startDateTime.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })} at ${startDateTime.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}.`,
|
||||
);
|
||||
}
|
||||
if (locationChanged) {
|
||||
parts.push(newLocation ? `New spot: ${newLocation}.` : 'Location updated.');
|
||||
}
|
||||
try {
|
||||
await postUpdate({
|
||||
event: { ...event, start: startTimestamp, end: endTimestamp, location: newLocation },
|
||||
message: `${parts.join(' ')} — see the event for details.`,
|
||||
updateKind,
|
||||
notifyPubkeys: audience,
|
||||
});
|
||||
} catch {
|
||||
// Non-fatal: the edit still succeeded even if the alert didn't post.
|
||||
}
|
||||
}
|
||||
|
||||
const notified = (timeChanged || locationChanged) && audience.length > 0;
|
||||
toast({
|
||||
title: 'Event updated!',
|
||||
description: 'Your changes have been published to the network.',
|
||||
description: notified
|
||||
? `Changes published — ${new Set(audience).size} attendee${new Set(audience).size === 1 ? '' : 's'} notified.`
|
||||
: 'Your changes have been published to the network.',
|
||||
});
|
||||
|
||||
navigate(`/event/${user?.pubkey}/${event.identifier}`);
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { ZapButton } from '@/components/ZapButton';
|
||||
import { RsvpButtons } from '@/components/RsvpButtons';
|
||||
import { AttendeeStack } from '@/components/AttendeeStack';
|
||||
import { AttendeeList } from '@/components/AttendeeList';
|
||||
import { EventUpdates } from '@/components/EventUpdates';
|
||||
import { CommentsSection } from '@/components/comments/CommentsSection';
|
||||
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
||||
import { useSkateEvent } from '@/hooks/useSkateEvents';
|
||||
|
|
@ -32,6 +33,7 @@ import {
|
|||
CalendarPlus,
|
||||
Pencil,
|
||||
Music,
|
||||
Megaphone,
|
||||
} from 'lucide-react';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
|
||||
|
|
@ -333,6 +335,19 @@ export default function EventDetailPage() {
|
|||
</Card>
|
||||
)}
|
||||
|
||||
{/* Host Updates */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Megaphone className="h-5 w-5 text-[#ccff00]" />
|
||||
Updates from the host
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EventUpdates event={event} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Comments Section */}
|
||||
{event.event && <CommentsSection root={event.event} />}
|
||||
</div>
|
||||
|
|
@ -378,20 +393,8 @@ export default function EventDetailPage() {
|
|||
<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.goingCount > 0 || rsvpSummary.tentativeCount > 0) ? (
|
||||
<AttendeeList going={rsvpSummary.going} tentative={rsvpSummary.tentative} />
|
||||
) : (
|
||||
!rsvpSummary.isLoading &&
|
||||
isPast && (
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Link } from 'react-router-dom';
|
|||
import { Layout } from '@/components/Layout';
|
||||
import { EventCard, EventCardSkeleton } from '@/components/EventCard';
|
||||
import { TourCard, TourCardSkeleton } from '@/components/TourCard';
|
||||
import { HostingDashboard } from '@/components/HostingDashboard';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { type TrustFilterLevel, filterByTrustLevel } from '@/components/TrustFilter';
|
||||
|
|
@ -36,6 +37,7 @@ import {
|
|||
Users,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Megaphone,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
|
|
@ -60,9 +62,18 @@ const ACCENT = {
|
|||
badge: 'bg-cyan-500/15 text-cyan-400 border-cyan-500/30',
|
||||
stripe: 'bg-cyan-500',
|
||||
},
|
||||
hosting: {
|
||||
active: 'bg-orange-500 text-white border-orange-500',
|
||||
chip: 'bg-orange-500 text-white border-orange-500',
|
||||
chipHover: 'hover:border-orange-500/60 hover:text-orange-400',
|
||||
border: 'border-orange-500/40',
|
||||
icon: 'text-orange-400',
|
||||
badge: 'bg-orange-500/15 text-orange-400 border-orange-500/30',
|
||||
stripe: 'bg-orange-500',
|
||||
},
|
||||
} as const;
|
||||
|
||||
type MainView = 'events' | 'tours';
|
||||
type MainView = 'events' | 'tours' | 'hosting';
|
||||
|
||||
// ─── Date window filter (skaters plan by weekend) ─────────────────────────────
|
||||
|
||||
|
|
@ -172,6 +183,9 @@ interface ToolbarProps {
|
|||
mainView: MainView;
|
||||
onViewChange: (v: MainView) => void;
|
||||
counts: { events: number; tours: number };
|
||||
/** Show the "Hosting" tab (logged-in users only). */
|
||||
showHosting: boolean;
|
||||
hostingCount: number;
|
||||
// Events
|
||||
searchQuery: string;
|
||||
onSearchChange: (q: string) => void;
|
||||
|
|
@ -198,6 +212,8 @@ function EventsToolbar({
|
|||
mainView,
|
||||
onViewChange,
|
||||
counts,
|
||||
showHosting,
|
||||
hostingCount,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
locationQuery,
|
||||
|
|
@ -221,14 +237,14 @@ function EventsToolbar({
|
|||
|
||||
return (
|
||||
<div className="sticky top-14 md:top-16 z-30 bg-black/95 backdrop-blur border-b border-white/5 -mx-4 px-4 py-3 space-y-2.5">
|
||||
{/* Main view tabs: Events / Tours */}
|
||||
{/* Main view tabs: Events / Tours / Hosting */}
|
||||
<div className="flex items-center gap-2">
|
||||
{(['events', 'tours'] as MainView[]).map((view) => {
|
||||
{(['events', 'tours', ...(showHosting ? ['hosting'] as const : [])] as MainView[]).map((view) => {
|
||||
const isActive = mainView === view;
|
||||
const a = ACCENT[view];
|
||||
const Icon = view === 'events' ? Calendar : Route;
|
||||
const label = view === 'events' ? 'Events' : 'Tours';
|
||||
const count = counts[view];
|
||||
const Icon = view === 'events' ? Calendar : view === 'tours' ? Route : Megaphone;
|
||||
const label = view === 'events' ? 'Events' : view === 'tours' ? 'Tours' : 'Hosting';
|
||||
const count = view === 'hosting' ? hostingCount : counts[view as 'events' | 'tours'];
|
||||
return (
|
||||
<button
|
||||
key={view}
|
||||
|
|
@ -291,7 +307,8 @@ function EventsToolbar({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Search + Location + Filter row */}
|
||||
{/* Search + Location + Filter row (not shown in Hosting) */}
|
||||
{mainView !== 'hosting' && (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1">
|
||||
|
|
@ -346,9 +363,10 @@ function EventsToolbar({
|
|||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active filter chips — only shown when something is selected */}
|
||||
{hasActiveChips && (
|
||||
{mainView !== 'hosting' && hasActiveChips && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{selectedTypes.map((type) => (
|
||||
<button
|
||||
|
|
@ -385,7 +403,7 @@ function EventsToolbar({
|
|||
)}
|
||||
|
||||
{/* Results count */}
|
||||
{!isLoading && (
|
||||
{mainView !== 'hosting' && !isLoading && (
|
||||
<p className="text-[11px] text-gray-500 font-semibold leading-none">
|
||||
{totalCount === 0 ? 'No results' : `${totalCount} result${totalCount !== 1 ? 's' : ''}`}
|
||||
</p>
|
||||
|
|
@ -819,6 +837,12 @@ export default function EventsPage() {
|
|||
tours: tours?.length ?? 0,
|
||||
}), [events, tours]);
|
||||
|
||||
// Events the logged-in user is hosting (for the Hosting tab badge).
|
||||
const hostingCount = useMemo(
|
||||
() => (user && events ? events.filter((e) => e.pubkey === user.pubkey).length : 0),
|
||||
[user, events],
|
||||
);
|
||||
|
||||
// ── Filter events ─────────────────────────────────────────────────────────────
|
||||
const filteredEvents = useMemo(() => {
|
||||
if (!events) return { upcoming: [], past: [] };
|
||||
|
|
@ -999,13 +1023,15 @@ export default function EventsPage() {
|
|||
<h1 className="text-4xl md:text-5xl font-black uppercase leading-none mb-1">
|
||||
<span className="neon-lime">SKATE</span>{' '}
|
||||
<span className="text-white">
|
||||
{mainView === 'events' ? 'EVENTS' : 'TOURS'}
|
||||
{mainView === 'events' ? 'EVENTS' : mainView === 'tours' ? 'TOURS' : 'HOSTING'}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-gray-500 text-sm">
|
||||
{mainView === 'events'
|
||||
? t('events.findEvents')
|
||||
: 'Local guides, road trips & world destruction competitions'}
|
||||
: mainView === 'tours'
|
||||
? 'Local guides, road trips & world destruction competitions'
|
||||
: 'Your events — RSVPs, updates & quick edits'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -1023,6 +1049,8 @@ export default function EventsPage() {
|
|||
mainView={mainView}
|
||||
onViewChange={handleViewChange}
|
||||
counts={rawCounts}
|
||||
showHosting={!!user}
|
||||
hostingCount={hostingCount}
|
||||
searchQuery={currentSearchQuery}
|
||||
onSearchChange={handleSearchChange}
|
||||
locationQuery={currentLocationQuery}
|
||||
|
|
@ -1070,6 +1098,9 @@ export default function EventsPage() {
|
|||
{/* ── Results ── */}
|
||||
<div className="mt-6">
|
||||
|
||||
{/* Hosting dashboard */}
|
||||
{mainView === 'hosting' && <HostingDashboard />}
|
||||
|
||||
{/* Events */}
|
||||
{mainView === 'events' && (
|
||||
eventsLoading ? (
|
||||
|
|
|
|||
Loading…
Reference in a new issue