SkateSpots/src/components/ZapCrewDialog.tsx
shakespeare.diy f881c6513f 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>
2026-07-24 14:31:45 +07:00

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