SkateSpots/src/components/JoinRequestCard.tsx
shakespeare.diy f4bca7b736 Admins can endorse join-requests; founder ratifies (kind 9087)
Closes the admin/founder asymmetry without breaking the trust model: only the
founder authors the crew event, so only they add members — but an admin can now
publicly *vouch* for a join-request, and the founder ratifies with one click.

Protocol (SkateSpots-native kind 9087, regular event):
- e-tags the join-request, a-tags the crew, p-tags the requester (role
  "requester") and the founder (so it hits the founder's bell).
- Clients keep only endorsements authored by a real admin of the endorsed crew
  and dedupe one-per-admin-per-request (no credibility inflation).
- New parseCrewEndorsement + CrewEndorsement type; documented in NIP.md.

Hooks:
- useEndorseJoinRequest (admin publishes an endorsement, admin-gated).
- useJoinRequestEndorsements: batch-fetch endorsements for all visible requests
  in ONE #e query, grouped per request, admin-verified per crew.
- useCrewNotifications attaches endorsements to each request entry.

UX (JoinRequestCard):
- Founder: primary becomes "Approve & add" when vouched, with a vouch strip
  (stacked admin avatars + "Vouched by Rider +1 more") as social proof.
- Admin: the old dead-end "Only the founder can add members" becomes a real
  "Vouch for them" action, then a "Vouched — waiting on the founder" state,
  plus a one-line explainer so the path isn't a mystery.
- Endorsements enrich the existing request card (no duplicate notification).

Build clean, no console errors. Not deployed.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-03 01:40:04 +07:00

264 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
<Avatar className="h-5 w-5 ring-2 ring-gray-900">
<AvatarImage src={author.data?.metadata?.picture} alt={name} />
<AvatarFallback className="text-[8px]">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
);
}
/** 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 (
<span className="font-bold text-emerald-300">
{firstName}
{extra > 0 && <span className="font-normal text-emerald-400/70"> +{extra} more</span>}
</span>
);
}
/**
* 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 (
<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">
{done === 'approved' ? (
<>
<Check className="h-4 w-4 text-emerald-400" />
Added <span className="font-bold text-white">{requesterName}</span> to {crew.name}
</>
) : done === 'endorsed' ? (
<>
<ThumbsUp className="h-4 w-4 text-emerald-400" />
You vouched for <span className="font-bold text-white">{requesterName}</span> — founder will confirm
</>
) : (
<>
<X className="h-4 w-4 text-gray-500" />
Dismissed request from {requesterName}
</>
)}
</div>
);
}
const busy = approving || dismissing || endorsing;
return (
<div
className={cn(
'rounded-xl border border-emerald-500/25 bg-gradient-to-br from-emerald-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-emerald-500/15">
<Hand className="h-4 w-4 text-emerald-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-emerald-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={crewHref} className="font-black text-emerald-300 hover:underline">
{crew.name}
</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>
)}
{/* Vouch strip — admins' endorsements as social proof for the founder. */}
{hasEndorsements && (
<div className="mt-2 flex items-center gap-2 rounded-lg border border-emerald-500/20 bg-emerald-500/[0.06] px-2.5 py-1.5">
<div className="flex -space-x-1.5">
{endorserPubkeys.slice(0, 3).map((pk) => (
<EndorserAvatar key={pk} pubkey={pk} />
))}
</div>
<p className="min-w-0 flex-1 truncate text-[11px] text-emerald-200/90">
Vouched by <EndorserNames pubkeys={endorserPubkeys} />
</p>
</div>
)}
<div className="mt-2.5 flex items-center gap-2">
{isFounder ? (
<Button
size="sm"
onClick={handleApprove}
disabled={busy}
className="h-8 flex-1 gap-1.5 bg-emerald-500 font-bold text-black hover:bg-emerald-600"
>
{approving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <UserPlus className="h-3.5 w-3.5" />}
{hasEndorsements ? 'Approve & add' : 'Add to crew'}
</Button>
) : isAdmin ? (
iEndorsed ? (
<span className="flex flex-1 items-center gap-1.5 text-xs font-bold text-emerald-400/90">
<Clock className="h-3.5 w-3.5" /> Vouched — waiting on the founder
</span>
) : (
<Button
size="sm"
onClick={handleEndorse}
disabled={busy}
className="h-8 flex-1 gap-1.5 bg-emerald-500 font-bold text-black hover:bg-emerald-600"
>
{endorsing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ThumbsUp className="h-3.5 w-3.5" />}
Vouch for them
</Button>
)
) : (
<span className="flex-1 text-xs text-gray-500">Crew leadership only.</span>
)}
<Button
size="sm"
variant="outline"
onClick={handleDismiss}
disabled={busy}
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>
{/* Micro-help so the admin path isn't a mystery. */}
{isAdmin && !iEndorsed && (
<p className="mt-1.5 text-[11px] leading-snug text-gray-500">
Admins can’t add members directly — vouching tells the founder you back this rider.
</p>
)}
</div>
</div>
</div>
);
}