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 { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ZapButton } from '@/components/ZapButton';
|
||||
import { ZapCrewDialog } from '@/components/ZapCrewDialog';
|
||||
import { ClinkPayButton } from '@/components/ClinkPayButton';
|
||||
import { CollectiveMembers } from '@/components/CollectiveMembers';
|
||||
import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
|
||||
import { LinkedLabCard } from '@/components/CrossLinkCards';
|
||||
import { useCollectiveMembers } from '@/hooks/useCollectiveMembers';
|
||||
import { NoteContent } from '@/components/NoteContent';
|
||||
import { useCrew } from '@/hooks/useCrews';
|
||||
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 { sanitizeUrl, urlDomain } from '@/lib/sanitizeUrl';
|
||||
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';
|
||||
|
||||
function FeedItem({ event }: { event: NostrEvent }) {
|
||||
|
|
@ -68,6 +70,23 @@ export default function CrewDetailPage() {
|
|||
const feedAuthors = crew ? [crew.pubkey, ...crew.members] : [];
|
||||
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) {
|
||||
return (
|
||||
<Layout>
|
||||
|
|
@ -257,6 +276,16 @@ export default function CrewDetailPage() {
|
|||
})}
|
||||
|
||||
<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" />
|
||||
{crew.noffer && (
|
||||
<ClinkPayButton noffer={crew.noffer} recipientName={crew.name} className="w-full justify-center" />
|
||||
|
|
|
|||
Loading…
Reference in a new issue