adding toast and displaying fees in the swap-form and modal

This commit is contained in:
2025-10-20 18:09:16 -04:00
parent cdbf2a57e6
commit caf6bff469
4 changed files with 259 additions and 74 deletions

View File

@@ -10,6 +10,7 @@ import { defineChain } from 'viem';
import { TranslationsProvider } from '@/providers/translations-provider'; import { TranslationsProvider } from '@/providers/translations-provider';
import { Header } from '@/components/header'; import { Header } from '@/components/header';
import { ToastProvider } from '@/components/ui/toast';
const mockchain = defineChain({ const mockchain = defineChain({
id: 31337, id: 31337,
@@ -87,12 +88,14 @@ export function Providers({ children }: { children: React.ReactNode }) {
storageKey="liquidity-party-theme" storageKey="liquidity-party-theme"
> >
<Web3Provider> <Web3Provider>
<div className="min-h-screen flex flex-col"> <ToastProvider>
<Header /> <div className="min-h-screen flex flex-col">
<main className="flex-1 container mx-auto px-4 py-8"> <Header />
{children} <main className="flex-1 container mx-auto px-4 py-8">
</main> {children}
</div> </main>
</div>
</ToastProvider>
</Web3Provider> </Web3Provider>
</ThemeProvider> </ThemeProvider>
</TranslationsProvider> </TranslationsProvider>

View File

@@ -5,25 +5,26 @@ import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ArrowDownUp, ChevronDown } from 'lucide-react'; import { ArrowDownUp, ChevronDown, Settings } from 'lucide-react';
import { useAccount } from 'wagmi'; import { useAccount } from 'wagmi';
import { useTokenDetails, useGetPoolsByToken, type TokenDetails } from '@/hooks/usePartyPlanner'; import { useTokenDetails, useGetPoolsByToken, type TokenDetails } from '@/hooks/usePartyPlanner';
import { useSwapAmounts, useSwap, selectBestSwapRoute } from '@/hooks/usePartyPool'; import { useSwapAmounts, useSwap, selectBestSwapRoute } from '@/hooks/usePartyPool';
import { formatUnits, parseUnits } from 'viem'; import { formatUnits, parseUnits } from 'viem';
import { SwapReviewModal } from './swap-review-modal'; import { SwapReviewModal } from './swap-review-modal';
import { useToast } from '@/components/ui/toast';
export function SwapForm() { export function SwapForm() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isConnected, address } = useAccount(); const { isConnected, address } = useAccount();
const { addToast, updateToast } = useToast();
const [fromAmount, setFromAmount] = useState(''); const [fromAmount, setFromAmount] = useState('');
const [toAmount, setToAmount] = useState(''); const [toAmount, setToAmount] = useState('');
const [selectedFromToken, setSelectedFromToken] = useState<TokenDetails | null>(null); const [selectedFromToken, setSelectedFromToken] = useState<TokenDetails | null>(null);
const [selectedToToken, setSelectedToToken] = useState<TokenDetails | null>(null); const [selectedToToken, setSelectedToToken] = useState<TokenDetails | null>(null);
const [isFromDropdownOpen, setIsFromDropdownOpen] = useState(false); const [isFromDropdownOpen, setIsFromDropdownOpen] = useState(false);
const [isToDropdownOpen, setIsToDropdownOpen] = useState(false); const [isToDropdownOpen, setIsToDropdownOpen] = useState(false);
const [slippage, setSlippage] = useState<number>(0.5); // Default 0.5% const [maxSlippage, setMaxSlippage] = useState<string>('5.5'); // Default 5.5%
const [customSlippage, setCustomSlippage] = useState<string>(''); const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isCustomSlippage, setIsCustomSlippage] = useState(false);
const [isReviewModalOpen, setIsReviewModalOpen] = useState(false); const [isReviewModalOpen, setIsReviewModalOpen] = useState(false);
const fromDropdownRef = useRef<HTMLDivElement>(null); const fromDropdownRef = useRef<HTMLDivElement>(null);
const toDropdownRef = useRef<HTMLDivElement>(null); const toDropdownRef = useRef<HTMLDivElement>(null);
@@ -45,8 +46,8 @@ export function SwapForm() {
return null; return null;
}, [selectedFromToken, selectedToToken, availableTokens]); }, [selectedFromToken, selectedToToken, availableTokens]);
// Get the current slippage value (either custom or preset) // Get the current slippage value
const currentSlippage = isCustomSlippage ? parseFloat(customSlippage) || 0.5 : slippage; const currentSlippage = parseFloat(maxSlippage) || 5.5;
// Calculate swap amounts for the selected token pair only // Calculate swap amounts for the selected token pair only
const { swapAmounts } = useSwapAmounts( const { swapAmounts } = useSwapAmounts(
@@ -94,12 +95,24 @@ export function SwapForm() {
return; return;
} }
// Show swapping toast
const toastId = addToast({
title: 'Swapping',
description: `${fromAmount} ${selectedFromToken.symbol}${toAmount} ${selectedToToken.symbol}`,
type: 'loading',
});
try { try {
// Use the shared helper to select the best swap route // Use the shared helper to select the best swap route
const bestRoute = selectBestSwapRoute(swapAmounts); const bestRoute = selectBestSwapRoute(swapAmounts);
if (!bestRoute) { if (!bestRoute) {
console.error('No valid swap route found'); console.error('No valid swap route found');
updateToast(toastId, {
title: 'Swap Failed',
description: 'No valid swap route found',
type: 'error',
});
return; return;
} }
@@ -115,8 +128,26 @@ export function SwapForm() {
maxAmountIn, maxAmountIn,
currentSlippage currentSlippage
); );
// Update toast to success
updateToast(toastId, {
title: 'Swap Confirmed',
description: `Successfully swapped ${fromAmount} ${selectedFromToken.symbol} to ${toAmount} ${selectedToToken.symbol}`,
type: 'success',
});
// Clear the form after successful swap
setFromAmount('');
setToAmount('');
setSelectedFromToken(null);
setSelectedToToken(null);
} catch (err) { } catch (err) {
console.error('Swap failed:', err); console.error('Swap failed:', err);
updateToast(toastId, {
title: 'Swap Failed',
description: err instanceof Error ? err.message : 'Transaction failed',
type: 'error',
});
} }
}; };
@@ -143,7 +174,17 @@ export function SwapForm() {
return ( return (
<Card className="w-full max-w-md mx-auto"> <Card className="w-full max-w-md mx-auto">
<CardHeader> <CardHeader>
<CardTitle>{t('swap.title')}</CardTitle> <div className="flex justify-between items-center">
<CardTitle>{t('swap.title')}</CardTitle>
<Button
variant="ghost"
size="icon"
onClick={() => setIsSettingsOpen(true)}
className="h-8 w-8"
>
<Settings className="h-5 w-5" />
</Button>
</div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* From Token */} {/* From Token */}
@@ -281,66 +322,27 @@ export function SwapForm() {
</div> </div>
</div> </div>
{/* Slippage Tolerance */} {/* Gas Estimate, Slippage, and Fees */}
<div className="space-y-3 pt-2"> {isConnected && fromAmount && toAmount && (
<div className="flex justify-between items-center"> <div className="px-4 py-2 bg-muted/30 rounded-lg space-y-2">
<label className="text-sm font-medium">Slippage Tolerance</label> <div className="flex justify-between text-sm">
<span className="text-sm text-muted-foreground"> <span className="text-muted-foreground">Estimated Gas Cost:</span>
{isCustomSlippage ? customSlippage || '0' : slippage}% <span className="font-medium">
</span> {isEstimatingGas ? 'Calculating...' : gasEstimate ? `$${gasEstimate.estimatedCostUsd}` : '-'}
</div> </span>
<div className="flex gap-2 flex-wrap"> </div>
{[0.1, 0.2, 0.3, 1, 2, 3].map((percent) => ( <div className="flex justify-between text-sm">
<Button <span className="text-muted-foreground">Max Slippage:</span>
key={percent} <span className="font-medium">{maxSlippage}%</span>
variant={!isCustomSlippage && slippage === percent ? 'default' : 'outline'} </div>
size="sm" {swapAmounts && swapAmounts.length > 0 && selectedToToken && (
onClick={() => { <div className="flex justify-between text-sm">
setSlippage(percent); <span className="text-muted-foreground">Fee:</span>
setIsCustomSlippage(false); <span className="font-medium">
}} {(Number(swapAmounts[0].fee) / Math.pow(10, selectedToToken.decimals)).toFixed(6)} {selectedToToken.symbol}
className="flex-1 min-w-[60px]"
>
{percent}%
</Button>
))}
<div className="flex-1 min-w-[80px] relative">
<Input
type="number"
placeholder="Custom"
value={customSlippage}
onChange={(e) => {
setCustomSlippage(e.target.value);
setIsCustomSlippage(true);
}}
onFocus={() => setIsCustomSlippage(true)}
className={`h-9 pr-6 ${isCustomSlippage ? 'border-primary' : ''}`}
step="0.01"
/>
{isCustomSlippage && (
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">
%
</span> </span>
)} </div>
</div> )}
</div>
</div>
{/* Gas Estimate */}
{isConnected && fromAmount && toAmount && gasEstimate && !isEstimatingGas && (
<div className="px-4 py-2 bg-muted/30 rounded-lg">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Estimated Gas Cost:</span>
<span className="font-medium">${gasEstimate.estimatedCostUsd}</span>
</div>
</div>
)}
{isConnected && fromAmount && toAmount && isEstimatingGas && (
<div className="px-4 py-2 bg-muted/30 rounded-lg">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Estimated Gas Cost:</span>
<span className="text-muted-foreground">Calculating...</span>
</div>
</div> </div>
)} )}
@@ -356,6 +358,54 @@ export function SwapForm() {
</Button> </Button>
</CardContent> </CardContent>
{/* Settings Modal */}
{isSettingsOpen && (
<>
<div
className="fixed inset-0 z-50 bg-black/80 animate-in fade-in-0"
onClick={() => setIsSettingsOpen(false)}
/>
<div className="fixed left-[50%] top-[50%] z-50 w-full max-w-sm translate-x-[-50%] translate-y-[-50%] animate-in fade-in-0 zoom-in-95">
<div className="bg-background border rounded-lg shadow-lg p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold">Settings</h2>
<button
onClick={() => setIsSettingsOpen(false)}
className="rounded-sm opacity-70 hover:opacity-100 transition-opacity"
>
<ChevronDown className="h-4 w-4" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="text-sm font-medium block mb-2">
Max Slippage
</label>
<div className="relative">
<Input
type="number"
value={maxSlippage}
onChange={(e) => setMaxSlippage(e.target.value)}
className="pr-8"
step="0.1"
min="0"
max="100"
/>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
%
</span>
</div>
<p className="text-xs text-muted-foreground mt-2">
Your transaction will revert if the price changes unfavorably by more than this percentage.
</p>
</div>
</div>
</div>
</div>
</>
)}
{/* Review Modal */} {/* Review Modal */}
<SwapReviewModal <SwapReviewModal
open={isReviewModalOpen} open={isReviewModalOpen}
@@ -366,6 +416,7 @@ export function SwapForm() {
toAmount={toAmount} toAmount={toAmount}
slippage={currentSlippage} slippage={currentSlippage}
gasEstimate={gasEstimate} gasEstimate={gasEstimate}
fee={swapAmounts && swapAmounts.length > 0 ? swapAmounts[0].fee : null}
onConfirm={async () => { onConfirm={async () => {
await handleSwap(); await handleSwap();
setIsReviewModalOpen(false); setIsReviewModalOpen(false);

View File

@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button';
import { ArrowDown, X } from 'lucide-react'; import { ArrowDown, X } from 'lucide-react';
import type { TokenDetails } from '@/hooks/usePartyPlanner'; import type { TokenDetails } from '@/hooks/usePartyPlanner';
import type { GasEstimate } from '@/hooks/usePartyPool'; import type { GasEstimate } from '@/hooks/usePartyPool';
import { useToast } from '@/components/ui/toast';
interface SwapReviewModalProps { interface SwapReviewModalProps {
open: boolean; open: boolean;
@@ -14,6 +15,7 @@ interface SwapReviewModalProps {
toAmount: string; toAmount: string;
slippage: number; slippage: number;
gasEstimate: GasEstimate | null; gasEstimate: GasEstimate | null;
fee: bigint | null;
onConfirm: () => void; onConfirm: () => void;
isSwapping: boolean; isSwapping: boolean;
} }
@@ -27,11 +29,22 @@ export function SwapReviewModal({
toAmount, toAmount,
slippage, slippage,
gasEstimate, gasEstimate,
fee,
onConfirm, onConfirm,
isSwapping, isSwapping,
}: SwapReviewModalProps) { }: SwapReviewModalProps) {
if (!open) return null; if (!open) return null;
// Calculate exchange rate
const exchangeRate = fromAmount && toAmount && parseFloat(fromAmount) > 0
? (parseFloat(toAmount) / parseFloat(fromAmount)).toFixed(6)
: '0';
// Format fee
const feeFormatted = fee && toToken
? (Number(fee) / Math.pow(10, toToken.decimals)).toFixed(6)
: '0';
return ( return (
<> <>
{/* Backdrop */} {/* Backdrop */}
@@ -80,11 +93,27 @@ export function SwapReviewModal({
{/* Details */} {/* Details */}
<div className="space-y-2 pt-2"> <div className="space-y-2 pt-2">
{/* Exchange Rate */}
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Rate</span>
<span className="font-medium">
1 {fromToken?.symbol} = {exchangeRate} {toToken?.symbol}
</span>
</div>
{/* Fee */}
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Fee</span>
<span className="font-medium">{feeFormatted} {toToken?.symbol}</span>
</div>
{/* Slippage */}
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-muted-foreground">Slippage Tolerance</span> <span className="text-muted-foreground">Slippage Tolerance</span>
<span className="font-medium">{slippage}%</span> <span className="font-medium">{slippage}%</span>
</div> </div>
{/* Network Fee */}
{gasEstimate && ( {gasEstimate && (
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-muted-foreground">Network Fee</span> <span className="text-muted-foreground">Network Fee</span>
@@ -93,13 +122,13 @@ export function SwapReviewModal({
)} )}
</div> </div>
{/* Confirm Button */} {/* Approve and Swap Button */}
<Button <Button
className="w-full h-12 text-lg" className="w-full h-12 text-lg"
onClick={onConfirm} onClick={onConfirm}
disabled={isSwapping} disabled={isSwapping}
> >
{isSwapping ? 'Swapping...' : 'Confirm Swap'} {isSwapping ? 'Swapping...' : 'Approve and Swap'}
</Button> </Button>
</div> </div>
</div> </div>

102
src/components/ui/toast.tsx Normal file
View File

@@ -0,0 +1,102 @@
'use client';
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
import { X, Loader2, CheckCircle2 } from 'lucide-react';
interface Toast {
id: string;
title: string;
description?: string;
type?: 'loading' | 'success' | 'error';
}
interface ToastContextType {
toasts: Toast[];
addToast: (toast: Omit<Toast, 'id'>) => string;
removeToast: (id: string) => void;
updateToast: (id: string, toast: Partial<Omit<Toast, 'id'>>) => void;
}
const ToastContext = createContext<ToastContextType | undefined>(undefined);
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const addToast = useCallback((toast: Omit<Toast, 'id'>) => {
const id = Math.random().toString(36).substr(2, 9);
setToasts((prev) => [...prev, { ...toast, id }]);
// Auto remove after 5 seconds for success/error toasts
if (toast.type === 'success' || toast.type === 'error') {
setTimeout(() => {
removeToast(id);
}, 5000);
}
return id;
}, []);
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, []);
const updateToast = useCallback((id: string, updates: Partial<Omit<Toast, 'id'>>) => {
setToasts((prev) =>
prev.map((toast) =>
toast.id === id ? { ...toast, ...updates } : toast
)
);
// Auto remove if updated to success/error
if (updates.type === 'success' || updates.type === 'error') {
setTimeout(() => {
removeToast(id);
}, 5000);
}
}, [removeToast]);
return (
<ToastContext.Provider value={{ toasts, addToast, removeToast, updateToast }}>
{children}
<div className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 max-w-md">
{toasts.map((toast) => (
<div
key={toast.id}
className="bg-background border rounded-lg shadow-lg p-4 flex items-start gap-3 animate-in slide-in-from-right"
>
{toast.type === 'loading' && (
<Loader2 className="h-5 w-5 animate-spin text-primary mt-0.5" />
)}
{toast.type === 'success' && (
<CheckCircle2 className="h-5 w-5 text-green-500 mt-0.5" />
)}
<div className="flex-1">
<div className="font-semibold text-sm">{toast.title}</div>
{toast.description && (
<div className="text-sm text-muted-foreground mt-1">
{toast.description}
</div>
)}
</div>
<button
onClick={() => removeToast(toast.id)}
className="rounded-sm opacity-70 hover:opacity-100 transition-opacity"
>
<X className="h-4 w-4" />
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within ToastProvider');
}
return context;
}