SkateSpots/src/components/JoinRequestCard.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

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>
);
}