SkateSpots/src/components/RequestToJoinCollective.tsx
shakespeare.diy f37d0184d3 Collective join-requests: one-tap approve from the bell
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>
2026-09-03 15:49:49 +07:00

126 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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