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:
shakespeare.diy 2026-07-24 14:31:45 +07:00
parent d8d1ba32c9
commit f881c6513f
3 changed files with 410 additions and 1 deletions

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

View file

@ -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" />