Zap a whole crew: split a zap across confirmed members
- useCrewZap hook: splits a total evenly across recipients, resolves each member's Lightning address (batched kind-0 query), fetches an invoice per recipient and auto-pays via NWC → WebLN; per-recipient status (resolving/paying/success/skipped/failed) with manual-invoice fallback - splitAmount util distributes remainder fairly (no lost sats) - ZapCrewDialog: amount presets + custom, per-head preview, live progress rows, copy-invoice fallback when no wallet; excludes the current user - CrewDetailPage: "Zap the Crew" button (recipients = founder + confirmed members via useCollectiveMembers); shown only when >1 recipient Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
d8d1ba32c9
commit
f881c6513f
3 changed files with 410 additions and 1 deletions
218
src/components/ZapCrewDialog.tsx
Normal file
218
src/components/ZapCrewDialog.tsx
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
|
import { useWallet } from '@/hooks/useWallet';
|
||||||
|
import { useCrewZap, splitAmount, type CrewZapResult } from '@/hooks/useCrewZap';
|
||||||
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
import { genUserName } from '@/lib/genUserName';
|
||||||
|
import { Zap, Check, Copy, Loader2, X, Users, AlertCircle } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const PRESETS = [210, 500, 1000, 2100, 5000];
|
||||||
|
|
||||||
|
interface ZapCrewDialogProps {
|
||||||
|
crewName: string;
|
||||||
|
/** Recipient pubkeys — typically founder + confirmed members. */
|
||||||
|
recipients: string[];
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecipientRow({ result }: { result: CrewZapResult }) {
|
||||||
|
const author = useAuthor(result.pubkey);
|
||||||
|
const meta = author.data?.metadata;
|
||||||
|
const name = meta?.name ?? genUserName(result.pubkey);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const copyInvoice = async () => {
|
||||||
|
if (!result.invoice) return;
|
||||||
|
await navigator.clipboard.writeText(result.invoice);
|
||||||
|
toast({ title: 'Invoice copied', description: 'Paste it into any Lightning wallet' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-gray-700 bg-black/40 p-2">
|
||||||
|
<Avatar className="h-7 w-7">
|
||||||
|
<AvatarImage src={meta?.picture} alt={name} />
|
||||||
|
<AvatarFallback className="text-[10px]">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-xs font-bold text-white">{name}</p>
|
||||||
|
<p className="text-[10px] text-gray-500">{result.amount.toLocaleString()} sats</p>
|
||||||
|
</div>
|
||||||
|
<StatusPill result={result} onCopy={copyInvoice} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPill({ result, onCopy }: { result: CrewZapResult; onCopy: () => void }) {
|
||||||
|
switch (result.status) {
|
||||||
|
case 'success':
|
||||||
|
return <span className="flex items-center gap-1 text-[10px] font-bold text-green-400"><Check className="h-3 w-3" /> Sent</span>;
|
||||||
|
case 'resolving':
|
||||||
|
case 'paying':
|
||||||
|
return <span className="flex items-center gap-1 text-[10px] text-teal-400"><Loader2 className="h-3 w-3 animate-spin" /> {result.status === 'paying' ? 'Paying' : 'Resolving'}</span>;
|
||||||
|
case 'skipped':
|
||||||
|
return <span className="text-[10px] text-gray-500" title={result.error}>Skipped</span>;
|
||||||
|
case 'failed':
|
||||||
|
return result.invoice ? (
|
||||||
|
<button type="button" onClick={onCopy} className="flex items-center gap-1 text-[10px] font-bold text-amber-400 hover:text-amber-300">
|
||||||
|
<Copy className="h-3 w-3" /> Copy invoice
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1 text-[10px] text-red-400" title={result.error}><AlertCircle className="h-3 w-3" /> Failed</span>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return <span className="text-[10px] text-gray-600">Waiting</span>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ZapCrewDialog({ crewName, recipients, children }: ZapCrewDialogProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const { preferredMethod } = useWallet();
|
||||||
|
const { zapCrew, results, isRunning, reset } = useCrewZap();
|
||||||
|
const [amount, setAmount] = useState<number | string>(1000);
|
||||||
|
const [comment, setComment] = useState('');
|
||||||
|
|
||||||
|
// Don't include the current user as a recipient (no zapping yourself).
|
||||||
|
const payable = useMemo(
|
||||||
|
() => recipients.filter((pk) => pk !== user?.pubkey),
|
||||||
|
[recipients, user?.pubkey]
|
||||||
|
);
|
||||||
|
|
||||||
|
const numAmount = typeof amount === 'string' ? parseInt(amount, 10) || 0 : amount;
|
||||||
|
const perHead = payable.length > 0 ? splitAmount(numAmount, payable.length) : [];
|
||||||
|
const started = results.length > 0;
|
||||||
|
const doneCount = results.filter((r) => r.status === 'success').length;
|
||||||
|
const allDone = started && !isRunning;
|
||||||
|
|
||||||
|
const handleZap = () => {
|
||||||
|
if (numAmount <= 0 || payable.length === 0) return;
|
||||||
|
zapCrew(payable, numAmount, comment || `Zapped the ${crewName} crew! 🤘`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = (o: boolean) => {
|
||||||
|
setOpen(o);
|
||||||
|
if (!o) {
|
||||||
|
reset();
|
||||||
|
setAmount(1000);
|
||||||
|
setComment('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!user) return <>{children}</>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<div className="cursor-pointer">{children}</div>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Users className="h-5 w-5 text-teal-400" /> Zap the Crew
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Split a zap evenly across {payable.length} crew {payable.length === 1 ? 'member' : 'members'} with a Lightning address.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{payable.length === 0 ? (
|
||||||
|
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
No other crew members to zap right now.
|
||||||
|
</div>
|
||||||
|
) : !started ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs uppercase tracking-wide text-muted-foreground">Total amount</Label>
|
||||||
|
<ToggleGroup
|
||||||
|
type="single"
|
||||||
|
value={String(amount)}
|
||||||
|
onValueChange={(v) => v && setAmount(parseInt(v, 10))}
|
||||||
|
className="grid grid-cols-5 gap-1 mt-2"
|
||||||
|
>
|
||||||
|
{PRESETS.map((p) => (
|
||||||
|
<ToggleGroupItem key={p} value={String(p)} className="flex-col h-auto text-xs py-2">
|
||||||
|
<Zap className="h-3.5 w-3.5 mb-1" />
|
||||||
|
{p >= 1000 ? `${p / 1000}k` : p}
|
||||||
|
</ToggleGroupItem>
|
||||||
|
))}
|
||||||
|
</ToggleGroup>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
placeholder="Custom total"
|
||||||
|
className="mt-2 bg-gray-800 border-gray-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{numAmount > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
|
≈ <span className="font-bold text-teal-400">{perHead[0]?.toLocaleString()}</span> sats each
|
||||||
|
{perHead.length > 1 && perHead[0] !== perHead[perHead.length - 1] && ' (rounded)'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
placeholder={`Zapped the ${crewName} crew! 🤘`}
|
||||||
|
rows={2}
|
||||||
|
className="bg-gray-800 border-gray-700 resize-none text-sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{preferredMethod === 'manual' && (
|
||||||
|
<p className="flex items-start gap-1.5 rounded-lg border border-amber-500/30 bg-amber-500/10 p-2 text-[11px] text-amber-300">
|
||||||
|
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||||
|
No wallet connected — you'll get an invoice per member to pay manually. Connect a wallet for one-tap splitting.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button onClick={handleZap} disabled={numAmount <= 0} className="w-full bg-teal-500 hover:bg-teal-600 font-bold">
|
||||||
|
<Zap className="h-4 w-4 mr-2" />
|
||||||
|
Zap {numAmount.toLocaleString()} sats across {payable.length}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="max-h-72 overflow-y-auto space-y-1.5">
|
||||||
|
{results.map((r) => (
|
||||||
|
<RecipientRow key={r.pubkey} result={r} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{allDone && (
|
||||||
|
<div className="rounded-lg border border-gray-700 bg-black/40 p-3 text-center">
|
||||||
|
<p className="text-sm font-bold text-white">
|
||||||
|
{doneCount > 0 ? `⚡ Sent to ${doneCount}/${payable.length}` : 'No auto-payments completed'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{results.some((r) => r.status === 'failed' && r.invoice)
|
||||||
|
? 'Copy any pending invoices to finish paying manually.'
|
||||||
|
: 'Thanks for supporting the crew!'}
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => handleOpenChange(false)} className="mt-3 border-gray-700">
|
||||||
|
<X className="h-3.5 w-3.5 mr-1" /> Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isRunning && (
|
||||||
|
<p className="flex items-center justify-center gap-1.5 text-xs text-teal-400">
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" /> Paying the crew…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
162
src/hooks/useCrewZap.ts
Normal file
162
src/hooks/useCrewZap.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useNostr } from '@nostrify/react';
|
||||||
|
import { useNWC } from '@/hooks/useNWCContext';
|
||||||
|
import { useWallet } from '@/hooks/useWallet';
|
||||||
|
import { getInvoiceFromLightningAddress } from '@/lib/lightningAddress';
|
||||||
|
import type { NostrMetadata } from '@nostrify/nostrify';
|
||||||
|
|
||||||
|
export type CrewZapStatus = 'pending' | 'resolving' | 'paying' | 'success' | 'skipped' | 'failed';
|
||||||
|
|
||||||
|
export interface CrewZapResult {
|
||||||
|
pubkey: string;
|
||||||
|
amount: number;
|
||||||
|
status: CrewZapStatus;
|
||||||
|
/** Lightning address used (if resolved). */
|
||||||
|
address?: string;
|
||||||
|
/** Invoice to pay manually if auto-pay unavailable. */
|
||||||
|
invoice?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split `total` sats as evenly as possible across `count` recipients. */
|
||||||
|
export function splitAmount(total: number, count: number): number[] {
|
||||||
|
if (count <= 0) return [];
|
||||||
|
const base = Math.floor(total / count);
|
||||||
|
let remainder = total - base * count;
|
||||||
|
return Array.from({ length: count }, () => {
|
||||||
|
const extra = remainder > 0 ? 1 : 0;
|
||||||
|
if (remainder > 0) remainder--;
|
||||||
|
return base + extra;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zap a whole crew: split an amount across the given recipient pubkeys and pay
|
||||||
|
* each recipient's Lightning address. Uses the connected wallet (NWC → WebLN).
|
||||||
|
* Recipients without a Lightning address are skipped. Recipients that can't be
|
||||||
|
* auto-paid surface a manual invoice.
|
||||||
|
*/
|
||||||
|
export function useCrewZap() {
|
||||||
|
const { nostr } = useNostr();
|
||||||
|
const { sendPayment, getActiveConnection } = useNWC();
|
||||||
|
const { webln } = useWallet();
|
||||||
|
|
||||||
|
const [results, setResults] = useState<CrewZapResult[]>([]);
|
||||||
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
|
|
||||||
|
const reset = useCallback(() => setResults([]), []);
|
||||||
|
|
||||||
|
const zapCrew = useCallback(
|
||||||
|
async (recipients: string[], totalAmount: number, comment: string) => {
|
||||||
|
if (recipients.length === 0 || totalAmount <= 0) return;
|
||||||
|
|
||||||
|
setIsRunning(true);
|
||||||
|
const shares = splitAmount(totalAmount, recipients.length);
|
||||||
|
|
||||||
|
// Seed initial state
|
||||||
|
const initial: CrewZapResult[] = recipients.map((pubkey, i) => ({
|
||||||
|
pubkey,
|
||||||
|
amount: shares[i],
|
||||||
|
status: 'pending',
|
||||||
|
}));
|
||||||
|
setResults(initial);
|
||||||
|
|
||||||
|
const update = (pubkey: string, patch: Partial<CrewZapResult>) =>
|
||||||
|
setResults((prev) => prev.map((r) => (r.pubkey === pubkey ? { ...r, ...patch } : r)));
|
||||||
|
|
||||||
|
// Fetch metadata (kind 0) for all recipients in one query
|
||||||
|
let metaByPubkey = new Map<string, NostrMetadata>();
|
||||||
|
try {
|
||||||
|
const signal = AbortSignal.timeout(6000);
|
||||||
|
const events = await nostr.query([{ kinds: [0], authors: recipients }], { signal });
|
||||||
|
// Latest kind-0 per author
|
||||||
|
const latest = new Map<string, { created_at: number; content: string }>();
|
||||||
|
for (const ev of events) {
|
||||||
|
const existing = latest.get(ev.pubkey);
|
||||||
|
if (!existing || ev.created_at > existing.created_at) {
|
||||||
|
latest.set(ev.pubkey, { created_at: ev.created_at, content: ev.content });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
metaByPubkey = new Map(
|
||||||
|
[...latest.entries()].map(([pk, { content }]) => {
|
||||||
|
try {
|
||||||
|
return [pk, JSON.parse(content) as NostrMetadata];
|
||||||
|
} catch {
|
||||||
|
return [pk, {} as NostrMetadata];
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Continue — recipients without resolvable metadata will be skipped
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeNWC = getActiveConnection();
|
||||||
|
|
||||||
|
// Pay each recipient sequentially (keeps NWC/WebLN happy, clear progress)
|
||||||
|
for (let i = 0; i < recipients.length; i++) {
|
||||||
|
const pubkey = recipients[i];
|
||||||
|
const amount = shares[i];
|
||||||
|
if (amount <= 0) {
|
||||||
|
update(pubkey, { status: 'skipped', error: 'Share rounds to 0 sats' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = metaByPubkey.get(pubkey);
|
||||||
|
const address = meta?.lud16;
|
||||||
|
if (!address) {
|
||||||
|
update(pubkey, { status: 'skipped', error: 'No Lightning address' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(pubkey, { status: 'resolving', address });
|
||||||
|
|
||||||
|
let invoice: string;
|
||||||
|
try {
|
||||||
|
invoice = await getInvoiceFromLightningAddress({
|
||||||
|
lightningAddress: address,
|
||||||
|
amountSats: amount,
|
||||||
|
comment,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
update(pubkey, { status: 'failed', error: err instanceof Error ? err.message : 'Invoice failed' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to auto-pay
|
||||||
|
update(pubkey, { status: 'paying', invoice });
|
||||||
|
let paid = false;
|
||||||
|
|
||||||
|
if (activeNWC && activeNWC.connectionString && activeNWC.isConnected) {
|
||||||
|
try {
|
||||||
|
await sendPayment(activeNWC, invoice);
|
||||||
|
paid = true;
|
||||||
|
} catch { /* fall through to WebLN / manual */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!paid && webln) {
|
||||||
|
try {
|
||||||
|
let provider = webln;
|
||||||
|
if (webln.enable && typeof webln.enable === 'function') {
|
||||||
|
const enabled = (await webln.enable()) as typeof webln | undefined;
|
||||||
|
if (enabled) provider = enabled;
|
||||||
|
}
|
||||||
|
await provider.sendPayment(invoice);
|
||||||
|
paid = true;
|
||||||
|
} catch { /* fall through to manual */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paid) {
|
||||||
|
update(pubkey, { status: 'success' });
|
||||||
|
} else {
|
||||||
|
// Leave invoice attached for manual payment
|
||||||
|
update(pubkey, { status: 'failed', error: 'Auto-pay unavailable — pay manually', invoice });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRunning(false);
|
||||||
|
},
|
||||||
|
[nostr, sendPayment, getActiveConnection, webln]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { zapCrew, results, isRunning, reset };
|
||||||
|
}
|
||||||
|
|
@ -8,10 +8,12 @@ import { Badge } from '@/components/ui/badge';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { ZapButton } from '@/components/ZapButton';
|
import { ZapButton } from '@/components/ZapButton';
|
||||||
|
import { ZapCrewDialog } from '@/components/ZapCrewDialog';
|
||||||
import { ClinkPayButton } from '@/components/ClinkPayButton';
|
import { ClinkPayButton } from '@/components/ClinkPayButton';
|
||||||
import { CollectiveMembers } from '@/components/CollectiveMembers';
|
import { CollectiveMembers } from '@/components/CollectiveMembers';
|
||||||
import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
|
import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
|
||||||
import { LinkedLabCard } from '@/components/CrossLinkCards';
|
import { LinkedLabCard } from '@/components/CrossLinkCards';
|
||||||
|
import { useCollectiveMembers } from '@/hooks/useCollectiveMembers';
|
||||||
import { NoteContent } from '@/components/NoteContent';
|
import { NoteContent } from '@/components/NoteContent';
|
||||||
import { useCrew } from '@/hooks/useCrews';
|
import { useCrew } from '@/hooks/useCrews';
|
||||||
import { useCrewFeed } from '@/hooks/useCrewFeed';
|
import { useCrewFeed } from '@/hooks/useCrewFeed';
|
||||||
|
|
@ -21,7 +23,7 @@ import { genUserName } from '@/lib/genUserName';
|
||||||
import { SKATE_CREW_KIND, SKATE_SPOT_KIND, CREW_VIBE_LABELS } from '@/lib/types';
|
import { SKATE_CREW_KIND, SKATE_SPOT_KIND, CREW_VIBE_LABELS } from '@/lib/types';
|
||||||
import { sanitizeUrl, urlDomain } from '@/lib/sanitizeUrl';
|
import { sanitizeUrl, urlDomain } from '@/lib/sanitizeUrl';
|
||||||
import NotFound from './NotFound';
|
import NotFound from './NotFound';
|
||||||
import { Users, MapPin, ArrowLeft, Calendar, Globe, Instagram, Edit, MessageSquare } from 'lucide-react';
|
import { Users, MapPin, ArrowLeft, Calendar, Globe, Instagram, Edit, MessageSquare, Zap } from 'lucide-react';
|
||||||
import type { NostrEvent } from '@nostrify/nostrify';
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
|
||||||
function FeedItem({ event }: { event: NostrEvent }) {
|
function FeedItem({ event }: { event: NostrEvent }) {
|
||||||
|
|
@ -68,6 +70,23 @@ export default function CrewDetailPage() {
|
||||||
const feedAuthors = crew ? [crew.pubkey, ...crew.members] : [];
|
const feedAuthors = crew ? [crew.pubkey, ...crew.members] : [];
|
||||||
const { data: feed, isLoading: feedLoading } = useCrewFeed(feedAuthors);
|
const { data: feed, isLoading: feedLoading } = useCrewFeed(feedAuthors);
|
||||||
|
|
||||||
|
// Confirmed members (for "Zap the Crew" — only pay people who vouched in)
|
||||||
|
const { data: memberStatuses } = useCollectiveMembers({
|
||||||
|
kind: SKATE_CREW_KIND,
|
||||||
|
founderPubkey: crew?.pubkey ?? '',
|
||||||
|
identifier: crew?.identifier ?? '',
|
||||||
|
claimedMembers: crew?.members ?? [],
|
||||||
|
enabled: !!crew && (crew.members.length ?? 0) > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recipients: founder + confirmed members (dedup, founder always included)
|
||||||
|
const crewZapRecipients = crew
|
||||||
|
? Array.from(new Set([
|
||||||
|
crew.pubkey,
|
||||||
|
...(memberStatuses ?? []).filter((m) => m.status === 'confirmed').map((m) => m.pubkey),
|
||||||
|
]))
|
||||||
|
: [];
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
|
|
@ -257,6 +276,16 @@ export default function CrewDetailPage() {
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<div className="pt-3 border-t border-gray-700 space-y-2">
|
<div className="pt-3 border-t border-gray-700 space-y-2">
|
||||||
|
{/* Zap the whole crew — split across founder + confirmed members */}
|
||||||
|
{crewZapRecipients.length > 1 && (
|
||||||
|
<ZapCrewDialog crewName={crew.name} recipients={crewZapRecipients}>
|
||||||
|
<Button className="w-full justify-center gap-2 bg-teal-500 hover:bg-teal-600 font-bold">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
<Zap className="h-4 w-4" />
|
||||||
|
Zap the Crew
|
||||||
|
</Button>
|
||||||
|
</ZapCrewDialog>
|
||||||
|
)}
|
||||||
<ZapButton target={crew.event} className="w-full justify-center" />
|
<ZapButton target={crew.event} className="w-full justify-center" />
|
||||||
{crew.noffer && (
|
{crew.noffer && (
|
||||||
<ClinkPayButton noffer={crew.noffer} recipientName={crew.name} className="w-full justify-center" />
|
<ClinkPayButton noffer={crew.noffer} recipientName={crew.name} className="w-full justify-center" />
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue