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()} + + +
+
To
+
{displayName}
+
+
+ + {/* 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' } } },