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 { 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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
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