adding toast and displaying fees in the swap-form and modal
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
102
src/components/ui/toast.tsx
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user