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 (
+
+ );
+}
diff --git a/src/hooks/useCrewZap.ts b/src/hooks/useCrewZap.ts
new file mode 100644
index 0000000..a3bb514
--- /dev/null
+++ b/src/hooks/useCrewZap.ts
@@ -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([]);
+ 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) =>
+ 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();
+ 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();
+ 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 };
+}
diff --git a/src/pages/CrewDetailPage.tsx b/src/pages/CrewDetailPage.tsx
index b2cd6bf..45c5b7b 100644
--- a/src/pages/CrewDetailPage.tsx
+++ b/src/pages/CrewDetailPage.tsx
@@ -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 (
@@ -257,6 +276,16 @@ export default function CrewDetailPage() {
})}
+ {/* Zap the whole crew β split across founder + confirmed members */}
+ {crewZapRecipients.length > 1 && (
+
+
+
+ )}
{crew.noffer && (