Turn the collective join-request into a genuinely seamless flow instead of a
dead-end deep-link:
- New useApproveCollectiveJoinRequest: owner appends the requester to their own
Lab-profile roster (re-signed, preserving all tags) and clears the request in
one action — mirrors the crew founder's "Add to crew".
- CollectiveJoinRequestCard now resolves the collective and offers a single
"Add to collective" tap, with graceful states for loading, already-a-member,
and unresolvable-profile (never traps the owner).
- Removed the old ?manage=members deep-link (pointed at a read-only card).
- Requester-side copy now sets accurate expectations ("added in one tap").
Trust model unchanged: only the owner signs their profile event.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
126 lines
4.6 KiB
TypeScript
126 lines
4.6 KiB
TypeScript
import { useState } from 'react';
|
||
import { Card, CardContent } from '@/components/ui/card';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Textarea } from '@/components/ui/textarea';
|
||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||
import { useToast } from '@/hooks/useToast';
|
||
import {
|
||
useSendCollectiveJoinRequest,
|
||
useMyCollectiveJoinRequest,
|
||
} from '@/hooks/useCollectiveJoinRequests';
|
||
import type { LabProfile } from '@/hooks/useLabProfiles';
|
||
import { Users, Hand, Loader2, Check, X } from 'lucide-react';
|
||
|
||
/**
|
||
* "Request to join this collective" — shown on a Lab collective's profile to
|
||
* logged-in skaters who aren't the owner and aren't already listed. Mirrors the
|
||
* crew join-request: it notifies the owner (who adds the member through their
|
||
* own editor). Owners never see it on their own profile.
|
||
*/
|
||
export function RequestToJoinCollective({
|
||
profile,
|
||
accentText = 'text-teal-400',
|
||
accentBorder = 'border-teal-500',
|
||
}: {
|
||
profile: LabProfile;
|
||
accentText?: string;
|
||
accentBorder?: string;
|
||
}) {
|
||
const { user } = useCurrentUser();
|
||
const { toast } = useToast();
|
||
const [open, setOpen] = useState(false);
|
||
const [note, setNote] = useState('');
|
||
|
||
const coord = `${profile.event.kind}:${profile.pubkey}:${profile.identifier}`;
|
||
const { mutateAsync: send, isPending: sending } = useSendCollectiveJoinRequest();
|
||
const { data: alreadyRequested, isLoading: checking } = useMyCollectiveJoinRequest(coord);
|
||
|
||
// Gate: must be logged in, not the owner, not already a listed member.
|
||
if (!user) return null;
|
||
const meLc = user.pubkey.toLowerCase();
|
||
if (meLc === profile.pubkey.toLowerCase()) return null;
|
||
if (profile.members.some((m) => m.toLowerCase() === meLc)) return null;
|
||
|
||
const handleSend = async () => {
|
||
try {
|
||
await send({ profile, note });
|
||
setOpen(false);
|
||
setNote('');
|
||
toast({
|
||
title: 'Request sent 🤝',
|
||
description: `${profile.title} will see it in their notifications and can add you.`,
|
||
});
|
||
} catch (err) {
|
||
toast({
|
||
title: 'Could not send request',
|
||
description: err instanceof Error ? err.message : 'Please try again.',
|
||
variant: 'destructive',
|
||
});
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Card className={`bg-black/90 border-2 ${accentBorder}`}>
|
||
<CardContent className="pt-6">
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<Users className={`h-5 w-5 ${accentText}`} />
|
||
<h3 className="text-lg text-white uppercase font-black">Join this collective</h3>
|
||
</div>
|
||
|
||
{alreadyRequested ? (
|
||
<div className="flex items-center gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-2.5 text-sm text-emerald-300">
|
||
<Check className="h-4 w-4" />
|
||
Request sent — waiting on {profile.title}.
|
||
</div>
|
||
) : !open ? (
|
||
<>
|
||
<p className="text-sm text-gray-400 mb-3">
|
||
Ask the crew behind <span className="font-bold text-white">{profile.title}</span> to add
|
||
you to the roster. They’ll get a notification and can add you in one tap.
|
||
</p>
|
||
<Button
|
||
onClick={() => setOpen(true)}
|
||
disabled={checking}
|
||
className="w-full gap-2 bg-white text-black font-black hover:bg-gray-200"
|
||
>
|
||
<Hand className="h-4 w-4" />
|
||
Request to join
|
||
</Button>
|
||
</>
|
||
) : (
|
||
<div className="space-y-3">
|
||
<Textarea
|
||
value={note}
|
||
onChange={(e) => setNote(e.target.value.slice(0, 500))}
|
||
placeholder="Optional: say why you'd be a good fit…"
|
||
rows={3}
|
||
className="resize-none border-gray-700 bg-black/60 text-white placeholder:text-gray-600"
|
||
/>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
onClick={handleSend}
|
||
disabled={sending}
|
||
className="flex-1 gap-2 bg-white text-black font-black hover:bg-gray-200"
|
||
>
|
||
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Hand className="h-4 w-4" />}
|
||
Send request
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => {
|
||
setOpen(false);
|
||
setNote('');
|
||
}}
|
||
disabled={sending}
|
||
className="gap-1.5 border-gray-700 text-gray-400 hover:bg-white/10 hover:text-white"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
Cancel
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|