9190f62b9e
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(-).
167 lines
5.9 KiB
TypeScript
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>
|
|
);
|
|
}
|