SkateSpots/src/components/CrewInviteCard.tsx
shakespeare.diy e8c1fbc71a Add crew invitation & notification system (Phase 1)
- Upgrade CrewInviteDialog with a "Send invite" tab: invite a specific npub
  with live invitee preview, optional note, and founder-only "invite as admin"
  toggle. Preserves the shareable-link flow on a second tab.
- Add NotificationBell to desktop + mobile headers: unread badge, popover with
  incoming crew invites (accept/decline) and pending-confirmation crews
  (confirm with public/crew-only visibility).
- Wire useSendCrewInvite / useMyCrewInvites / accept / decline hooks and the
  useCrewNotifications aggregator into the UI.
- Membership stays unforgeable (founder p-tag + invitee NIP-32 attestation).

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-02 22:59:34 +07:00

151 lines
5.5 KiB
TypeScript

import { useState } from 'react';
import { Link } from 'react-router-dom';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { useAuthor } from '@/hooks/useAuthor';
import { useAcceptCrewInvite, useDeclineCrewInvite } from '@/hooks/useCrewInvites';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { parseAddrCoord, type CrewInvite } from '@/lib/types';
import { Users, Check, X, Loader2, ShieldCheck } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* A single crew-invite notification card with Accept / Decline actions.
* Accepting publishes the invitee's membership attestation (making them a
* confirmed member); declining dismisses the invite locally.
*/
export function CrewInviteCard({
invite,
onResolved,
compact,
}: {
invite: CrewInvite;
onResolved?: () => void;
compact?: boolean;
}) {
const inviter = useAuthor(invite.inviter);
const inviterName = inviter.data?.metadata?.name ?? genUserName(invite.inviter);
const inviterPic = inviter.data?.metadata?.picture;
const { toast } = useToast();
const { mutateAsync: accept, isPending: accepting } = useAcceptCrewInvite();
const { mutateAsync: decline, isPending: declining } = useDeclineCrewInvite();
const [done, setDone] = useState<'accepted' | 'declined' | null>(null);
const coord = parseAddrCoord(invite.crewCoord);
const crewHref = coord ? `/crew/${coord.pubkey}/${coord.identifier}` : '/crews';
const handleAccept = async () => {
try {
await accept({ invite });
setDone('accepted');
toast({
title: `Welcome to ${invite.crewName}! 🛹`,
description: 'Your membership is confirmed.',
});
onResolved?.();
} catch (err) {
toast({
title: 'Could not accept',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
}
};
const handleDecline = async () => {
try {
await decline({ invite });
setDone('declined');
onResolved?.();
} catch {
toast({ title: 'Could not decline', 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 === 'accepted' ? (
<>
<Check className="h-4 w-4 text-teal-400" />
You joined <span className="font-bold text-white">{invite.crewName}</span>
</>
) : (
<>
<X className="h-4 w-4 text-gray-500" />
Declined invite to {invite.crewName}
</>
)}
</div>
);
}
const busy = accepting || declining;
return (
<div className={cn('rounded-xl border border-teal-500/25 bg-gradient-to-br from-teal-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-teal-500/15">
<Users className="h-4.5 w-4.5 text-teal-400" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm leading-snug text-gray-200">
<Link to={`/${inviterNpubSafe(invite.inviter)}`} className="inline-flex items-center gap-1 font-bold text-white hover:text-teal-300">
<Avatar className="h-4 w-4">
<AvatarImage src={inviterPic} alt={inviterName} />
<AvatarFallback className="text-[8px]">{inviterName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
{inviterName}
</Link>{' '}
invited you to join{' '}
<Link to={crewHref} className="font-black text-teal-300 hover:underline">
{invite.crewName}
</Link>
{invite.role === 'admin' && (
<span className="ml-1 inline-flex items-center gap-0.5 rounded-full bg-teal-500/20 px-1.5 py-0.5 text-[9px] font-black uppercase text-teal-300">
<ShieldCheck className="h-2.5 w-2.5" /> Admin
</span>
)}
</p>
{invite.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]">
“{invite.note}”
</p>
)}
<div className="mt-2.5 flex items-center gap-2">
<Button
size="sm"
onClick={handleAccept}
disabled={busy}
className="h-8 flex-1 gap-1.5 bg-teal-500 font-bold text-black hover:bg-teal-600"
>
{accepting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
Accept
</Button>
<Button
size="sm"
variant="outline"
onClick={handleDecline}
disabled={busy}
className="h-8 gap-1.5 border-white/15 text-gray-400 hover:bg-white/10 hover:text-white"
>
{declining ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
Decline
</Button>
</div>
</div>
</div>
</div>
);
}
// Keep a tiny local encoder to avoid importing nip19 into every card render path.
import { nip19 } from 'nostr-tools';
function inviterNpubSafe(pubkey: string): string {
try {
return nip19.npubEncode(pubkey);
} catch {
return pubkey;
}
}