mirror of
https://github.com/pezkuwichain/pezkuwi-telegram-miniapp.git
synced 2026-07-22 09:45:45 +00:00
2017ae77da
Behavior-preserving lint cleanup of the 30 pre-existing warnings, fixed by category (no blanket suppression): - no-explicit-any (19): precise local types / ApiPromise for Substrate dynamic queries + 'as unknown as' casts (same pattern as TokensCard). - no-console (4): dev-gated (import.meta.env.DEV) / warn; main.tsx prod console-suppression kept with a scoped documented disable. - no-non-null-assertion (4): replaced '!' with explicit guards reusing the existing fallback/return paths. - react-hooks/exhaustive-deps (3): missing dep is 't' (i18n) — adding it would re-fire blockchain fetches on language change; kept deps with a documented intentional disable. Tighten the lint gate from --max-warnings 30 to 0 so no new warnings can land. Verified: tsc 0 errors, eslint --max-warnings 0 clean, vite build ok.
876 lines
33 KiB
TypeScript
876 lines
33 KiB
TypeScript
/**
|
|
* Pools Modal Component
|
|
* Mobile-optimized liquidity pools interface for Telegram miniapp
|
|
*/
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { X, Droplets, Plus, Minus, AlertCircle, Check } from 'lucide-react';
|
|
import { useWallet } from '@/contexts/WalletContext';
|
|
import { useTelegram } from '@/hooks/useTelegram';
|
|
import { KurdistanSun } from '@/components/KurdistanSun';
|
|
import { useTranslation } from '@/i18n';
|
|
|
|
interface PoolsModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
interface Pool {
|
|
id: string;
|
|
asset0: number;
|
|
asset1: number;
|
|
asset0Symbol: string;
|
|
asset1Symbol: string;
|
|
asset0Decimals: number;
|
|
asset1Decimals: number;
|
|
lpTokenId: number;
|
|
reserve0: number;
|
|
reserve1: number;
|
|
price: number;
|
|
userLpBalance?: number;
|
|
userShare?: number;
|
|
}
|
|
|
|
// Native token ID
|
|
const NATIVE_TOKEN_ID = -1;
|
|
|
|
// Token info mapping
|
|
const TOKEN_INFO: Record<number, { symbol: string; decimals: number; icon: string }> = {
|
|
[-1]: { symbol: 'HEZ', decimals: 12, icon: '/tokens/HEZ.png' },
|
|
1: { symbol: 'PEZ', decimals: 12, icon: '/tokens/PEZ.png' },
|
|
1000: { symbol: 'USDT', decimals: 6, icon: '/tokens/USDT.png' },
|
|
1001: { symbol: 'DOT', decimals: 10, icon: '/tokens/DOT.png' },
|
|
};
|
|
|
|
// Helper to convert asset ID to XCM Location format
|
|
const formatAssetLocation = (id: number) => {
|
|
if (id === NATIVE_TOKEN_ID) {
|
|
return { parents: 1, interior: 'Here' };
|
|
}
|
|
return { parents: 0, interior: { X2: [{ PalletInstance: 50 }, { GeneralIndex: id }] } };
|
|
};
|
|
|
|
// Minimal shapes for the dynamic @pezkuwi/api results we read here. These
|
|
// pallets/runtime-calls are not in the base typings, so we describe just the
|
|
// fields actually used and cast via `as unknown as`.
|
|
type AccountInfoResult = { data: { free: { toString(): string } } };
|
|
type AssetAccountResult = {
|
|
isSome: boolean;
|
|
unwrap(): { balance: { toString(): string } };
|
|
};
|
|
type QuoteResult = { isNone: boolean; unwrap(): { toString(): string } };
|
|
type AssetConversionCall = {
|
|
assetConversionApi: {
|
|
quotePriceExactTokensForTokens: (
|
|
asset0: ReturnType<typeof formatAssetLocation>,
|
|
asset1: ReturnType<typeof formatAssetLocation>,
|
|
amount: string,
|
|
includeFee: boolean
|
|
) => Promise<QuoteResult>;
|
|
};
|
|
};
|
|
|
|
export function PoolsModal({ isOpen, onClose }: PoolsModalProps) {
|
|
const { assetHubApi, keypair } = useWallet();
|
|
const { hapticImpact, hapticNotification } = useTelegram();
|
|
const { t } = useTranslation();
|
|
|
|
const [pools, setPools] = useState<Pool[]>([]);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [selectedPool, setSelectedPool] = useState<Pool | null>(null);
|
|
const [isAddingLiquidity, setIsAddingLiquidity] = useState(false);
|
|
const [isRemovingLiquidity, setIsRemovingLiquidity] = useState(false);
|
|
const [amount0, setAmount0] = useState('');
|
|
const [amount1, setAmount1] = useState('');
|
|
const [lpAmountToRemove, setLpAmountToRemove] = useState('');
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [success, setSuccess] = useState(false);
|
|
const [successMessage, setSuccessMessage] = useState('');
|
|
|
|
// Token balances
|
|
const [balances, setBalances] = useState<Record<string, string>>({
|
|
HEZ: '0',
|
|
PEZ: '0',
|
|
USDT: '0',
|
|
});
|
|
|
|
// Fetch balances and pools
|
|
useEffect(() => {
|
|
if (!isOpen || !assetHubApi || !keypair) return;
|
|
|
|
// Reset state when modal opens
|
|
setError('');
|
|
setAmount0('');
|
|
setAmount1('');
|
|
setLpAmountToRemove('');
|
|
|
|
let isCancelled = false;
|
|
|
|
const fetchData = async () => {
|
|
setIsLoading(true);
|
|
|
|
try {
|
|
// Add timeout wrapper for API calls
|
|
const withTimeout = <T,>(promise: Promise<T>, ms: number): Promise<T> => {
|
|
return Promise.race([
|
|
promise,
|
|
new Promise<T>((_, reject) =>
|
|
setTimeout(() => reject(new Error('API call timeout')), ms)
|
|
),
|
|
]);
|
|
};
|
|
|
|
// Fetch HEZ balance from Asset Hub (native token)
|
|
|
|
const hezAccount = (await withTimeout(
|
|
assetHubApi.query.system.account(keypair.address),
|
|
10000
|
|
)) as unknown as AccountInfoResult;
|
|
if (isCancelled) return;
|
|
const hezFree = hezAccount.data.free.toString();
|
|
setBalances((prev) => ({ ...prev, HEZ: (parseInt(hezFree) / 1e12).toFixed(4) }));
|
|
|
|
const pezResult = (await withTimeout(
|
|
assetHubApi.query.assets.account(1, keypair.address),
|
|
10000
|
|
)) as unknown as AssetAccountResult;
|
|
if (isCancelled) return;
|
|
if (pezResult.isSome) {
|
|
setBalances((prev) => ({
|
|
...prev,
|
|
PEZ: (parseInt(pezResult.unwrap().balance.toString()) / 1e12).toFixed(4),
|
|
}));
|
|
} else {
|
|
setBalances((prev) => ({ ...prev, PEZ: '0.0000' }));
|
|
}
|
|
|
|
const usdtResult = (await withTimeout(
|
|
assetHubApi.query.assets.account(1000, keypair.address),
|
|
10000
|
|
)) as unknown as AssetAccountResult;
|
|
if (isCancelled) return;
|
|
if (usdtResult.isSome) {
|
|
setBalances((prev) => ({
|
|
...prev,
|
|
USDT: (parseInt(usdtResult.unwrap().balance.toString()) / 1e6).toFixed(2),
|
|
}));
|
|
} else {
|
|
setBalances((prev) => ({ ...prev, USDT: '0.00' }));
|
|
}
|
|
|
|
// Fetch pools
|
|
const poolPairs: [number, number][] = [
|
|
[NATIVE_TOKEN_ID, 1], // HEZ-PEZ
|
|
[NATIVE_TOKEN_ID, 1000], // HEZ-USDT
|
|
[NATIVE_TOKEN_ID, 1001], // HEZ-DOT
|
|
];
|
|
|
|
const fetchedPools: Pool[] = [];
|
|
|
|
for (const [asset0, asset1] of poolPairs) {
|
|
if (isCancelled) return;
|
|
try {
|
|
const poolKey = [formatAssetLocation(asset0), formatAssetLocation(asset1)];
|
|
const poolInfo = await withTimeout(
|
|
assetHubApi.query.assetConversion.pools(poolKey),
|
|
10000
|
|
);
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
if (poolInfo && !(poolInfo as any).isEmpty) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const poolData = (poolInfo as any).unwrap().toJSON() as { lpToken: number };
|
|
const lpTokenId = poolData.lpToken;
|
|
|
|
const token0 = TOKEN_INFO[asset0];
|
|
const token1 = TOKEN_INFO[asset1];
|
|
|
|
// Get price quote
|
|
let price = 0;
|
|
let reserve0 = 0;
|
|
let reserve1 = 0;
|
|
|
|
try {
|
|
const oneUnit = BigInt(Math.pow(10, token0.decimals));
|
|
|
|
const quote = await withTimeout(
|
|
(
|
|
assetHubApi.call as unknown as AssetConversionCall
|
|
).assetConversionApi.quotePriceExactTokensForTokens(
|
|
formatAssetLocation(asset0),
|
|
formatAssetLocation(asset1),
|
|
oneUnit.toString(),
|
|
true
|
|
),
|
|
10000
|
|
);
|
|
|
|
if (quote && !quote.isNone) {
|
|
price = Number(BigInt(quote.unwrap().toString())) / Math.pow(10, token1.decimals);
|
|
|
|
// Estimate reserves from LP supply
|
|
const lpAsset = await withTimeout(
|
|
assetHubApi.query.poolAssets.asset(lpTokenId),
|
|
10000
|
|
);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
if ((lpAsset as any).isSome) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const lpSupply = Number((lpAsset as any).unwrap().toJSON().supply) / 1e12;
|
|
// Decimal correction factor for mixed-decimal pools
|
|
const decimalFactor = Math.pow(
|
|
10,
|
|
12 - (token0.decimals + token1.decimals) / 2
|
|
);
|
|
const sqrtPrice = Math.sqrt(price);
|
|
reserve0 = (lpSupply * decimalFactor) / sqrtPrice;
|
|
reserve1 = lpSupply * decimalFactor * sqrtPrice;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn('Could not fetch price for pool:', err);
|
|
}
|
|
|
|
// Get user's LP balance
|
|
let userLpBalance = 0;
|
|
let userShare = 0;
|
|
|
|
try {
|
|
const userLp = await withTimeout(
|
|
assetHubApi.query.poolAssets.account(lpTokenId, keypair.address),
|
|
10000
|
|
);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
if ((userLp as any).isSome) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
userLpBalance = Number((userLp as any).unwrap().toJSON().balance) / 1e12;
|
|
|
|
const lpAsset = await withTimeout(
|
|
assetHubApi.query.poolAssets.asset(lpTokenId),
|
|
10000
|
|
);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
if ((lpAsset as any).isSome) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const totalSupply = Number((lpAsset as any).unwrap().toJSON().supply) / 1e12;
|
|
userShare = totalSupply > 0 ? (userLpBalance / totalSupply) * 100 : 0;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn('Could not fetch user LP balance:', err);
|
|
}
|
|
|
|
fetchedPools.push({
|
|
id: `${asset0}-${asset1}`,
|
|
asset0,
|
|
asset1,
|
|
asset0Symbol: token0.symbol,
|
|
asset1Symbol: token1.symbol,
|
|
asset0Decimals: token0.decimals,
|
|
asset1Decimals: token1.decimals,
|
|
lpTokenId,
|
|
reserve0,
|
|
reserve1,
|
|
price,
|
|
userLpBalance,
|
|
userShare,
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.warn('Pool not found:', err);
|
|
}
|
|
}
|
|
|
|
if (!isCancelled) {
|
|
setPools(fetchedPools);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch pools:', err);
|
|
if (!isCancelled) {
|
|
setError(t('pools.connectionError'));
|
|
}
|
|
} finally {
|
|
if (!isCancelled) {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
fetchData();
|
|
|
|
return () => {
|
|
isCancelled = true;
|
|
};
|
|
// `t` is intentionally omitted: it is only used for error messages and its
|
|
// identity changes only on language switch — including it would re-run the
|
|
// blockchain fetch (extra RPC calls) on every language change.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [isOpen, assetHubApi, keypair]);
|
|
|
|
// Auto-calculate amount1 based on pool price
|
|
useEffect(() => {
|
|
if (selectedPool && amount0 && selectedPool.price > 0) {
|
|
const calculated = parseFloat(amount0) * selectedPool.price;
|
|
setAmount1(calculated.toFixed(selectedPool.asset1Decimals === 6 ? 2 : 4));
|
|
} else {
|
|
setAmount1('');
|
|
}
|
|
}, [amount0, selectedPool]);
|
|
|
|
// Add liquidity
|
|
const handleAddLiquidity = async () => {
|
|
if (!assetHubApi || !keypair || !selectedPool || !amount0 || !amount1) return;
|
|
|
|
setIsSubmitting(true);
|
|
setError('');
|
|
|
|
try {
|
|
const amt0 = BigInt(
|
|
Math.floor(parseFloat(amount0) * Math.pow(10, selectedPool.asset0Decimals))
|
|
);
|
|
const amt1 = BigInt(
|
|
Math.floor(parseFloat(amount1) * Math.pow(10, selectedPool.asset1Decimals))
|
|
);
|
|
const minAmt0 = (amt0 * BigInt(90)) / BigInt(100); // 10% slippage
|
|
const minAmt1 = (amt1 * BigInt(90)) / BigInt(100);
|
|
|
|
const asset0Location = formatAssetLocation(selectedPool.asset0);
|
|
const asset1Location = formatAssetLocation(selectedPool.asset1);
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const tx = (assetHubApi.tx.assetConversion as any).addLiquidity(
|
|
asset0Location,
|
|
asset1Location,
|
|
amt0.toString(),
|
|
amt1.toString(),
|
|
minAmt0.toString(),
|
|
minAmt1.toString(),
|
|
keypair.address
|
|
);
|
|
|
|
// Minimal shape of the signAndSend result fields we read. `asModule`
|
|
// is typed from the registry call so it stays type-safe.
|
|
type TxResult = {
|
|
status: { isFinalized: boolean };
|
|
dispatchError?: {
|
|
isModule: boolean;
|
|
asModule: Parameters<typeof assetHubApi.registry.findMetaError>[0];
|
|
toString(): string;
|
|
};
|
|
};
|
|
|
|
// Wait for transaction to be finalized
|
|
await new Promise<void>((resolve, reject) => {
|
|
tx.signAndSend(keypair, ({ status, dispatchError }: TxResult) => {
|
|
if (status.isFinalized) {
|
|
if (dispatchError) {
|
|
let errorMsg = t('pools.addFailed');
|
|
if (dispatchError.isModule) {
|
|
const decoded = assetHubApi.registry.findMetaError(dispatchError.asModule);
|
|
errorMsg = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
|
|
} else if (dispatchError.toString) {
|
|
errorMsg = dispatchError.toString();
|
|
}
|
|
console.error('Add liquidity error:', errorMsg);
|
|
reject(new Error(errorMsg));
|
|
} else {
|
|
resolve();
|
|
}
|
|
}
|
|
}).catch(reject);
|
|
});
|
|
|
|
setSuccessMessage(
|
|
t('pools.addedLiquidity', {
|
|
amount0,
|
|
token0: selectedPool.asset0Symbol,
|
|
amount1,
|
|
token1: selectedPool.asset1Symbol,
|
|
})
|
|
);
|
|
setSuccess(true);
|
|
hapticNotification('success');
|
|
|
|
setTimeout(() => {
|
|
setSuccess(false);
|
|
setIsAddingLiquidity(false);
|
|
setSelectedPool(null);
|
|
setAmount0('');
|
|
setAmount1('');
|
|
}, 2000);
|
|
} catch (err) {
|
|
console.error('Add liquidity failed:', err);
|
|
setError(err instanceof Error ? err.message : t('pools.addFailed'));
|
|
hapticNotification('error');
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
// Remove liquidity
|
|
const handleRemoveLiquidity = async () => {
|
|
if (!assetHubApi || !keypair || !selectedPool || !lpAmountToRemove) return;
|
|
|
|
const lpAmount = parseFloat(lpAmountToRemove);
|
|
if (lpAmount <= 0 || lpAmount > (selectedPool.userLpBalance || 0)) {
|
|
setError(t('pools.invalidLpAmount'));
|
|
hapticNotification('error');
|
|
return;
|
|
}
|
|
|
|
setIsSubmitting(true);
|
|
setError('');
|
|
|
|
try {
|
|
const lpAmountRaw = BigInt(Math.floor(lpAmount * 1e12));
|
|
|
|
// Calculate minimum amounts to receive (with 10% slippage)
|
|
const userShare =
|
|
((lpAmount / (selectedPool.userLpBalance || 1)) * (selectedPool.userShare || 0)) / 100;
|
|
const expectedAmt0 = selectedPool.reserve0 * userShare;
|
|
const expectedAmt1 = selectedPool.reserve1 * userShare;
|
|
|
|
const minAmt0 = BigInt(
|
|
Math.floor(expectedAmt0 * 0.9 * Math.pow(10, selectedPool.asset0Decimals))
|
|
);
|
|
const minAmt1 = BigInt(
|
|
Math.floor(expectedAmt1 * 0.9 * Math.pow(10, selectedPool.asset1Decimals))
|
|
);
|
|
|
|
const asset0Location = formatAssetLocation(selectedPool.asset0);
|
|
const asset1Location = formatAssetLocation(selectedPool.asset1);
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const tx = (assetHubApi.tx.assetConversion as any).removeLiquidity(
|
|
asset0Location,
|
|
asset1Location,
|
|
lpAmountRaw.toString(),
|
|
minAmt0.toString(),
|
|
minAmt1.toString(),
|
|
keypair.address
|
|
);
|
|
|
|
// Minimal shape of the signAndSend result fields we read. `asModule`
|
|
// is typed from the registry call so it stays type-safe.
|
|
type TxResult = {
|
|
status: { isFinalized: boolean };
|
|
dispatchError?: {
|
|
isModule: boolean;
|
|
asModule: Parameters<typeof assetHubApi.registry.findMetaError>[0];
|
|
toString(): string;
|
|
};
|
|
};
|
|
|
|
// Wait for transaction to be finalized
|
|
await new Promise<void>((resolve, reject) => {
|
|
tx.signAndSend(keypair, ({ status, dispatchError }: TxResult) => {
|
|
if (status.isFinalized) {
|
|
if (dispatchError) {
|
|
let errorMsg = t('pools.removeFailed');
|
|
if (dispatchError.isModule) {
|
|
const decoded = assetHubApi.registry.findMetaError(dispatchError.asModule);
|
|
errorMsg = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
|
|
} else if (dispatchError.toString) {
|
|
errorMsg = dispatchError.toString();
|
|
}
|
|
console.error('Remove liquidity error:', errorMsg);
|
|
reject(new Error(errorMsg));
|
|
} else {
|
|
resolve();
|
|
}
|
|
}
|
|
}).catch(reject);
|
|
});
|
|
|
|
setSuccessMessage(t('pools.removedLiquidity', { amount: lpAmountToRemove }));
|
|
setSuccess(true);
|
|
hapticNotification('success');
|
|
|
|
setTimeout(() => {
|
|
setSuccess(false);
|
|
setIsRemovingLiquidity(false);
|
|
setSelectedPool(null);
|
|
setLpAmountToRemove('');
|
|
}, 2000);
|
|
} catch (err) {
|
|
console.error('Remove liquidity failed:', err);
|
|
setError(err instanceof Error ? err.message : t('pools.removeFailed'));
|
|
hapticNotification('error');
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
if (!isOpen) return null;
|
|
|
|
// Success screen
|
|
if (success) {
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
|
|
<div className="w-full max-w-md bg-card rounded-2xl p-6 text-center space-y-4">
|
|
<div className="w-16 h-16 mx-auto bg-green-500/20 rounded-full flex items-center justify-center">
|
|
<Check className="w-8 h-8 text-green-500" />
|
|
</div>
|
|
<h2 className="text-xl font-semibold">{t('pools.success')}</h2>
|
|
<p className="text-muted-foreground">{successMessage}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Add liquidity form
|
|
if (isAddingLiquidity && selectedPool) {
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
|
|
<div className="w-full max-w-md bg-card rounded-2xl shadow-xl border border-border overflow-hidden max-h-[90vh] overflow-y-auto">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-4 border-b border-border">
|
|
<button
|
|
onClick={() => {
|
|
setIsAddingLiquidity(false);
|
|
setAmount0('');
|
|
setAmount1('');
|
|
setError('');
|
|
}}
|
|
className="text-muted-foreground"
|
|
>
|
|
{t('pools.back')}
|
|
</button>
|
|
<h2 className="text-lg font-semibold">{t('pools.addLiquidity')}</h2>
|
|
<div className="w-10" />
|
|
</div>
|
|
|
|
{/* Pool Info */}
|
|
<div className="p-4 bg-muted/30 border-b border-border">
|
|
<div className="flex items-center justify-center gap-2">
|
|
<span className="text-lg font-semibold">
|
|
{selectedPool.asset0Symbol}/{selectedPool.asset1Symbol}
|
|
</span>
|
|
</div>
|
|
<p className="text-center text-sm text-muted-foreground mt-1">
|
|
1 {selectedPool.asset0Symbol} = {selectedPool.price.toFixed(4)}{' '}
|
|
{selectedPool.asset1Symbol}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Form */}
|
|
<div className="p-4 space-y-4">
|
|
{/* Amount 0 */}
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-muted-foreground">{selectedPool.asset0Symbol} Mîqdar</span>
|
|
<span className="text-muted-foreground">
|
|
{t('swap.balanceLabel')} {balances[selectedPool.asset0Symbol]}
|
|
</span>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="number"
|
|
value={amount0}
|
|
onChange={(e) => setAmount0(e.target.value)}
|
|
placeholder="0.00"
|
|
className="flex-1 px-4 py-3 bg-muted rounded-xl text-lg font-mono"
|
|
/>
|
|
<button
|
|
onClick={() => setAmount0(balances[selectedPool.asset0Symbol])}
|
|
className="px-3 py-2 bg-muted rounded-xl text-sm text-primary"
|
|
>
|
|
Max
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-center">
|
|
<Plus className="w-5 h-5 text-muted-foreground" />
|
|
</div>
|
|
|
|
{/* Amount 1 */}
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-muted-foreground">
|
|
{t('pools.amountAuto', { token: selectedPool.asset1Symbol })}
|
|
</span>
|
|
<span className="text-muted-foreground">
|
|
{t('swap.balanceLabel')} {balances[selectedPool.asset1Symbol]}
|
|
</span>
|
|
</div>
|
|
<input
|
|
type="text"
|
|
value={amount1}
|
|
readOnly
|
|
placeholder="0.00"
|
|
className="w-full px-4 py-3 bg-muted rounded-xl text-lg font-mono text-muted-foreground"
|
|
/>
|
|
</div>
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
|
|
<AlertCircle className="w-4 h-4" />
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Submit Button or Loading Animation */}
|
|
{isSubmitting ? (
|
|
<div className="flex flex-col items-center justify-center py-4 space-y-3">
|
|
<KurdistanSun size={80} />
|
|
<p className="text-sm text-muted-foreground animate-pulse">{t('pools.adding')}</p>
|
|
</div>
|
|
) : (
|
|
<button
|
|
onClick={handleAddLiquidity}
|
|
disabled={!amount0 || !amount1 || parseFloat(amount0) <= 0}
|
|
className="w-full py-4 bg-gradient-to-r from-green-600 to-blue-600 text-white font-semibold rounded-xl disabled:opacity-50 flex items-center justify-center gap-2"
|
|
>
|
|
<Droplets className="w-5 h-5" />
|
|
{t('pools.addLiquidity')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Remove liquidity form
|
|
if (isRemovingLiquidity && selectedPool) {
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
|
|
<div className="w-full max-w-md bg-card rounded-2xl shadow-xl border border-border overflow-hidden max-h-[90vh] overflow-y-auto">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-4 border-b border-border">
|
|
<button
|
|
onClick={() => {
|
|
setIsRemovingLiquidity(false);
|
|
setLpAmountToRemove('');
|
|
setError('');
|
|
}}
|
|
className="text-muted-foreground"
|
|
>
|
|
{t('pools.back')}
|
|
</button>
|
|
<h2 className="text-lg font-semibold">{t('pools.removeLiquidity')}</h2>
|
|
<div className="w-10" />
|
|
</div>
|
|
|
|
{/* Pool Info */}
|
|
<div className="p-4 bg-muted/30 border-b border-border">
|
|
<div className="flex items-center justify-center gap-2">
|
|
<span className="text-lg font-semibold">
|
|
{selectedPool.asset0Symbol}/{selectedPool.asset1Symbol}
|
|
</span>
|
|
</div>
|
|
<p className="text-center text-sm text-muted-foreground mt-1">
|
|
{t('pools.lpBalance')} {selectedPool.userLpBalance?.toFixed(4) || '0'} LP
|
|
</p>
|
|
</div>
|
|
|
|
{/* Form */}
|
|
<div className="p-4 space-y-4">
|
|
{/* LP Amount */}
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-muted-foreground">{t('pools.lpTokenAmount')}</span>
|
|
<span className="text-muted-foreground">
|
|
Max: {selectedPool.userLpBalance?.toFixed(4) || '0'}
|
|
</span>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="number"
|
|
value={lpAmountToRemove}
|
|
onChange={(e) => setLpAmountToRemove(e.target.value)}
|
|
placeholder="0.00"
|
|
className="flex-1 px-4 py-3 bg-muted rounded-xl text-lg font-mono"
|
|
/>
|
|
<button
|
|
onClick={() => setLpAmountToRemove(selectedPool.userLpBalance?.toString() || '0')}
|
|
className="px-3 py-2 bg-muted rounded-xl text-sm text-primary"
|
|
>
|
|
Max
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Estimated Returns */}
|
|
{lpAmountToRemove && parseFloat(lpAmountToRemove) > 0 && (
|
|
<div className="bg-muted/50 rounded-xl p-3 space-y-2 text-sm">
|
|
<p className="text-muted-foreground">{t('pools.estimatedReturn')}</p>
|
|
<div className="flex justify-between">
|
|
<span>{selectedPool.asset0Symbol}</span>
|
|
<span className="font-mono">
|
|
~
|
|
{(
|
|
(((parseFloat(lpAmountToRemove) / (selectedPool.userLpBalance || 1)) *
|
|
(selectedPool.userShare || 0)) /
|
|
100) *
|
|
selectedPool.reserve0
|
|
).toFixed(4)}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span>{selectedPool.asset1Symbol}</span>
|
|
<span className="font-mono">
|
|
~
|
|
{(
|
|
(((parseFloat(lpAmountToRemove) / (selectedPool.userLpBalance || 1)) *
|
|
(selectedPool.userShare || 0)) /
|
|
100) *
|
|
selectedPool.reserve1
|
|
).toFixed(selectedPool.asset1Decimals === 6 ? 2 : 4)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
|
|
<AlertCircle className="w-4 h-4" />
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Submit Button or Loading Animation */}
|
|
{isSubmitting ? (
|
|
<div className="flex flex-col items-center justify-center py-4 space-y-3">
|
|
<KurdistanSun size={80} />
|
|
<p className="text-sm text-muted-foreground animate-pulse">{t('pools.removing')}</p>
|
|
</div>
|
|
) : (
|
|
<button
|
|
onClick={handleRemoveLiquidity}
|
|
disabled={!lpAmountToRemove || parseFloat(lpAmountToRemove) <= 0}
|
|
className="w-full py-4 bg-gradient-to-r from-red-600 to-orange-600 text-white font-semibold rounded-xl disabled:opacity-50 flex items-center justify-center gap-2"
|
|
>
|
|
<Minus className="w-5 h-5" />
|
|
{t('pools.removeLiquidity')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Pool list
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
|
|
<div className="w-full max-w-md bg-card rounded-2xl shadow-xl border border-border overflow-hidden max-h-[90vh] overflow-y-auto">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-4 border-b border-border">
|
|
<h2 className="text-lg font-semibold">{t('pools.title')}</h2>
|
|
<button onClick={onClose} className="p-2 rounded-full hover:bg-muted">
|
|
<X className="w-5 h-5 text-muted-foreground" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="p-4 space-y-3">
|
|
{isLoading ? (
|
|
<div className="flex flex-col items-center justify-center py-8">
|
|
<KurdistanSun size={80} />
|
|
<p className="text-muted-foreground mt-3 animate-pulse">{t('pools.loadingPools')}</p>
|
|
</div>
|
|
) : pools.length === 0 ? (
|
|
<div className="text-center py-8">
|
|
<Droplets className="w-12 h-12 mx-auto text-muted-foreground mb-2" />
|
|
<p className="text-muted-foreground">{t('pools.noPools')}</p>
|
|
</div>
|
|
) : (
|
|
pools.map((pool) => (
|
|
<div key={pool.id} className="bg-muted/50 rounded-xl p-4 border border-border">
|
|
{/* Pool Header */}
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex -space-x-2">
|
|
<img
|
|
src={TOKEN_INFO[pool.asset0]?.icon}
|
|
alt={pool.asset0Symbol}
|
|
className="w-8 h-8 rounded-full border-2 border-card"
|
|
/>
|
|
<img
|
|
src={TOKEN_INFO[pool.asset1]?.icon}
|
|
alt={pool.asset1Symbol}
|
|
className="w-8 h-8 rounded-full border-2 border-card"
|
|
/>
|
|
</div>
|
|
<span className="font-semibold">
|
|
{pool.asset0Symbol}/{pool.asset1Symbol}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Pool Stats */}
|
|
<div className="grid grid-cols-2 gap-2 text-sm mb-3">
|
|
<div>
|
|
<span className="text-muted-foreground">
|
|
{t('pools.reserve')} {pool.asset0Symbol}
|
|
</span>
|
|
<p className="font-mono">
|
|
{pool.reserve0.toLocaleString('en-US', { maximumFractionDigits: 0 })}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-muted-foreground">
|
|
{t('pools.reserve')} {pool.asset1Symbol}
|
|
</span>
|
|
<p className="font-mono">
|
|
{pool.reserve1.toLocaleString('en-US', { maximumFractionDigits: 0 })}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* User Position */}
|
|
{pool.userLpBalance && pool.userLpBalance > 0 && (
|
|
<div className="bg-green-500/10 border border-green-500/30 rounded-lg p-2 mb-3 text-sm">
|
|
<div className="flex justify-between">
|
|
<span className="text-green-400">{t('pools.yourPosition')}</span>
|
|
<span className="text-green-400 font-mono">
|
|
{pool.userShare?.toFixed(2)}%
|
|
</span>
|
|
</div>
|
|
<div className="text-xs text-muted-foreground mt-1">
|
|
LP Token: {pool.userLpBalance.toFixed(4)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Action Buttons */}
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => {
|
|
hapticImpact('light');
|
|
setSelectedPool(pool);
|
|
setIsAddingLiquidity(true);
|
|
}}
|
|
className="flex-1 py-2 bg-gradient-to-r from-green-600/20 to-blue-600/20 border border-green-500/30 text-green-400 font-medium rounded-lg flex items-center justify-center gap-1 text-sm"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
{t('pools.addButton')}
|
|
</button>
|
|
{pool.userLpBalance && pool.userLpBalance > 0 && (
|
|
<button
|
|
onClick={() => {
|
|
hapticImpact('light');
|
|
setSelectedPool(pool);
|
|
setIsRemovingLiquidity(true);
|
|
}}
|
|
className="flex-1 py-2 bg-gradient-to-r from-red-600/20 to-orange-600/20 border border-red-500/30 text-red-400 font-medium rounded-lg flex items-center justify-center gap-1 text-sm"
|
|
>
|
|
<Minus className="w-4 h-4" />
|
|
{t('pools.removeButton')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|