Every provider lived at /coach/<pubkey>/<id> regardless of craft (a barber got a "coach" URL). New src/lib/labRoutes.ts maps craft kind → segment and builds the canonical path; the router adds /lab/:craft/:pubkey/:identifier (the craft segment is cosmetic — the page still resolves by pubkey + identifier) and keeps /coach/... working for old links. All generated links updated: offer-form redirects, Lab cards + "Your Lab profiles", cross-links, spot/event/admin links, the CVM client URL, the share path, and the SPA clean-link shim.
218 lines
8.5 KiB
TypeScript
218 lines
8.5 KiB
TypeScript
import { useState } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import { labProfilePathFromKind } from '@/lib/labRoutes';
|
|
import { nip19 } from 'nostr-tools';
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
import { Button } from '@/components/ui/button';
|
|
import { useAuthor } from '@/hooks/useAuthor';
|
|
import { useLabProfile } from '@/hooks/useLabProfiles';
|
|
import { useApproveCollectiveJoinRequest } from '@/hooks/useCollectiveJoinRequests';
|
|
import { useDismissCrewNotification } from '@/hooks/useCrewDismissals';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { useTranslation } from '@/contexts/language';
|
|
import { genUserName } from '@/lib/genUserName';
|
|
import { parseAddrCoord, type CollectiveJoinRequest } from '@/lib/types';
|
|
import { UserPlus, X, Loader2, Hand, Check } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
function npubSafe(pubkey: string): string {
|
|
try {
|
|
return nip19.npubEncode(pubkey);
|
|
} catch {
|
|
return pubkey;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* An incoming "request to join this collective" for a Lab-collective owner.
|
|
*
|
|
* One-tap flow: the owner sees who wants in and hits **Add to collective** —
|
|
* that appends the requester to the owner's own Lab-profile roster and clears
|
|
* the request. Only the owner can edit their own event, so no forged membership
|
|
* is possible. While the profile loads (or if it can't be resolved) the card
|
|
* degrades gracefully to a link + Dismiss so the owner is never stuck.
|
|
*/
|
|
export function CollectiveJoinRequestCard({
|
|
request,
|
|
onResolved,
|
|
compact,
|
|
}: {
|
|
request: CollectiveJoinRequest;
|
|
onResolved?: () => void;
|
|
compact?: boolean;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const requester = useAuthor(request.requester);
|
|
const requesterName = requester.data?.metadata?.name ?? genUserName(request.requester);
|
|
const requesterPic = requester.data?.metadata?.picture;
|
|
const { toast } = useToast();
|
|
|
|
const coord = parseAddrCoord(request.collectiveCoord);
|
|
const collectiveHref = coord ? labProfilePathFromKind(coord.kind, coord.pubkey, coord.identifier) : '/lab';
|
|
|
|
// Resolve the collective so we can add the member in one click.
|
|
const { data: profile, isLoading: profileLoading } = useLabProfile(
|
|
coord?.identifier ?? '',
|
|
coord?.pubkey,
|
|
);
|
|
|
|
const { mutateAsync: approve, isPending: approving } = useApproveCollectiveJoinRequest();
|
|
const { mutateAsync: dismiss, isPending: dismissing } = useDismissCrewNotification();
|
|
const [done, setDone] = useState<'added' | 'dismissed' | null>(null);
|
|
|
|
const alreadyMember =
|
|
!!profile && profile.members.some((m) => m.toLowerCase() === request.requester.toLowerCase());
|
|
|
|
const handleAdd = async () => {
|
|
if (!profile) return;
|
|
try {
|
|
await approve({ request, profile });
|
|
setDone('added');
|
|
toast({
|
|
title: t('socialUi.collectiveJoinRequest.addedTitle', { name: requesterName, collective: request.collectiveName }),
|
|
description: t('socialUi.collectiveJoinRequest.addedDesc'),
|
|
});
|
|
onResolved?.();
|
|
} catch (err) {
|
|
toast({
|
|
title: t('socialUi.collectiveJoinRequest.couldNotAdd'),
|
|
description: err instanceof Error ? err.message : t('common.tryAgain'),
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleDismiss = async () => {
|
|
try {
|
|
await dismiss(request.id);
|
|
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 === 'added' ? (
|
|
<>
|
|
<Check className="h-4 w-4 text-sky-400" />
|
|
{t('socialUi.collectiveJoinRequest.addedDone')} <span className="font-bold text-white">{requesterName}</span> {t('socialUi.collectiveJoinRequest.addedTo')}{' '}
|
|
{request.collectiveName}
|
|
</>
|
|
) : (
|
|
<>
|
|
<X className="h-4 w-4 text-gray-500" />
|
|
{t('socialUi.collectiveJoinRequest.dismissedDone', { name: requesterName })}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const busy = approving || dismissing;
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'rounded-xl border border-sky-500/25 bg-gradient-to-br from-sky-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-sky-500/15">
|
|
<Hand className="h-4 w-4 text-sky-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-sky-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={collectiveHref} className="font-black text-sky-300 hover:underline">
|
|
{request.collectiveName}
|
|
</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>
|
|
)}
|
|
|
|
{alreadyMember ? (
|
|
<p className="mt-2 flex items-center gap-1.5 text-xs font-bold text-sky-400/90">
|
|
<Check className="h-3.5 w-3.5" /> {t('socialUi.collectiveJoinRequest.alreadyOnRoster')}
|
|
<button
|
|
type="button"
|
|
onClick={handleDismiss}
|
|
disabled={busy}
|
|
className="ml-auto text-gray-500 underline-offset-2 hover:text-white hover:underline"
|
|
>
|
|
{t('common.clear')}
|
|
</button>
|
|
</p>
|
|
) : !profileLoading && !profile ? (
|
|
/* Profile couldn't be resolved — never trap the owner. Offer a link
|
|
to open the collective plus a way to clear the notification. */
|
|
<div className="mt-2.5 flex items-center gap-2">
|
|
<Button
|
|
asChild
|
|
size="sm"
|
|
className="h-8 flex-1 gap-1.5 bg-sky-500 font-bold text-black hover:bg-sky-600"
|
|
>
|
|
<Link to={collectiveHref}>
|
|
<UserPlus className="h-3.5 w-3.5" />
|
|
{t('socialUi.collectiveJoinRequest.openCollective')}
|
|
</Link>
|
|
</Button>
|
|
<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>
|
|
) : (
|
|
<div className="mt-2.5 flex items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
onClick={handleAdd}
|
|
disabled={busy || profileLoading || !profile}
|
|
className="h-8 flex-1 gap-1.5 bg-sky-500 font-bold text-black hover:bg-sky-600"
|
|
>
|
|
{approving || profileLoading ? (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<UserPlus className="h-3.5 w-3.5" />
|
|
)}
|
|
{profileLoading ? t('common.loading') : t('socialUi.collectiveJoinRequest.addToCollective')}
|
|
</Button>
|
|
<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>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|