Show a grand success screen after a successful zap

Previously, a successful send from the Zap dialog auto-closed and surfaced a
toast. That undersold what just happened — the user sent Bitcoin. Now both
rails (on-chain + Lightning) flip the dialog over to a dedicated success
screen with an animated check, amount, recipient card, rail indicator, and
(for on-chain) a "View transaction" link to mempool.space. The dialog
auto-dismisses after six seconds if the user walks away.
This commit is contained in:
Alex Gleason
2026-05-07 14:55:57 -07:00
parent 4b9fe24b25
commit 5c2c35130f
6 changed files with 357 additions and 42 deletions
+11 -4
View File
@@ -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}
/>
);
}
+63 -14
View File
@@ -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 <ZapSuccessScreen />.
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) {
<DialogContent className="max-w-[425px] rounded-2xl p-0 gap-0 border-border overflow-hidden max-h-[95vh] [&>button]:hidden" data-testid="zap-modal">
<div className="flex items-center justify-between px-4 h-12">
<DialogTitle className="text-base font-semibold flex items-center gap-1.5">
{invoice
? 'Lightning Payment'
: 'Send Bitcoin'}{' '}
<HelpTip
faqId={
invoice || activeTab === 'lightning'
? 'send-bitcoin-lightning'
: 'send-bitcoin-onchain'
}
/>
{success
? 'Success'
: invoice
? 'Lightning Payment'
: 'Send Bitcoin'}{' '}
{!success && (
<HelpTip
faqId={
invoice || activeTab === 'lightning'
? 'send-bitcoin-lightning'
: 'send-bitcoin-onchain'
}
/>
)}
</DialogTitle>
<button
onClick={() => setOpen(false)}
@@ -480,7 +508,16 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
</button>
</div>
<div className="overflow-y-auto">
{hasLightning ? (
{success ? (
<ZapSuccessScreen
recipientPubkey={target.pubkey}
amountSats={success.amountSats}
btcPrice={btcPrice}
kind={success.kind}
txid={success.kind === 'onchain' ? success.txid : undefined}
onClose={() => setOpen(false)}
/>
) : hasLightning ? (
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'onchain' | 'lightning')} className="w-full">
<div className="px-4 pt-2">
<TabsList className="grid w-full grid-cols-2 h-9">
@@ -493,14 +530,26 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
</TabsList>
</div>
<TabsContent value="onchain" className="mt-0">
<OnchainZapContent target={target} onSuccess={() => setOpen(false)} />
<OnchainZapContent
target={target}
onSuccess={({ txid, amountSats }) =>
setSuccess({ kind: 'onchain', amountSats, txid })
}
onClose={() => setOpen(false)}
/>
</TabsContent>
<TabsContent value="lightning" className="mt-0">
<LightningZapContent {...lightningContentProps} />
</TabsContent>
</Tabs>
) : (
<OnchainZapContent target={target} onSuccess={() => setOpen(false)} />
<OnchainZapContent
target={target}
onSuccess={({ txid, amountSats }) =>
setSuccess({ kind: 'onchain', amountSats, txid })
}
onClose={() => setOpen(false)}
/>
)}
</div>
</DialogContent>
+221
View File
@@ -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' ? (
<Zap className="size-3.5" />
) : (
<Bitcoin className="size-3.5" />
);
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 (
<div
role="status"
aria-live="polite"
className="relative grid gap-5 px-6 py-8 w-full overflow-hidden text-center motion-safe:animate-success-fade-up"
>
{/* Soft radial glow behind the whole card. Pure decoration. */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(circle_at_50%_35%,hsl(var(--primary)/0.18),transparent_65%)]"
/>
{/* Check + halo + sparkles */}
<div className="relative mx-auto flex size-28 items-center justify-center">
{/* Expanding halo ring */}
<span
aria-hidden
className="absolute inset-0 rounded-full bg-gradient-to-br from-amber-400/40 to-orange-500/30 motion-safe:animate-success-halo"
/>
{/* Solid gradient disc */}
<span
aria-hidden
className="absolute inset-0 rounded-full bg-gradient-to-br from-amber-400 to-orange-500 shadow-lg shadow-orange-500/30 motion-safe:animate-success-pop"
/>
{/* Checkmark */}
<Check
className="relative size-14 text-white drop-shadow-sm motion-safe:animate-success-pop"
strokeWidth={3}
aria-hidden
/>
{/* Sparkle burst */}
<div aria-hidden className="pointer-events-none absolute inset-0 motion-reduce:hidden">
{sparkles.map((s) => (
<span
key={s.id}
className={`absolute left-1/2 top-1/2 size-1.5 rounded-full ${s.hue} motion-safe:animate-success-spark`}
style={
{
'--spark-x': `${s.x}px`,
'--spark-y': `${s.y}px`,
animationDelay: `${s.delay}s`,
} as React.CSSProperties
}
/>
))}
</div>
</div>
{/* Headline + amount */}
<div className="grid gap-1">
<h2 className="text-lg font-semibold tracking-tight">
Bitcoin sent
</h2>
<div className="text-4xl font-bold tabular-nums bg-gradient-to-br from-amber-500 to-orange-600 bg-clip-text text-transparent">
{usdDisplay || `${formatSats(amountSats)} sats`}
</div>
{usdDisplay && (
<div className="text-xs text-muted-foreground tabular-nums">
{formatSats(amountSats)} sats
</div>
)}
</div>
{/* Recipient card */}
<div className="mx-auto flex items-center gap-3 rounded-full border border-border/70 bg-muted/40 pl-2 pr-4 py-2 max-w-full">
<Avatar shape={avatarShape} className="size-8 shrink-0">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-xs">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 text-left">
<div className="text-[11px] text-muted-foreground leading-tight">To</div>
<div className="text-sm font-medium truncate max-w-[220px]">{displayName}</div>
</div>
</div>
{/* Rail indicator */}
<div className="flex items-center justify-center gap-1.5 text-xs text-muted-foreground">
<span className="text-orange-500">{railIcon}</span>
<span>{railLabel}</span>
</div>
{/* Actions */}
<div className="grid gap-2">
{kind === 'onchain' && txid && (
<Button
type="button"
variant="outline"
onClick={viewOnMempool}
className="w-full"
>
<ExternalLink className="size-4 mr-2" />
View transaction
</Button>
)}
<Button type="button" onClick={onClose} className="w-full">
Done
</Button>
</div>
{/* Auto-close progress hairline. Hidden when autoCloseMs is 0. */}
{autoCloseMs > 0 && (
<div
aria-hidden
className="absolute bottom-0 left-0 h-0.5 bg-gradient-to-r from-amber-400 to-orange-500 transition-[width] duration-100 ease-linear"
style={{ width: `${progressRatio * 100}%` }}
/>
)}
</div>
);
}
+16 -8
View File
@@ -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
+19 -15
View File
@@ -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);
+27 -1
View File
@@ -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'
}
}
},