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 { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAuthor } from '@/hooks/useAuthor'; import { useApproveJoinRequest, useDismissJoinRequest, useEndorseJoinRequest, } from '@/hooks/useCrewInvites'; import { useToast } from '@/hooks/useToast'; import { genUserName } from '@/lib/genUserName'; import { parseAddrCoord, type CrewEndorsement, type CrewJoinRequest, type SkateCrew } from '@/lib/types'; import { UserPlus, Check, X, Loader2, Hand, ThumbsUp, Clock } from 'lucide-react'; import { cn } from '@/lib/utils'; function npubSafe(pubkey: string): string { try { return nip19.npubEncode(pubkey); } catch { return pubkey; } } /** Tiny avatar for an endorsing admin (used in the vouch strip). */ function EndorserAvatar({ pubkey }: { pubkey: string }) { const author = useAuthor(pubkey); const name = author.data?.metadata?.name ?? genUserName(pubkey); return ( {name.slice(0, 2).toUpperCase()} ); } /** Readable "Vouched by Rider, Mia +1" line for the endorsement strip. */ function EndorserNames({ pubkeys }: { pubkeys: string[] }) { const first = useAuthor(pubkeys[0]); const firstName = first.data?.metadata?.name ?? genUserName(pubkeys[0]); const extra = pubkeys.length - 1; return ( {firstName} {extra > 0 && +{extra} more} ); } /** * A single incoming join-request card for a crew's founder / admins. * * Trust model: only the founder authors the crew event, so only they can *add* * a member. Admins therefore **endorse** ("vouch for") a request; the founder * sees those endorsements as social proof and ratifies with one click. This * closes the admin/founder asymmetry without breaking who-can-edit-the-roster. */ export function JoinRequestCard({ request, crew, endorsements = [], onResolved, compact, }: { request: CrewJoinRequest; crew: SkateCrew; endorsements?: CrewEndorsement[]; onResolved?: () => void; compact?: boolean; }) { const { user } = useCurrentUser(); 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: approve, isPending: approving } = useApproveJoinRequest(); const { mutateAsync: dismiss, isPending: dismissing } = useDismissJoinRequest(); const { mutateAsync: endorse, isPending: endorsing } = useEndorseJoinRequest(); const [done, setDone] = useState<'approved' | 'dismissed' | 'endorsed' | null>(null); const coord = parseAddrCoord(request.crewCoord); const crewHref = coord ? `/crew/${coord.pubkey}/${coord.identifier}` : '/crews'; const meLc = user?.pubkey.toLowerCase(); const isFounder = !!meLc && meLc === crew.pubkey.toLowerCase(); const isAdmin = !!meLc && crew.admins.some((a) => a.toLowerCase() === meLc); const endorserPubkeys = endorsements.map((e) => e.endorser); const iEndorsed = !!meLc && endorserPubkeys.some((p) => p.toLowerCase() === meLc); const hasEndorsements = endorserPubkeys.length > 0; const handleApprove = async () => { try { await approve({ request, crew }); setDone('approved'); toast({ title: `Added ${requesterName} to ${crew.name} πŸ›Ή`, description: "They'll confirm from their side to appear as a member.", }); onResolved?.(); } catch (err) { toast({ title: 'Could not add member', description: err instanceof Error ? err.message : 'Please try again.', variant: 'destructive', }); } }; const handleEndorse = async () => { try { await endorse({ request, crew }); setDone('endorsed'); toast({ title: `You vouched for ${requesterName} 🀝`, description: `The founder of ${crew.name} will see your endorsement and can add them.`, }); onResolved?.(); } catch (err) { toast({ title: 'Could not endorse', description: err instanceof Error ? err.message : 'Please try again.', variant: 'destructive', }); } }; const handleDismiss = async () => { try { await dismiss({ request }); setDone('dismissed'); onResolved?.(); } catch { toast({ title: 'Could not dismiss', variant: 'destructive' }); } }; if (done) { return (
{done === 'approved' ? ( <> Added {requesterName} to {crew.name} ) : done === 'endorsed' ? ( <> You vouched for {requesterName} β€” founder will confirm ) : ( <> Dismissed request from {requesterName} )}
); } const busy = approving || dismissing || endorsing; return (

{requesterName.slice(0, 2).toUpperCase()} {requesterName} {' '} wants to join{' '} {crew.name}

{request.note && (

β€œ{request.note}”

)} {/* Vouch strip β€” admins' endorsements as social proof for the founder. */} {hasEndorsements && (
{endorserPubkeys.slice(0, 3).map((pk) => ( ))}

Vouched by

)}
{isFounder ? ( ) : isAdmin ? ( iEndorsed ? ( Vouched β€” waiting on the founder ) : ( ) ) : ( Crew leadership only. )}
{/* Micro-help so the admin path isn't a mystery. */} {isAdmin && !iEndorsed && (

Admins can’t add members directly β€” vouching tells the founder you back this rider.

)}
); }