diff --git a/src/components/OnchainZapContent.tsx b/src/components/OnchainZapContent.tsx
index b843aa4f..f258c258 100644
--- a/src/components/OnchainZapContent.tsx
+++ b/src/components/OnchainZapContent.tsx
@@ -75,7 +75,11 @@ function getUniqueFeeSpeeds(
interface OnchainZapContentProps {
target: NostrEvent;
- onSuccess?: () => void;
+ /** Called with the tx result when a zap successfully broadcasts. */
+ onSuccess?: (result: { txid: string; amountSats: number }) => void;
+ /** Called when the user dismisses without a send (e.g. "Done" in the
+ * unsupported-signer QR fallback). */
+ onClose?: () => void;
}
/**
@@ -86,7 +90,7 @@ interface OnchainZapContentProps {
* UX mirrors the Lightning zap flow: one screen, one button, no review step.
* Balance, fee breakdown, and confirmation are all hidden unless needed.
*/
-export function OnchainZapContent({ target, onSuccess }: OnchainZapContentProps) {
+export function OnchainZapContent({ target, onSuccess, onClose }: OnchainZapContentProps) {
const { user } = useCurrentUser();
const { capability } = useBitcoinSigner();
const { logins } = useNostrLogin();
@@ -202,7 +206,10 @@ export function OnchainZapContent({ target, onSuccess }: OnchainZapContentProps)
setConfirmArmed(false);
}, [amountSats, currentFeeRate, btcPrice]);
- const { zapAsync, isZapping, progress } = useOnchainZap(target, onSuccess);
+ const { zapAsync, isZapping, progress } = useOnchainZap(target, (result) => {
+ // Forward the txid + amount so the dialog can render its success screen.
+ onSuccess?.({ txid: result.txid, amountSats: result.amountSats });
+ });
const handleZap = useCallback(async () => {
setError('');
@@ -273,7 +280,7 @@ export function OnchainZapContent({ target, onSuccess }: OnchainZapContentProps)
usdAmount={usdAmount}
setUsdAmount={setUsdAmount}
loginType={loginType}
- onClose={onSuccess}
+ onClose={onClose}
/>
);
}
diff --git a/src/components/ZapDialog.tsx b/src/components/ZapDialog.tsx
index 53593165..3330c4d7 100644
--- a/src/components/ZapDialog.tsx
+++ b/src/components/ZapDialog.tsx
@@ -16,6 +16,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { QRCodeCanvas } from '@/components/ui/qrcode';
import { OnchainZapContent } from '@/components/OnchainZapContent';
+import { ZapSuccessScreen } from '@/components/ZapSuccessScreen';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useBitcoinSigner } from '@/hooks/useBitcoinSigner';
@@ -286,7 +287,28 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
const { data: author } = useAuthor(target.pubkey);
const { toast } = useToast();
const { webln, activeNWC } = useWallet();
- const { zap, isZapping, invoice, setInvoice } = useZaps(target, webln, activeNWC, () => setOpen(false));
+
+ // Success state: populated by either zap rail's onSuccess callback.
+ // When set, we replace the tab UI with .
+ const [success, setSuccess] = useState<
+ | { kind: 'onchain'; amountSats: number; txid: string }
+ | { kind: 'lightning'; amountSats: number }
+ | null
+ >(null);
+
+ const handleLightningSuccess = useCallback(
+ ({ amountSats }: { amountSats: number }) => {
+ setSuccess({ kind: 'lightning', amountSats });
+ },
+ [],
+ );
+
+ const { zap, isZapping, invoice, setInvoice } = useZaps(
+ target,
+ webln,
+ activeNWC,
+ handleLightningSuccess,
+ );
// USD-denominated state (matches OnchainZapContent). The sats amount is
// derived just before we hit the LNURL endpoint.
@@ -375,6 +397,7 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
setEditingAmount(false);
setError('');
setConfirmArmed(false);
+ setSuccess(null);
setActiveTab(bitcoinUnsupported && hasLightning ? 'lightning' : 'onchain');
} else {
setUsdAmount(0.5);
@@ -383,6 +406,7 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
setEditingAmount(false);
setError('');
setConfirmArmed(false);
+ setSuccess(null);
}
// `bitcoinUnsupported`/`hasLightning` deliberately excluded — we only
// want to reset the active tab on open/close, not on every capability
@@ -461,16 +485,20 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
- {invoice
- ? 'Lightning Payment'
- : 'Send Bitcoin'}{' '}
-
+ {success
+ ? 'Success'
+ : invoice
+ ? 'Lightning Payment'
+ : 'Send Bitcoin'}{' '}
+ {!success && (
+
+ )}
- {hasLightning ? (
+ {success ? (
+
setOpen(false)}
+ />
+ ) : hasLightning ? (
setActiveTab(v as 'onchain' | 'lightning')} className="w-full">
@@ -493,14 +530,26 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
- setOpen(false)} />
+
+ setSuccess({ kind: 'onchain', amountSats, txid })
+ }
+ onClose={() => setOpen(false)}
+ />
) : (
- setOpen(false)} />
+
+ setSuccess({ kind: 'onchain', amountSats, txid })
+ }
+ onClose={() => setOpen(false)}
+ />
)}
diff --git a/src/components/ZapSuccessScreen.tsx b/src/components/ZapSuccessScreen.tsx
new file mode 100644
index 00000000..d7c07304
--- /dev/null
+++ b/src/components/ZapSuccessScreen.tsx
@@ -0,0 +1,221 @@
+import { useEffect, useMemo, useState } from 'react';
+import { Check, ExternalLink, Zap, Bitcoin } from 'lucide-react';
+import { openUrl } from '@/lib/downloadFile';
+import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
+import { Button } from '@/components/ui/button';
+import { getAvatarShape } from '@/lib/avatarShape';
+import { useAuthor } from '@/hooks/useAuthor';
+import { formatSats, satsToUSD } from '@/lib/bitcoin';
+import { genUserName } from '@/lib/genUserName';
+
+interface ZapSuccessScreenProps {
+ /** Recipient pubkey (hex). Used to resolve the author avatar + name. */
+ recipientPubkey: string;
+ /** Amount sent in satoshis. */
+ amountSats: number;
+ /** Current BTC/USD price for display; optional, falls back to sats only. */
+ btcPrice: number | undefined;
+ /** Payment rail used. Drives iconography + label. */
+ kind: 'onchain' | 'lightning';
+ /** Bitcoin txid (onchain only). Used for the mempool.space link. */
+ txid?: string;
+ /** Close handler (the "Done" button and auto-dismiss timer both call this). */
+ onClose: () => void;
+ /** Auto-dismiss delay in ms. Set to 0 to disable. Defaults to 6 seconds. */
+ autoCloseMs?: number;
+}
+
+/**
+ * Grand confirmation screen shown after a successful Bitcoin send in the
+ * ZapDialog. Replaces the previous toast-and-auto-close behavior with a
+ * dedicated celebration moment: animated checkmark, expanding halo, a
+ * confetti-adjacent sparkle burst, the amount sent, the recipient, and
+ * a "View transaction" shortcut when we have a txid on hand.
+ *
+ * Respects `prefers-reduced-motion`: the entrance animations collapse to a
+ * simple fade and the sparkle burst is suppressed.
+ */
+export function ZapSuccessScreen({
+ recipientPubkey,
+ amountSats,
+ btcPrice,
+ kind,
+ txid,
+ onClose,
+ autoCloseMs = 6000,
+}: ZapSuccessScreenProps) {
+ const { data: author } = useAuthor(recipientPubkey);
+ const metadata = author?.metadata;
+ const displayName = metadata?.name || metadata?.display_name || genUserName(recipientPubkey);
+ const avatarShape = getAvatarShape(metadata);
+
+ const usdDisplay = useMemo(
+ () => (btcPrice ? satsToUSD(amountSats, btcPrice) : ''),
+ [amountSats, btcPrice],
+ );
+
+ // Auto-dismiss fallback. Pause + show a subtle progress bar so the user
+ // knows the dialog will close on its own if they walk away.
+ const [remainingMs, setRemainingMs] = useState(autoCloseMs);
+ useEffect(() => {
+ if (!autoCloseMs) return;
+ const started = performance.now();
+ const id = window.setInterval(() => {
+ const elapsed = performance.now() - started;
+ const left = Math.max(0, autoCloseMs - elapsed);
+ setRemainingMs(left);
+ if (left <= 0) {
+ window.clearInterval(id);
+ onClose();
+ }
+ }, 100);
+ return () => window.clearInterval(id);
+ }, [autoCloseMs, onClose]);
+
+ // Sparkle burst positions: 8 particles radiating outward from the
+ // checkmark, each with a slightly offset delay so the burst reads organic
+ // rather than synchronised.
+ const sparkles = useMemo(
+ () =>
+ Array.from({ length: 8 }, (_, i) => {
+ const angle = (i / 8) * Math.PI * 2;
+ const radius = 58;
+ return {
+ id: i,
+ x: Math.cos(angle) * radius,
+ y: Math.sin(angle) * radius,
+ delay: 0.15 + (i % 4) * 0.05,
+ hue: i % 2 === 0 ? 'bg-amber-400' : 'bg-orange-500',
+ };
+ }),
+ [],
+ );
+
+ const railIcon = kind === 'lightning' ? (
+
+ ) : (
+
+ );
+ const railLabel = kind === 'lightning' ? 'Sent via Lightning' : 'Sent via Bitcoin';
+
+ const viewOnMempool = () => {
+ if (txid) openUrl(`https://mempool.space/tx/${txid}`);
+ };
+
+ const progressRatio = autoCloseMs ? remainingMs / autoCloseMs : 0;
+
+ return (
+
+ {/* Soft radial glow behind the whole card. Pure decoration. */}
+
+
+ {/* Check + halo + sparkles */}
+
+ {/* Expanding halo ring */}
+
+
+ {/* Solid gradient disc */}
+
+
+ {/* Checkmark */}
+
+
+ {/* Sparkle burst */}
+
+ {sparkles.map((s) => (
+
+ ))}
+
+
+
+ {/* Headline + amount */}
+
+
+ Bitcoin sent
+
+
+ {usdDisplay || `${formatSats(amountSats)} sats`}
+
+ {usdDisplay && (
+
+ {formatSats(amountSats)} sats
+
+ )}
+
+
+ {/* Recipient card */}
+
+
+
+
+ {displayName[0]?.toUpperCase()}
+
+
+
+
+
+ {/* Rail indicator */}
+
+ {railIcon}
+ {railLabel}
+
+
+ {/* Actions */}
+
+ {kind === 'onchain' && txid && (
+
+ )}
+
+
+
+ {/* Auto-close progress hairline. Hidden when autoCloseMs is 0. */}
+ {autoCloseMs > 0 && (
+
+ )}
+
+ );
+}
diff --git a/src/hooks/useOnchainZap.ts b/src/hooks/useOnchainZap.ts
index abe8f03b..2aa6f81c 100644
--- a/src/hooks/useOnchainZap.ts
+++ b/src/hooks/useOnchainZap.ts
@@ -44,6 +44,8 @@ interface OnchainZapArgs {
interface OnchainZapResult {
/** The broadcast Bitcoin transaction ID. */
txid: string;
+ /** Amount sent in satoshis. */
+ amountSats: number;
/** Fee paid in satoshis. */
fee: number;
/** The published kind 8333 event. */
@@ -64,7 +66,7 @@ interface OnchainZapResult {
*/
export function useOnchainZap(
target: NostrEvent,
- onSuccess?: () => void,
+ onSuccess?: (result: OnchainZapResult) => void,
) {
const { user } = useCurrentUser();
const { canSignPsbt, signPsbt } = useBitcoinSigner();
@@ -160,21 +162,27 @@ export function useOnchainZap(
tags,
});
- return { txid, fee, event };
+ return { txid, amountSats, fee, event };
},
- onSuccess: ({ txid, fee }) => {
+ onSuccess: (result) => {
notificationSuccess();
- toast({
- title: 'Bitcoin zap sent!',
- description: `Broadcast txid ${txid.slice(0, 12)}… (fee ${fee.toLocaleString()} sats)`,
- });
// Invalidate caches that track zaps / balances
queryClient.invalidateQueries({ queryKey: ['onchain-zaps'] });
queryClient.invalidateQueries({ queryKey: ['event-interactions'] });
queryClient.invalidateQueries({ queryKey: ['bitcoin-utxos'] });
queryClient.invalidateQueries({ queryKey: ['bitcoin-balance'] });
queryClient.invalidateQueries({ queryKey: ['bitcoin-txs'] });
- onSuccess?.();
+ // If the caller opted into handling success themselves (e.g. the
+ // ZapDialog shows a grand confirmation screen and owns the dismiss),
+ // skip the built-in toast — the screen is the feedback.
+ if (onSuccess) {
+ onSuccess(result);
+ } else {
+ toast({
+ title: 'Bitcoin zap sent!',
+ description: `Broadcast txid ${result.txid.slice(0, 12)}… (fee ${result.fee.toLocaleString()} sats)`,
+ });
+ }
},
onError: (err) => {
// If the signer turned out to not support PSBT signing (common for
diff --git a/src/hooks/useZaps.ts b/src/hooks/useZaps.ts
index 204574d1..8237b745 100644
--- a/src/hooks/useZaps.ts
+++ b/src/hooks/useZaps.ts
@@ -20,7 +20,7 @@ export function useZaps(
target: Event,
webln: WebLNProvider | null,
_nwcConnection: NWCConnection | null,
- onZapSuccess?: () => void
+ onZapSuccess?: (result: { amountSats: number }) => void
) {
const { toast } = useToast();
const { user } = useCurrentUser();
@@ -163,16 +163,19 @@ export function useZaps(
setInvoice(null);
notificationSuccess();
- toast({
- title: 'Zap successful!',
- description: `You sent ${amount} sats via NWC to the author.`,
- });
-
// Invalidate zap queries to refresh counts
queryClient.invalidateQueries({ queryKey: ['zaps'] });
- // Close dialog last to ensure clean state
- onZapSuccess?.();
+ if (onZapSuccess) {
+ // Consumer (e.g. ZapDialog) owns the success UI — skip the
+ // toast so we don't double up with their celebration screen.
+ onZapSuccess({ amountSats: amount });
+ } else {
+ toast({
+ title: 'Zap successful!',
+ description: `You sent ${amount} sats via NWC to the author.`,
+ });
+ }
return;
} catch (nwcError) {
console.error('NWC payment failed, falling back:', nwcError);
@@ -208,16 +211,17 @@ export function useZaps(
setInvoice(null);
notificationSuccess();
- toast({
- title: 'Zap successful!',
- description: `You sent ${amount} sats to the author.`,
- });
-
// Invalidate zap queries to refresh counts
queryClient.invalidateQueries({ queryKey: ['zaps'] });
- // Close dialog last to ensure clean state
- onZapSuccess?.();
+ if (onZapSuccess) {
+ onZapSuccess({ amountSats: amount });
+ } else {
+ toast({
+ title: 'Zap successful!',
+ description: `You sent ${amount} sats to the author.`,
+ });
+ }
} catch (weblnError) {
console.error('WebLN payment failed, falling back:', weblnError);
diff --git a/tailwind.config.ts b/tailwind.config.ts
index ba2716fa..f3a226ca 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -127,6 +127,28 @@ export default {
// an organic audio indicator.
'0%, 100%': { transform: 'scaleY(0.35)' },
'50%': { transform: 'scaleY(1)' }
+ },
+ 'success-pop': {
+ // Celebratory pop-in for the zap success checkmark.
+ '0%': { transform: 'scale(0.3)', opacity: '0' },
+ '60%': { transform: 'scale(1.15)', opacity: '1' },
+ '100%': { transform: 'scale(1)', opacity: '1' }
+ },
+ 'success-halo': {
+ // Expanding ring behind the checkmark.
+ '0%': { transform: 'scale(0.6)', opacity: '0.7' },
+ '100%': { transform: 'scale(2.2)', opacity: '0' }
+ },
+ 'success-fade-up': {
+ // Staggered fade-in from below for the body text + actions.
+ '0%': { transform: 'translateY(8px)', opacity: '0' },
+ '100%': { transform: 'translateY(0)', opacity: '1' }
+ },
+ 'success-spark': {
+ // Individual sparkle: scale + drift outward then fade.
+ '0%': { transform: 'translate(0, 0) scale(0.4)', opacity: '0' },
+ '20%': { opacity: '1' },
+ '100%': { transform: 'translate(var(--spark-x, 0), var(--spark-y, 0)) scale(1)', opacity: '0' }
}
},
animation: {
@@ -137,7 +159,11 @@ export default {
'highlight-fade': 'highlight-fade 1.5s ease-out forwards',
'collapsible-down': 'collapsible-down 0.2s ease-out',
'collapsible-up': 'collapsible-up 0.2s ease-out',
- 'equaliser-bar': 'equaliser-bar 0.9s ease-in-out infinite'
+ 'equaliser-bar': 'equaliser-bar 0.9s ease-in-out infinite',
+ 'success-pop': 'success-pop 0.55s cubic-bezier(0.34, 1.56, 0.64, 1) both',
+ 'success-halo': 'success-halo 0.9s ease-out both',
+ 'success-fade-up': 'success-fade-up 0.45s ease-out both',
+ 'success-spark': 'success-spark 1.1s ease-out both'
}
}
},