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

View File

@@ -5,25 +5,26 @@ import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { ArrowDownUp, ChevronDown } from 'lucide-react';
import { ArrowDownUp, ChevronDown, Settings } from 'lucide-react';
import { useAccount } from 'wagmi';
import { useTokenDetails, useGetPoolsByToken, type TokenDetails } from '@/hooks/usePartyPlanner';
import { useSwapAmounts, useSwap, selectBestSwapRoute } from '@/hooks/usePartyPool';
import { formatUnits, parseUnits } from 'viem';
import { SwapReviewModal } from './swap-review-modal';
import { useToast } from '@/components/ui/toast';
export function SwapForm() {
const { t } = useTranslation();
const { isConnected, address } = useAccount();
const { addToast, updateToast } = useToast();
const [fromAmount, setFromAmount] = useState('');
const [toAmount, setToAmount] = useState('');
const [selectedFromToken, setSelectedFromToken] = useState<TokenDetails | null>(null);
const [selectedToToken, setSelectedToToken] = useState<TokenDetails | null>(null);
const [isFromDropdownOpen, setIsFromDropdownOpen] = useState(false);
const [isToDropdownOpen, setIsToDropdownOpen] = useState(false);
const [slippage, setSlippage] = useState<number>(0.5); // Default 0.5%
const [customSlippage, setCustomSlippage] = useState<string>('');
const [isCustomSlippage, setIsCustomSlippage] = useState(false);
const [maxSlippage, setMaxSlippage] = useState<string>('5.5'); // Default 5.5%
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isReviewModalOpen, setIsReviewModalOpen] = useState(false);
const fromDropdownRef = useRef<HTMLDivElement>(null);
const toDropdownRef = useRef<HTMLDivElement>(null);
@@ -45,8 +46,8 @@ export function SwapForm() {
return null;
}, [selectedFromToken, selectedToToken, availableTokens]);
// Get the current slippage value (either custom or preset)
const currentSlippage = isCustomSlippage ? parseFloat(customSlippage) || 0.5 : slippage;
// Get the current slippage value
const currentSlippage = parseFloat(maxSlippage) || 5.5;
// Calculate swap amounts for the selected token pair only
const { swapAmounts } = useSwapAmounts(
@@ -94,12 +95,24 @@ export function SwapForm() {
return;
}
// Show swapping toast
const toastId = addToast({
title: 'Swapping',
description: `${fromAmount} ${selectedFromToken.symbol}${toAmount} ${selectedToToken.symbol}`,
type: 'loading',
});
try {
// Use the shared helper to select the best swap route
const bestRoute = selectBestSwapRoute(swapAmounts);
if (!bestRoute) {
console.error('No valid swap route found');
updateToast(toastId, {
title: 'Swap Failed',
description: 'No valid swap route found',
type: 'error',
});
return;
}
@@ -115,8 +128,26 @@ export function SwapForm() {
maxAmountIn,
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) {
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 (
<Card className="w-full max-w-md mx-auto">
<CardHeader>
<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>
<CardContent className="space-y-4">
{/* From Token */}
@@ -281,66 +322,27 @@ export function SwapForm() {
</div>
</div>
{/* Slippage Tolerance */}
<div className="space-y-3 pt-2">
<div className="flex justify-between items-center">
<label className="text-sm font-medium">Slippage Tolerance</label>
<span className="text-sm text-muted-foreground">
{isCustomSlippage ? customSlippage || '0' : slippage}%
</span>
</div>
<div className="flex gap-2 flex-wrap">
{[0.1, 0.2, 0.3, 1, 2, 3].map((percent) => (
<Button
key={percent}
variant={!isCustomSlippage && slippage === percent ? 'default' : 'outline'}
size="sm"
onClick={() => {
setSlippage(percent);
setIsCustomSlippage(false);
}}
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>
)}
</div>
</div>
</div>
{/* Gas Estimate */}
{isConnected && fromAmount && toAmount && gasEstimate && !isEstimatingGas && (
<div className="px-4 py-2 bg-muted/30 rounded-lg">
{/* Gas Estimate, Slippage, and Fees */}
{isConnected && fromAmount && toAmount && (
<div className="px-4 py-2 bg-muted/30 rounded-lg space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Estimated Gas Cost:</span>
<span className="font-medium">${gasEstimate.estimatedCostUsd}</span>
<span className="font-medium">
{isEstimatingGas ? 'Calculating...' : gasEstimate ? `$${gasEstimate.estimatedCostUsd}` : '-'}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Max Slippage:</span>
<span className="font-medium">{maxSlippage}%</span>
</div>
{swapAmounts && swapAmounts.length > 0 && selectedToToken && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Fee:</span>
<span className="font-medium">
{(Number(swapAmounts[0].fee) / Math.pow(10, selectedToToken.decimals)).toFixed(6)} {selectedToToken.symbol}
</span>
</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>
)}
@@ -356,6 +358,54 @@ export function SwapForm() {
</Button>
</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 */}
<SwapReviewModal
open={isReviewModalOpen}
@@ -366,6 +416,7 @@ export function SwapForm() {
toAmount={toAmount}
slippage={currentSlippage}
gasEstimate={gasEstimate}
fee={swapAmounts && swapAmounts.length > 0 ? swapAmounts[0].fee : null}
onConfirm={async () => {
await handleSwap();
setIsReviewModalOpen(false);

View File

@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button';
import { ArrowDown, X } from 'lucide-react';
import type { TokenDetails } from '@/hooks/usePartyPlanner';
import type { GasEstimate } from '@/hooks/usePartyPool';
import { useToast } from '@/components/ui/toast';
interface SwapReviewModalProps {
open: boolean;
@@ -14,6 +15,7 @@ interface SwapReviewModalProps {
toAmount: string;
slippage: number;
gasEstimate: GasEstimate | null;
fee: bigint | null;
onConfirm: () => void;
isSwapping: boolean;
}
@@ -27,11 +29,22 @@ export function SwapReviewModal({
toAmount,
slippage,
gasEstimate,
fee,
onConfirm,
isSwapping,
}: SwapReviewModalProps) {
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 (
<>
{/* Backdrop */}
@@ -80,11 +93,27 @@ export function SwapReviewModal({
{/* Details */}
<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">
<span className="text-muted-foreground">Slippage Tolerance</span>
<span className="font-medium">{slippage}%</span>
</div>
{/* Network Fee */}
{gasEstimate && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Network Fee</span>
@@ -93,13 +122,13 @@ export function SwapReviewModal({
)}
</div>
{/* Confirm Button */}
{/* Approve and Swap Button */}
<Button
className="w-full h-12 text-lg"
onClick={onConfirm}
disabled={isSwapping}
>
{isSwapping ? 'Swapping...' : 'Confirm Swap'}
{isSwapping ? 'Swapping...' : 'Approve and Swap'}
</Button>
</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;
}