~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).
267 lines
10 KiB
TypeScript
267 lines
10 KiB
TypeScript
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 { useTranslation } from '@/contexts/language';
|
|
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 { t } = useTranslation();
|
|
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"> {t('socialUi.joinRequest.moreCount', { count: extra })}</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 { t } = useTranslation();
|
|
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: t('socialUi.joinRequest.addedTitle', { name: requesterName, crew: crew.name }),
|
|
description: t('socialUi.joinRequest.addedDesc'),
|
|
});
|
|
onResolved?.();
|
|
} catch (err) {
|
|
toast({
|
|
title: t('socialUi.collectiveJoinRequest.couldNotAdd'),
|
|
description: err instanceof Error ? err.message : t('common.tryAgain'),
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleEndorse = async () => {
|
|
try {
|
|
await endorse({ request, crew });
|
|
setDone('endorsed');
|
|
toast({
|
|
title: t('socialUi.joinRequest.vouchedTitle', { name: requesterName }),
|
|
description: t('socialUi.joinRequest.vouchedDesc', { crew: crew.name }),
|
|
});
|
|
onResolved?.();
|
|
} catch (err) {
|
|
toast({
|
|
title: t('socialUi.joinRequest.couldNotEndorse'),
|
|
description: err instanceof Error ? err.message : t('common.tryAgain'),
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleDismiss = async () => {
|
|
try {
|
|
await dismiss({ request });
|
|
setDone('dismissed');
|
|
onResolved?.();
|
|
} catch {
|
|
toast({ title: t('socialUi.collectiveJoinRequest.couldNotDismiss'), 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" />
|
|
{t('socialUi.collectiveJoinRequest.addedDone')} <span className="font-bold text-white">{requesterName}</span> {t('socialUi.collectiveJoinRequest.addedTo')} {crew.name}
|
|
</>
|
|
) : done === 'endorsed' ? (
|
|
<>
|
|
<ThumbsUp className="h-4 w-4 text-emerald-400" />
|
|
{t('socialUi.joinRequest.endorsedDoneA')} <span className="font-bold text-white">{requesterName}</span> {t('socialUi.joinRequest.endorsedDoneB')}
|
|
</>
|
|
) : (
|
|
<>
|
|
<X className="h-4 w-4 text-gray-500" />
|
|
{t('socialUi.collectiveJoinRequest.dismissedDone', { name: 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>{' '}
|
|
{t('socialUi.collectiveJoinRequest.wantsToJoin')}{' '}
|
|
<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">
|
|
{t('socialUi.joinRequest.vouchedBy')} <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 ? t('socialUi.joinRequest.approveAndAdd') : t('socialUi.joinRequest.addToCrew')}
|
|
</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" /> {t('socialUi.joinRequest.vouchedWaiting')}
|
|
</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" />}
|
|
{t('socialUi.joinRequest.vouchForThem')}
|
|
</Button>
|
|
)
|
|
) : (
|
|
<span className="flex-1 text-xs text-gray-500">{t('socialUi.joinRequest.leadershipOnly')}</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" />}
|
|
{t('socialUi.collectiveJoinRequest.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">
|
|
{t('socialUi.joinRequest.adminHelp')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|