diff --git a/src/components/CrewInviteCard.tsx b/src/components/CrewInviteCard.tsx new file mode 100644 index 0000000..b62cfd8 --- /dev/null +++ b/src/components/CrewInviteCard.tsx @@ -0,0 +1,151 @@ +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 ( +
+ {done === 'accepted' ? ( + <> + + You joined {invite.crewName} + + ) : ( + <> + + Declined invite to {invite.crewName} + + )} +
+ ); + } + + const busy = accepting || declining; + + return ( +
+
+
+ +
+
+

+ + + + {inviterName.slice(0, 2).toUpperCase()} + + {inviterName} + {' '} + invited you to join{' '} + + {invite.crewName} + + {invite.role === 'admin' && ( + + Admin + + )} +

+ {invite.note && ( +

+ β€œ{invite.note}” +

+ )} +
+ + +
+
+
+
+ ); +} + +// 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; + } +} diff --git a/src/components/CrewInviteDialog.tsx b/src/components/CrewInviteDialog.tsx index 28c9c8f..902d4cc 100644 --- a/src/components/CrewInviteDialog.tsx +++ b/src/components/CrewInviteDialog.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; -import { UserPlus, Copy, Check, Share2, Edit } from 'lucide-react'; +import { UserPlus, Copy, Check, Share2, Edit, Send, Loader2, ShieldCheck } from 'lucide-react'; import { Link } from 'react-router-dom'; +import { nip19 } from 'nostr-tools'; import { Dialog, DialogContent, @@ -9,45 +10,91 @@ import { DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; +import { Switch } from '@/components/ui/switch'; +import { Label } from '@/components/ui/label'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { useToast } from '@/hooks/useToast'; +import { useAuthor } from '@/hooks/useAuthor'; +import { useSendCrewInvite } from '@/hooks/useCrewInvites'; +import { genUserName } from '@/lib/genUserName'; import { cn } from '@/lib/utils'; +import type { SkateCrew } from '@/lib/types'; interface CrewInviteDialogProps { - crewName: string; - founderPubkey: string; - identifier: string; - /** Whether the viewer is the founder (gets the extra "add member" edit link). */ + crew: SkateCrew; + /** Whether the viewer is the founder (gets the extra "add member" edit link + admin toggle). */ isFounder: boolean; triggerClassName?: string; } +/** Try to resolve a raw string (npub / nprofile / hex) to a hex pubkey. */ +function resolvePubkey(input: string): string | null { + const trimmed = input.trim(); + if (!trimmed) return null; + // Bare hex + if (/^[0-9a-f]{64}$/i.test(trimmed)) return trimmed.toLowerCase(); + try { + const decoded = nip19.decode(trimmed); + if (decoded.type === 'npub') return decoded.data.toLowerCase(); + if (decoded.type === 'nprofile') return decoded.data.pubkey.toLowerCase(); + } catch { + return null; + } + return null; +} + +/** Small live preview of who a pubkey resolves to (avatar + name). */ +function InviteePreview({ pubkey }: { pubkey: string }) { + const author = useAuthor(pubkey); + const name = author.data?.metadata?.name ?? genUserName(pubkey); + const pic = author.data?.metadata?.picture; + return ( +
+ + + {name.slice(0, 2).toUpperCase()} + +
+

{name}

+

{nip19.npubEncode(pubkey)}

+
+
+ ); +} + /** * Invite flow for crew moderators (founder + admins). * - * On Nostr, only the founder can write the crew's member (`p`) list, so an admin - * can't silently add someone. What every moderator CAN do is invite: share a - * link/message that brings the invitee to the crew page, where β€” once the - * founder lists them β€” they confirm membership with their own key. - * - * Founders additionally get a direct "add member" shortcut to the edit form. + * Two ways to invite: + * 1. **Send invite** β€” publishes a structured invite (kind 36229) addressed to a + * specific npub. That person gets a notification with Accept / Decline. + * When the FOUNDER sends it, the invitee is pre-listed so accepting instantly + * confirms membership. Admins can invite too (founder auto-confirms on accept). + * 2. **Share link** β€” copy/share a link for people not yet on Nostr. */ -export function CrewInviteDialog({ - crewName, - founderPubkey, - identifier, - isFounder, - triggerClassName, -}: CrewInviteDialogProps) { +export function CrewInviteDialog({ crew, isFounder, triggerClassName }: CrewInviteDialogProps) { const { toast } = useToast(); + const [open, setOpen] = useState(false); const [copiedLink, setCopiedLink] = useState(false); const [copiedMsg, setCopiedMsg] = useState(false); - const crewUrl = `${window.location.origin}/crew/${founderPubkey}/${identifier}`; + // Send-invite state + const [inviteeInput, setInviteeInput] = useState(''); + const [note, setNote] = useState(''); + const [asAdmin, setAsAdmin] = useState(false); + const { mutateAsync: sendInvite, isPending: sending } = useSendCrewInvite(); + + const resolvedPubkey = resolvePubkey(inviteeInput); + const inputLooksLikeKey = inviteeInput.trim().length > 0; + const invalidKey = inputLooksLikeKey && !resolvedPubkey; + + const crewUrl = `${window.location.origin}/crew/${crew.pubkey}/${crew.identifier}`; const inviteMessage = - `Yo β€” join the ${crewName} crew on SkateSpots πŸ›Ή\n${crewUrl}\n\n` + + `Yo β€” join the ${crew.name} crew on SkateSpots πŸ›Ή\n${crewUrl}\n\n` + `Log in with your Nostr key, then confirm your membership on the crew page.`; const copy = async (text: string, which: 'link' | 'msg') => { @@ -69,7 +116,7 @@ export function CrewInviteDialog({ const nativeShare = async () => { if (navigator.share) { try { - await navigator.share({ title: `Join ${crewName}`, text: inviteMessage, url: crewUrl }); + await navigator.share({ title: `Join ${crew.name}`, text: inviteMessage, url: crewUrl }); } catch { /* cancelled */ } @@ -78,8 +125,35 @@ export function CrewInviteDialog({ } }; + const handleSend = async () => { + if (!resolvedPubkey) return; + try { + await sendInvite({ + crew, + invitee: resolvedPubkey, + role: asAdmin ? 'admin' : 'member', + note, + isFounder, + }); + toast({ + title: 'Invite sent! πŸ›Ή', + description: "They'll get a notification to accept or decline.", + }); + setInviteeInput(''); + setNote(''); + setAsAdmin(false); + setOpen(false); + } catch (err) { + toast({ + title: 'Could not send invite', + description: err instanceof Error ? err.message : 'Please try again.', + variant: 'destructive', + }); + } + }; + return ( - + - + - Invite to {crewName} + Invite to {crew.name} - Share this with someone you want in the crew. They'll land on the crew page to confirm. + Send an invite to someone's Nostr key, or share a link. -
- {/* Invite link */} -
- -
- e.target.select()} /> - -
-
+ + + + Send invite + + + Share link + + - {/* Invite message */} -
- -