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:
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user