SkateSpots/src/components/CrewInviteDialog.tsx
SkateSpots 0b796440f1 feat(i18n): Spanish batch 2 — nav/chrome, events/shops/tours, crews/DMs, settings/auth, wallet/stash/newsletter/stats
~3,470 new keys (5,770 total, EN=ES), ~115 more files wired.
- nav + chromeUi: header, More menu, burger menu, footer, map toolbar widgets
  (sort/trust/weather), spot-type label helper.
- placesUi: events, shops, tours, food. socialUi: crews, DMs, notifications,
  collectives, crew chat/coordinator. settingsUi: settings, auth/signup,
  onboarding, profile edit, legal. walletUi: wallet/Cashu, stash, mints,
  app shop, newsletter, community stats, leaderboards, profile.
- Fixed 738 usages where an agent dropped the socialUi. prefix (caught by check:i18n).
2026-10-02 10:11:06 +07:00

295 lines
12 KiB
TypeScript

import { useState } from '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,
DialogDescription,
DialogHeader,
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 { useTranslation } from '@/contexts/language';
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 {
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 (
<div className="flex items-center gap-2 rounded-lg border border-teal-500/25 bg-teal-500/5 px-3 py-2">
<Avatar className="h-8 w-8">
<AvatarImage src={pic} alt={name} />
<AvatarFallback className="text-[10px]">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="truncate text-sm font-bold text-white">{name}</p>
<p className="truncate font-mono text-[10px] text-gray-500">{nip19.npubEncode(pubkey)}</p>
</div>
</div>
);
}
/**
* Invite flow for crew moderators (founder + admins).
*
* 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({ crew, isFounder, triggerClassName }: CrewInviteDialogProps) {
const { t } = useTranslation();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const [copiedLink, setCopiedLink] = useState(false);
const [copiedMsg, setCopiedMsg] = useState(false);
// 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 =
`${t('socialUi.inviteDialog.messageIntro', { crew: crew.name })}\n${crewUrl}\n\n` +
`${t('socialUi.inviteDialog.messageBody')}`;
const copy = async (text: string, which: 'link' | 'msg') => {
try {
await navigator.clipboard.writeText(text);
if (which === 'link') {
setCopiedLink(true);
setTimeout(() => setCopiedLink(false), 2000);
} else {
setCopiedMsg(true);
setTimeout(() => setCopiedMsg(false), 2000);
}
toast({ title: t('common.copied') });
} catch {
toast({ title: t('socialUi.inviteDialog.copyFailed'), variant: 'destructive' });
}
};
const nativeShare = async () => {
if (navigator.share) {
try {
await navigator.share({ title: t('socialUi.inviteDialog.shareTitle', { crew: crew.name }), text: inviteMessage, url: crewUrl });
} catch {
/* cancelled */
}
} else {
copy(inviteMessage, 'msg');
}
};
const handleSend = async () => {
if (!resolvedPubkey) return;
try {
await sendInvite({
crew,
invitee: resolvedPubkey,
role: asAdmin ? 'admin' : 'member',
note,
isFounder,
});
toast({
title: t('socialUi.inviteDialog.sentTitle'),
description: t('socialUi.inviteDialog.sentDesc'),
});
setInviteeInput('');
setNote('');
setAsAdmin(false);
setOpen(false);
} catch (err) {
toast({
title: t('socialUi.inviteDialog.couldNotSend'),
description: err instanceof Error ? err.message : t('common.tryAgain'),
variant: 'destructive',
});
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className={cn('gap-1.5 border-teal-500/40 text-teal-400 hover:bg-teal-500/10', triggerClassName)}
>
<UserPlus className="h-3.5 w-3.5" /> {t('socialUi.inviteDialog.trigger')}
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UserPlus className="h-5 w-5 text-teal-400" /> {t('socialUi.inviteDialog.title', { crew: crew.name })}
</DialogTitle>
<DialogDescription>
{t('socialUi.inviteDialog.description')}
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="send" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="send" className="gap-1.5">
<Send className="h-3.5 w-3.5" /> {t('socialUi.inviteDialog.sendTab')}
</TabsTrigger>
<TabsTrigger value="share" className="gap-1.5">
<Share2 className="h-3.5 w-3.5" /> {t('socialUi.inviteDialog.shareTab')}
</TabsTrigger>
</TabsList>
{/* ── Send a structured invite to an npub ── */}
<TabsContent value="send" className="space-y-4 pt-2">
<div className="space-y-1.5">
<label className="text-xs font-bold uppercase tracking-wide text-gray-500">
{t('socialUi.inviteDialog.npubLabel')}
</label>
<Input
value={inviteeInput}
onChange={(e) => setInviteeInput(e.target.value)}
placeholder="npub1… or nprofile1…"
className={cn('font-mono text-xs', invalidKey && 'border-red-500/50')}
autoComplete="off"
spellCheck={false}
/>
{invalidKey && (
<p className="text-xs text-red-400">{t('socialUi.inviteDialog.invalidNpub')}</p>
)}
</div>
{resolvedPubkey && <InviteePreview pubkey={resolvedPubkey} />}
<div className="space-y-1.5">
<label className="text-xs font-bold uppercase tracking-wide text-gray-500">
{t('socialUi.inviteDialog.noteLabel')} <span className="font-normal text-gray-600">{t('socialUi.inviteDialog.optional')}</span>
</label>
<Textarea
value={note}
onChange={(e) => setNote(e.target.value.slice(0, 500))}
placeholder={t('socialUi.inviteDialog.notePlaceholder')}
rows={2}
className="text-sm"
/>
</div>
{isFounder && (
<div className="flex items-center justify-between rounded-lg border border-teal-500/25 bg-teal-500/5 px-3 py-2.5">
<div className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-teal-400" />
<div>
<Label htmlFor="invite-admin" className="text-sm font-bold text-white">
{t('socialUi.inviteDialog.inviteAsAdmin')}
</Label>
<p className="text-[11px] text-gray-500">{t('socialUi.inviteDialog.inviteAsAdminDesc')}</p>
</div>
</div>
<Switch id="invite-admin" checked={asAdmin} onCheckedChange={setAsAdmin} />
</div>
)}
<Button
type="button"
onClick={handleSend}
disabled={!resolvedPubkey || sending}
className="w-full gap-1.5 bg-teal-500 font-bold text-black hover:bg-teal-600"
>
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
{t('socialUi.inviteDialog.sendButton')}
</Button>
<p className="text-center text-[11px] text-gray-500">
{t('socialUi.inviteDialog.sendFooter')}
</p>
</TabsContent>
{/* ── Share a link (for people not on Nostr yet) ── */}
<TabsContent value="share" className="space-y-4 pt-2">
<div className="space-y-1.5">
<label className="text-xs font-bold uppercase tracking-wide text-gray-500">{t('socialUi.inviteDialog.inviteLink')}</label>
<div className="flex gap-2">
<Input readOnly value={crewUrl} className="font-mono text-xs" onFocus={(e) => e.target.select()} />
<Button type="button" variant="outline" size="icon" className="shrink-0" onClick={() => copy(crewUrl, 'link')}>
{copiedLink ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold uppercase tracking-wide text-gray-500">{t('socialUi.inviteDialog.messageLabel')}</label>
<Textarea readOnly value={inviteMessage} rows={4} className="text-sm" />
<div className="flex gap-2">
<Button type="button" variant="outline" className="flex-1 gap-1.5" onClick={() => copy(inviteMessage, 'msg')}>
{copiedMsg ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
{t('socialUi.inviteDialog.copyMessage')}
</Button>
<Button type="button" className="flex-1 gap-1.5 bg-teal-500 hover:bg-teal-600 text-black" onClick={nativeShare}>
<Share2 className="h-4 w-4" /> {t('common.share')}
</Button>
</div>
</div>
{isFounder && (
<div className="rounded-lg border border-teal-500/30 bg-teal-500/5 p-3">
<p className="mb-2 text-xs text-gray-400">
{t('socialUi.inviteDialog.founderHint')}
</p>
<Link to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}>
<Button type="button" variant="outline" size="sm" className="w-full gap-1.5 border-teal-500/40 text-teal-400 hover:bg-teal-500/10">
<Edit className="h-3.5 w-3.5" /> {t('socialUi.inviteDialog.addMemberSettings')}
</Button>
</Link>
</div>
)}
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}