From f881c6513f1cd35b0be5410184cf3107b146bbab Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Fri, 24 Jul 2026 14:31:45 +0700 Subject: [PATCH] Zap a whole crew: split a zap across confirmed members MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/components/ZapCrewDialog.tsx | 218 +++++++++++++++++++++++++++++++ src/hooks/useCrewZap.ts | 162 +++++++++++++++++++++++ src/pages/CrewDetailPage.tsx | 31 ++++- 3 files changed, 410 insertions(+), 1 deletion(-) create mode 100644 src/components/ZapCrewDialog.tsx create mode 100644 src/hooks/useCrewZap.ts diff --git a/src/components/ZapCrewDialog.tsx b/src/components/ZapCrewDialog.tsx new file mode 100644 index 0000000..2e66f1f --- /dev/null +++ b/src/components/ZapCrewDialog.tsx @@ -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 ( +
+ + + {name.slice(0, 2).toUpperCase()} + +
+

{name}

+

{result.amount.toLocaleString()} sats

+
+ +
+ ); +} + +function StatusPill({ result, onCopy }: { result: CrewZapResult; onCopy: () => void }) { + switch (result.status) { + case 'success': + return Sent; + case 'resolving': + case 'paying': + return {result.status === 'paying' ? 'Paying' : 'Resolving'}; + case 'skipped': + return Skipped; + case 'failed': + return result.invoice ? ( + + ) : ( + Failed + ); + default: + return Waiting; + } +} + +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(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 ( + + +
{children}
+
+ + + + Zap the Crew + + + Split a zap evenly across {payable.length} crew {payable.length === 1 ? 'member' : 'members'} with a Lightning address. + + + + {payable.length === 0 ? ( +
+ No other crew members to zap right now. +
+ ) : !started ? ( +
+
+ + v && setAmount(parseInt(v, 10))} + className="grid grid-cols-5 gap-1 mt-2" + > + {PRESETS.map((p) => ( + + + {p >= 1000 ? `${p / 1000}k` : p} + + ))} + + setAmount(e.target.value)} + placeholder="Custom total" + className="mt-2 bg-gray-800 border-gray-700" + /> +
+ + {numAmount > 0 && ( +

+ β‰ˆ {perHead[0]?.toLocaleString()} sats each + {perHead.length > 1 && perHead[0] !== perHead[perHead.length - 1] && ' (rounded)'} +

+ )} + +