~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).
153 lines
5.9 KiB
TypeScript
153 lines
5.9 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 { useTranslation } from '@/contexts/language';
|
|
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 { t } = useTranslation();
|
|
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: t('socialUi.inviteCard.acceptedTitle'),
|
|
description: t('socialUi.inviteCard.acceptedDesc', { crew: invite.crewName }),
|
|
});
|
|
onResolved?.();
|
|
} catch (err) {
|
|
toast({
|
|
title: t('socialUi.inviteCard.couldNotAccept'),
|
|
description: err instanceof Error ? err.message : t('common.tryAgain'),
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleDecline = async () => {
|
|
try {
|
|
await decline({ invite });
|
|
setDone('declined');
|
|
onResolved?.();
|
|
} catch {
|
|
toast({ title: t('socialUi.inviteCard.couldNotDecline'), 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" />
|
|
{t('socialUi.inviteCard.acceptedDone')} <span className="font-bold text-white">{invite.crewName}</span> {t('socialUi.inviteCard.confirmsMembers')}
|
|
</>
|
|
) : (
|
|
<>
|
|
<X className="h-4 w-4 text-gray-500" />
|
|
{t('socialUi.inviteCard.declinedDone', { crew: 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>{' '}
|
|
{t('socialUi.inviteCard.invitedYou')}{' '}
|
|
<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" /> {t('socialUi.inviteCard.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="min-h-11 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" />}
|
|
{t('socialUi.inviteCard.accept')}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={handleDecline}
|
|
disabled={busy}
|
|
className="min-h-11 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" />}
|
|
{t('socialUi.inviteCard.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;
|
|
}
|
|
}
|