Lab collective join-requests → notification bell

Skaters can now request to join a Lab collective (studio/build team), mirroring
the crew join-request flow:

- New addressable kind 36387 (collective join-request), documented in NIP.md
- useCollectiveJoinRequests: send / incoming / "already requested" hooks
- Owner-only notification surfaced in the bell (All + Crews tabs) via
  CollectiveJoinRequestCard, reusing the synced dismissal set
- "Request to join this collective" button on collective Lab profiles
  (RequestToJoinCollective), hidden for the owner and existing members

Trust model preserved: only the owner can edit their own profile roster, so the
card links them to the member editor — no forged membership possible.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-03 15:41:03 +07:00
parent 64b91c0ae2
commit 314c8620f1
8 changed files with 502 additions and 7 deletions

44
NIP.md
View file

@ -2395,6 +2395,47 @@ join-request, `a`-tags the crew, and `p`-tags both the requester (role
Clients **must** keep only endorsements whose author is a listed admin of the
endorsed crew, and dedupe to one endorsement per admin per request.
#### Collective join requests (kind `36387`) — join a Lab collective
Lab collectives (studios, build teams, filmer houses — any Lab profile with
`entity = collective`) accept the same "ask to join" flow as crews, but keyed to
the collective's own coordinate. A skater publishes a public, addressable event
(kind `36387`) that `p`-tags the collective's owner so it surfaces in their
notification bell.
```json
{
"kind": 36387,
"content": "Been filming the local plaza scene for years — would love to roll with you.",
"tags": [
["d", "35003:<ownerPubkey>:<collectiveDtag>:<requesterPubkey>"],
["a", "35003:<ownerPubkey>:<collectiveDtag>"],
["collective_name", "Barcelona Filmers"],
["p", "<ownerPubkey>"],
["alt", "Collective join request: Barcelona Filmers on SkateSpots"]
]
}
```
| Tag | Description |
|-----|-------------|
| `d` | Collective coordinate + requester pubkey, so re-requesting **replaces** the prior request rather than piling up. |
| `a` | The Lab collective coordinate (`<kind>:<owner>:<d>`) where `<kind>` is any Lab craft kind (35001–35009). Clients validate the referenced kind is a Lab craft kind. |
| `collective_name` | Cached title for rendering the notification without a second fetch. |
| `p` | The collective **owner** — queryable so the owner finds requests addressed to them: `{ kinds: [36387], "#p": ["<me>"] }`. |
| `alt` | NIP-31 human-readable summary. |
**Flow (mirrors crews):**
1. **Request.** The skater authors the request, binding "who wants in" to their key.
2. **Notify.** The owner's client queries `#p` and surfaces a card linking to the
collective's member editor plus a **Dismiss** action.
3. **Add.** Only the owner authors the collective profile, so only they add the
requester's `p` tag; the requester then confirms with their own NIP-32
membership attestation, exactly as with any collective member.
4. **Dismiss.** Handled/ignored requests are cleared from the owner's bell via
the same synced dismissal set below (keyed by event id).
#### Dismissed notifications (kind `30078`) — private, cross-device
Neither an invitee nor a moderator authored the invite / join-request they're
@ -2402,7 +2443,8 @@ acting on, so they can't delete it — they can only **stop it nagging them**.
That "dismissed" set is kept as a single private, addressable **NIP-78**
application-data event (kind `30078`, `d = skatespots:crew-notif-dismissals`)
whose `content` is a **NIP-44 payload encrypted to the user's own key**. One
list covers both invites and join-requests (both keyed by event id).
list covers invites, crew join-requests, and collective join-requests (all keyed
by event id).
```json
{

View file

@ -0,0 +1,134 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { useAuthor } from '@/hooks/useAuthor';
import { useDismissCrewNotification } from '@/hooks/useCrewDismissals';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { parseAddrCoord, type CollectiveJoinRequest } from '@/lib/types';
import { Users, X, Loader2, Hand, Settings2, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
function npubSafe(pubkey: string): string {
try {
return nip19.npubEncode(pubkey);
} catch {
return pubkey;
}
}
/**
* An incoming "request to join this collective" for a Lab-collective owner.
*
* Trust model mirrors crews: only the owner can edit their own Lab-profile
* event, so the card can't add the member for them. Instead it links straight
* to the collective's editor (where the roster lives) and lets the owner
* dismiss the request once handled. No forged membership is ever possible.
*/
export function CollectiveJoinRequestCard({
request,
onResolved,
compact,
}: {
request: CollectiveJoinRequest;
onResolved?: () => void;
compact?: boolean;
}) {
const requester = useAuthor(request.requester);
const requesterName = requester.data?.metadata?.name ?? genUserName(request.requester);
const requesterPic = requester.data?.metadata?.picture;
const { toast } = useToast();
const { mutateAsync: dismiss, isPending: dismissing } = useDismissCrewNotification();
const [done, setDone] = useState(false);
const coord = parseAddrCoord(request.collectiveCoord);
// All Lab crafts render + edit through the /coach detail route.
const collectiveHref = coord ? `/coach/${coord.pubkey}/${coord.identifier}` : '/lab';
const handleDismiss = async () => {
try {
await dismiss(request.id);
setDone(true);
onResolved?.();
} catch {
toast({ title: 'Could not dismiss', variant: 'destructive' });
}
};
if (done) {
return (
<div className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/[0.03] px-3 py-2.5 text-sm text-gray-400">
<Check className="h-4 w-4 text-emerald-400" />
Dismissed request from {requesterName}
</div>
);
}
return (
<div
className={cn(
'rounded-xl border border-sky-500/25 bg-gradient-to-br from-sky-500/[0.08] to-transparent p-3',
compact && 'p-2.5',
)}
>
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl bg-sky-500/15">
<Hand className="h-4 w-4 text-sky-400" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm leading-snug text-gray-200">
<Link
to={`/${npubSafe(request.requester)}`}
className="inline-flex items-center gap-1 font-bold text-white hover:text-sky-300"
>
<Avatar className="h-4 w-4">
<AvatarImage src={requesterPic} alt={requesterName} />
<AvatarFallback className="text-[8px]">{requesterName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
{requesterName}
</Link>{' '}
wants to join{' '}
<Link to={collectiveHref} className="font-black text-sky-300 hover:underline">
{request.collectiveName}
</Link>
</p>
{request.note && (
<p className="mt-1 rounded-lg bg-black/30 px-2.5 py-1.5 text-xs italic text-gray-400 break-words [overflow-wrap:anywhere]">
“{request.note}”
</p>
)}
<div className="mt-2.5 flex items-center gap-2">
<Button
asChild
size="sm"
className="h-8 flex-1 gap-1.5 bg-sky-500 font-bold text-black hover:bg-sky-600"
>
<Link to={`${collectiveHref}?manage=members`}>
<Settings2 className="h-3.5 w-3.5" />
Manage roster
</Link>
</Button>
<Button
size="sm"
variant="outline"
onClick={handleDismiss}
disabled={dismissing}
className="h-8 gap-1.5 border-white/15 text-gray-400 hover:bg-white/10 hover:text-white"
>
{dismissing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
Dismiss
</Button>
</div>
<p className="mt-1.5 flex items-center gap-1 text-[11px] leading-snug text-gray-500">
<Users className="h-3 w-3" />
Add them from your collective’s member list, then dismiss this.
</p>
</div>
</div>
</div>
);
}

View file

@ -8,6 +8,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { CrewInviteCard } from '@/components/CrewInviteCard';
import { JoinRequestCard } from '@/components/JoinRequestCard';
import { CollectiveJoinRequestCard } from '@/components/CollectiveJoinRequestCard';
import { ActivityNotificationCard } from '@/components/ActivityNotificationCard';
import { useCrewNotifications, useIsPendingMember } from '@/hooks/useCrewNotifications';
import { useActivityNotifications } from '@/hooks/useActivityNotifications';
@ -145,13 +146,14 @@ function EmptyState({ label }: { label: string }) {
*/
export function NotificationBell({ className }: { className?: string }) {
const [open, setOpen] = useState(false);
const { user, invites, listedCrews, joinRequests, isLoading: crewLoading } = useCrewNotifications();
const { user, invites, listedCrews, joinRequests, collectiveRequests, isLoading: crewLoading } =
useCrewNotifications();
const { notifications: activity, isLoading: activityLoading } = useActivityNotifications();
const { lastRead, markAllRead } = useNotificationsRead();
const crewTotal = useMemo(
() => invites.length + listedCrews.length + joinRequests.length,
[invites, listedCrews, joinRequests],
() => invites.length + listedCrews.length + joinRequests.length + collectiveRequests.length,
[invites, listedCrews, joinRequests, collectiveRequests],
);
// Unread social items = activity newer than the last time the bell was opened.
@ -243,6 +245,9 @@ export function NotificationBell({ className }: { className?: string }) {
{joinRequests.map(({ request, crew, endorsements }) => (
<JoinRequestCard key={request.id} request={request} crew={crew} endorsements={endorsements} compact />
))}
{collectiveRequests.map((request) => (
<CollectiveJoinRequestCard key={request.id} request={request} compact />
))}
{invites.map((invite) => (
<CrewInviteCard key={invite.id} invite={invite} compact />
))}
@ -290,12 +295,15 @@ export function NotificationBell({ className }: { className?: string }) {
</div>
)}
{!crewLoading && crewTotal === 0 && (
<EmptyState label="Crew invites and membership requests will show up here." />
<EmptyState label="Crew invites, collective requests and membership requests will show up here." />
)}
<div className="space-y-2.5">
{joinRequests.map(({ request, crew, endorsements }) => (
<JoinRequestCard key={request.id} request={request} crew={crew} endorsements={endorsements} compact />
))}
{collectiveRequests.map((request) => (
<CollectiveJoinRequestCard key={request.id} request={request} compact />
))}
{invites.map((invite) => (
<CrewInviteCard key={invite.id} invite={invite} compact />
))}

View file

@ -0,0 +1,126 @@
import { useState } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast';
import {
useSendCollectiveJoinRequest,
useMyCollectiveJoinRequest,
} from '@/hooks/useCollectiveJoinRequests';
import type { LabProfile } from '@/hooks/useLabProfiles';
import { Users, Hand, Loader2, Check, X } from 'lucide-react';
/**
* "Request to join this collective" — shown on a Lab collective's profile to
* logged-in skaters who aren't the owner and aren't already listed. Mirrors the
* crew join-request: it notifies the owner (who adds the member through their
* own editor). Owners never see it on their own profile.
*/
export function RequestToJoinCollective({
profile,
accentText = 'text-teal-400',
accentBorder = 'border-teal-500',
}: {
profile: LabProfile;
accentText?: string;
accentBorder?: string;
}) {
const { user } = useCurrentUser();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const [note, setNote] = useState('');
const coord = `${profile.event.kind}:${profile.pubkey}:${profile.identifier}`;
const { mutateAsync: send, isPending: sending } = useSendCollectiveJoinRequest();
const { data: alreadyRequested, isLoading: checking } = useMyCollectiveJoinRequest(coord);
// Gate: must be logged in, not the owner, not already a listed member.
if (!user) return null;
const meLc = user.pubkey.toLowerCase();
if (meLc === profile.pubkey.toLowerCase()) return null;
if (profile.members.some((m) => m.toLowerCase() === meLc)) return null;
const handleSend = async () => {
try {
await send({ profile, note });
setOpen(false);
setNote('');
toast({
title: 'Request sent 🤝',
description: `${profile.title} will see it in their notifications and can add you.`,
});
} catch (err) {
toast({
title: 'Could not send request',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
}
};
return (
<Card className={`bg-black/90 border-2 ${accentBorder}`}>
<CardContent className="pt-6">
<div className="flex items-center gap-2 mb-3">
<Users className={`h-5 w-5 ${accentText}`} />
<h3 className="text-lg text-white uppercase font-black">Join this collective</h3>
</div>
{alreadyRequested ? (
<div className="flex items-center gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-2.5 text-sm text-emerald-300">
<Check className="h-4 w-4" />
Request sent — waiting on {profile.title}.
</div>
) : !open ? (
<>
<p className="text-sm text-gray-400 mb-3">
Ask the crew behind <span className="font-bold text-white">{profile.title}</span> to add
you to the roster. They confirm from their side.
</p>
<Button
onClick={() => setOpen(true)}
disabled={checking}
className="w-full gap-2 bg-white text-black font-black hover:bg-gray-200"
>
<Hand className="h-4 w-4" />
Request to join
</Button>
</>
) : (
<div className="space-y-3">
<Textarea
value={note}
onChange={(e) => setNote(e.target.value.slice(0, 500))}
placeholder="Optional: say why you'd be a good fit…"
rows={3}
className="resize-none border-gray-700 bg-black/60 text-white placeholder:text-gray-600"
/>
<div className="flex items-center gap-2">
<Button
onClick={handleSend}
disabled={sending}
className="flex-1 gap-2 bg-white text-black font-black hover:bg-gray-200"
>
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Hand className="h-4 w-4" />}
Send request
</Button>
<Button
variant="outline"
onClick={() => {
setOpen(false);
setNote('');
}}
disabled={sending}
className="gap-1.5 border-gray-700 text-gray-400 hover:bg-white/10 hover:text-white"
>
<X className="h-4 w-4" />
Cancel
</Button>
</div>
</div>
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,109 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import {
COLLECTIVE_JOIN_REQUEST_KIND,
collectiveJoinRequestD,
parseCollectiveJoinRequest,
type CollectiveJoinRequest,
} from '@/lib/types';
import type { LabProfile } from '@/hooks/useLabProfiles';
/**
* Lab-collective join-requests — mirror of the crew join-request flow.
*
* Someone asks to join a Lab collective (studio / build team). The request is
* addressable (keyed by collective + requester, so re-requesting replaces) and
* `p`-tagged to the collective owner so it surfaces in their notification bell.
* The owner adds the member through their normal Lab-profile editor — only they
* can edit their own event, so there's no forged-membership risk.
*/
/** Publish (or replace) a join-request to a Lab collective's owner. */
export function useSendCollectiveJoinRequest() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ profile, note = '' }: { profile: LabProfile; note?: string }) => {
if (!user) throw new Error('You must be logged in to request to join.');
const coord = `${profile.event.kind}:${profile.pubkey}:${profile.identifier}`;
const event = await user.signer.signEvent({
kind: COLLECTIVE_JOIN_REQUEST_KIND,
content: note.slice(0, 500),
created_at: Math.floor(Date.now() / 1000),
tags: [
['d', collectiveJoinRequestD(coord, user.pubkey.toLowerCase())],
['a', coord],
['collective_name', profile.title],
['p', profile.pubkey],
['alt', `Collective join request: ${profile.title} on SkateSpots`],
],
});
await nostr.event(event, { signal: AbortSignal.timeout(15000) });
return event;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['collective-join-requests'] });
},
});
}
/** Incoming collective join-requests addressed to the given owner. */
export function useIncomingCollectiveJoinRequests(pubkey: string | undefined) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery({
queryKey: ['collective-join-requests', 'incoming', pubkey, getQueryKeySuffix()],
enabled: !!pubkey,
staleTime: 30_000,
refetchOnMount: 'always',
queryFn: async (): Promise<CollectiveJoinRequest[]> => {
if (!pubkey) return [];
const signal = AbortSignal.timeout(10000);
const events = await querySkateContent(
[{ kinds: [COLLECTIVE_JOIN_REQUEST_KIND], '#p': [pubkey], limit: 100 }],
{ signal },
);
const seen = new Map<string, CollectiveJoinRequest>();
for (const ev of events) {
const req = parseCollectiveJoinRequest(ev);
if (!req) continue;
if (!req.recipients.includes(pubkey.toLowerCase())) continue;
// Dedup by (collective + requester) — newest wins.
const key = `${req.collectiveCoord}:${req.requester}`;
const prev = seen.get(key);
if (!prev || req.createdAt > prev.createdAt) seen.set(key, req);
}
return [...seen.values()].sort((a, b) => b.createdAt - a.createdAt);
},
});
}
/**
* Whether the logged-in user already has a pending join-request to a given
* collective coordinate (so the button can show "Requested").
*/
export function useMyCollectiveJoinRequest(collectiveCoord: string | undefined) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const { user } = useCurrentUser();
return useQuery({
queryKey: ['collective-join-requests', 'mine', collectiveCoord, user?.pubkey, getQueryKeySuffix()],
enabled: !!collectiveCoord && !!user?.pubkey,
staleTime: 30_000,
queryFn: async (): Promise<boolean> => {
if (!collectiveCoord || !user?.pubkey) return false;
const signal = AbortSignal.timeout(8000);
const d = collectiveJoinRequestD(collectiveCoord, user.pubkey.toLowerCase());
const events = await querySkateContent(
[{ kinds: [COLLECTIVE_JOIN_REQUEST_KIND], authors: [user.pubkey], '#d': [d], limit: 1 }],
{ signal },
);
return events.length > 0;
},
});
}

View file

@ -1,10 +1,11 @@
import { useMemo } from 'react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useMyCrewInvites, useIncomingJoinRequests, useJoinRequestEndorsements } from '@/hooks/useCrewInvites';
import { useIncomingCollectiveJoinRequests } from '@/hooks/useCollectiveJoinRequests';
import { useCrewDismissals } from '@/hooks/useCrewDismissals';
import { useCrews } from '@/hooks/useCrews';
import { useCollectiveMembers } from '@/hooks/useCollectiveMembers';
import { SKATE_CREW_KIND, type CrewEndorsement, type CrewInvite, type CrewJoinRequest, type SkateCrew } from '@/lib/types';
import { SKATE_CREW_KIND, type CollectiveJoinRequest, type CrewEndorsement, type CrewInvite, type CrewJoinRequest, type SkateCrew } from '@/lib/types';
export interface CrewNotification {
/** Stable id for the notification (invite id or crew coord). */
@ -30,6 +31,8 @@ export function useCrewNotifications() {
const { user } = useCurrentUser();
const { data: invites, isLoading: invitesLoading } = useMyCrewInvites(user?.pubkey);
const { data: joinRequests, isLoading: joinLoading } = useIncomingJoinRequests(user?.pubkey);
const { data: collectiveRequestsRaw, isLoading: collectiveLoading } =
useIncomingCollectiveJoinRequests(user?.pubkey);
const { data: crews, isLoading: crewsLoading } = useCrews(200);
// Cross-device-synced set of dismissed invite/request ids.
const { data: dismissed } = useCrewDismissals();
@ -98,12 +101,21 @@ export function useCrewNotifications() {
[requestsWithCrew, endorsements],
);
// Incoming Lab-collective join-requests, minus dismissed ones. These are
// already p-tagged to (and filtered for) the collective owner upstream.
const collectiveRequests = useMemo(() => {
const list = (collectiveRequestsRaw ?? []) as CollectiveJoinRequest[];
if (!dismissed || dismissed.size === 0) return list;
return list.filter((req) => !dismissed.has(req.id));
}, [collectiveRequestsRaw, dismissed]);
return {
user,
invites: visibleInvites,
listedCrews,
joinRequests: requestsWithEndorsements,
isLoading: invitesLoading || joinLoading || crewsLoading,
collectiveRequests,
isLoading: invitesLoading || joinLoading || collectiveLoading || crewsLoading,
};
}

View file

@ -71,6 +71,7 @@ export const SPOT_STATUS_KIND = 2259; // Regular event - community "spot status
export const CREW_INVITE_KIND = 36229; // Addressable crew invitation — founder/admin invites a specific npub to a crew. SkateSpots-native.
export const CREW_JOIN_REQUEST_KIND = 31969; // Addressable crew join-request — a skater asks to join a crew (notifiable). SkateSpots-native.
export const CREW_ENDORSEMENT_KIND = 9087; // Regular event — an admin endorses ("vouches for") a join-request; the founder ratifies. SkateSpots-native.
export const COLLECTIVE_JOIN_REQUEST_KIND = 36387; // Addressable Lab-collective join-request — someone asks to join a Lab collective (studio/build team). Notifiable. SkateSpots-native.
/**
* Lab provider entity type.
@ -365,6 +366,58 @@ export function crewJoinRequestD(crewCoord: string, requesterPubkey: string): st
return `${crewCoord}:${requesterPubkey}`;
}
/**
* A join-request to a Lab COLLECTIVE (studio / build team / etc.) — someone
* asks to be added to the collective's roster. Mirrors the crew join-request:
* addressable (keyed by collective + requester so re-requesting replaces),
* `p`-tagged to the collective owner so it surfaces as a notification. The owner
* adds the member via their normal Lab-profile editor (only they can edit their
* own event), so there's no forged-membership risk.
*/
export interface CollectiveJoinRequest {
id: string;
requester: string; // pubkey asking to join
collectiveCoord: string; // e.g. 35003:pubkey:d
collectiveName: string;
note: string; // optional personal message
recipients: string[]; // owner pubkey(s) addressed (from `p` tags)
createdAt: number;
event: NostrEvent;
}
/** Lab craft kinds that can be collectives (all of them carry entityType). */
const COLLECTIVE_CAPABLE_KINDS = new Set<number>([
SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND,
SKATE_BUILDER_KIND, SKATE_ARTIST_KIND, SKATE_GRAFFITI_KIND, SKATE_TATTOO_KIND,
SKATE_TRAINER_KIND, SKATE_MUSICIAN_KIND, SKATE_JUDGE_KIND,
]);
export function parseCollectiveJoinRequest(event: NostrEvent): CollectiveJoinRequest | null {
if (event.kind !== COLLECTIVE_JOIN_REQUEST_KIND) return null;
const collectiveCoord = event.tags.find(([n]) => n === 'a')?.[1];
if (!collectiveCoord) return null;
const c = parseAddrCoord(collectiveCoord);
if (!c || !COLLECTIVE_CAPABLE_KINDS.has(c.kind)) return null;
const recipients = event.tags
.filter(([n, v]) => n === 'p' && /^[0-9a-f]{64}$/i.test(v ?? ''))
.map(([, v]) => v.toLowerCase());
return {
id: event.id,
requester: event.pubkey,
collectiveCoord,
collectiveName: event.tags.find(([n]) => n === 'collective_name')?.[1] ?? 'a collective',
note: event.content.slice(0, 500),
recipients,
createdAt: event.created_at,
event,
};
}
/** Deterministic d-tag for a collective join-request: coord + requester. */
export function collectiveJoinRequestD(collectiveCoord: string, requesterPubkey: string): string {
return `${collectiveCoord}:${requesterPubkey}`;
}
/**
* An admin's endorsement of a join-request (kind 9087). Since only the founder
* can edit the crew roster, an admin can't add a member directly — but they can

View file

@ -9,6 +9,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { ZapButton } from '@/components/ZapButton';
import { ClinkPayButton } from '@/components/ClinkPayButton';
import { CollectiveMembers } from '@/components/CollectiveMembers';
import { RequestToJoinCollective } from '@/components/RequestToJoinCollective';
import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
import { VouchCard } from '@/components/VouchCard';
import { LinkedCrewCard } from '@/components/CrossLinkCards';
@ -619,6 +620,16 @@ export default function CoachDetailPage() {
/>
)}
{/* Request to join (collectives only) — shown to non-owner,
non-member skaters; notifies the owner via their bell. */}
{isCollective && (
<RequestToJoinCollective
profile={profile}
accentText={accent.text}
accentBorder={accent.border}
/>
)}
{/* Linked crew (cross-link) — pass this profile's own coord so a
mutual link shows a "Verified" badge. */}
<LinkedCrewCard