- 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>
218 lines
9 KiB
TypeScript
218 lines
9 KiB
TypeScript
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>
|
|
);
|
|
}
|