Files
eranos/src/components/ZapSuccessScreen.tsx
T
Alex Gleason 9190f62b9e Consolidate /bitcoin into /wallet, drop Lightning custody
Agora previously shipped two parallel wallets: a heavy 6,400-line Breez
SDK Lightning wallet at /wallet and a lightweight on-chain Taproot view
at /bitcoin derived from the user's Nostr pubkey. Maintaining two key
custody models, two send flows, two zap paths (Lightning via Spark,
on-chain via PSBT), and the Spark-specific UI (CreateWallet, mnemonic
backup/restore, lock screen, payment history, etc.) didn't pay for itself
once on-chain Bitcoin signing via NIP-07/NIP-46 became viable.

This consolidation aligns Agora with Ditto's wallet model:

  - The on-chain Taproot view from /bitcoin becomes the only /wallet UI.
  - /bitcoin redirects to /wallet for back-compat; sidebar and TopNav
    drop the duplicate Bitcoin entry.
  - The Breez/Spark wallet stack is removed: SparkWalletProvider,
    SparkWalletContext, all of src/components/SparkWallet/*, useSparkWallet,
    useCommunityBatchZaps, usePaymentContext, WalletSettingsContent, and
    LightningEffect are deleted (~6,400 lines).
  - Ditto's mature bitcoin/zap stack is ported: useOnchainZap (single-event
    on-chain zaps + kind 8333 receipts), OnchainZapContent, ZapDialog with
    Bitcoin/Lightning tabs, ZapSuccessScreen, BitcoinContentHeader, and the
    larger SendBitcoinDialog. useZaps loses its breezService branch and
    falls back to NWC → WebLN → manual QR.
  - bitcoin.ts now threads esploraBaseUrl through every call, matching
    AppConfig and allowing future relay/Esplora customization.
  - CommunityZapDialog is bitcoin-only; CommunityDetailPage drops the
    sibling Lightning trigger.

Lightning recovery remains intentional. A small "Looking for your old
wallet?" link on /wallet routes to /wallet/recovery, which lazy-loads
@breeztech/breez-sdk-spark (now in its own 67 KB chunk plus the WASM)
only when a user needs to evacuate funds. The recovery page:

  - Auto-detects the NIP-78 kind-30078 d="spark-wallet-backup" relay
    backup and offers one-click NIP-44 decrypt via the user's signer.
  - Accepts a manual 12-word mnemonic as fallback.
  - Connects Breez in-memory, sweeps the entire on-chain balance to the
    user's Nostr-derived Taproot address, then disconnects. Nothing is
    persisted; the old wallet is never "restored" — only evacuated.

Other small carry-overs from Ditto needed by the ported code:
useFormatMoney + AppConfig.currencyDisplay ("usd" | "sats"), and the
nostrId helper (HexId branded type + isNostrId validator).

48 files changed, 2,464 insertions(+), 9,743 deletions(-).
2026-05-17 22:53:20 -05:00

167 lines
5.9 KiB
TypeScript

import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Check, ExternalLink } from 'lucide-react';
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 { 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;
/** Bitcoin txid (onchain only). Enables the "View transaction" link to the in-app tx detail page. */
txid?: string;
/** Close handler invoked by the "Done" button. */
onClose: () => void;
}
/**
* 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,
txid,
onClose,
}: 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],
);
// 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',
};
}),
[],
);
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 || `${amountSats.toLocaleString()} 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>
{/* Actions */}
<div className="grid gap-2">
{txid && (
<Button
type="button"
variant="outline"
asChild
className="w-full"
>
<Link to={`/i/bitcoin:tx:${txid}`} onClick={onClose}>
<ExternalLink className="size-4 mr-2" />
View transaction
</Link>
</Button>
)}
<Button type="button" onClick={onClose} className="w-full">
Done
</Button>
</div>
</div>
);
}