feat: add staking and presale buttons to wallet quick actions

- Change quick actions grid from 2x2 to 2x3 with smaller buttons
- Add LP Staking modal with stake/unstake/claim rewards functionality
- Add Presale button with coming soon message
This commit is contained in:
2026-02-07 01:20:16 +03:00
parent 1a7609c14c
commit 122e38e306
4 changed files with 500 additions and 27 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pezkuwi-telegram-miniapp",
"version": "1.0.123",
"version": "1.0.124",
"type": "module",
"description": "Pezkuwichain Telegram Mini App - Forum, Announcements, Rewards",
"author": "Pezkuwichain Team",
+446
View File
@@ -0,0 +1,446 @@
/**
* LP Staking Modal for Telegram Mini App
* Allows users to stake LP tokens and earn PEZ rewards
*/
import { useState, useEffect } from 'react';
import { X, Lock, Unlock, Gift, AlertCircle, Loader2 } from 'lucide-react';
import { useWallet } from '@/contexts/WalletContext';
import { useTelegram } from '@/hooks/useTelegram';
import { cn } from '@/lib/utils';
interface StakingPool {
poolId: number;
stakedAsset: string;
rewardAsset: string;
rewardRatePerBlock: string;
totalStaked: string;
userStaked: string;
pendingRewards: string;
lpTokenId: number;
lpBalance: string;
}
interface LPStakingModalProps {
isOpen: boolean;
onClose: () => void;
}
const LP_TOKEN_NAMES: Record<number, string> = {
0: 'HEZ-PEZ LP',
1: 'HEZ-USDT LP',
2: 'HEZ-DOT LP',
};
export function LPStakingModal({ isOpen, onClose }: LPStakingModalProps) {
const { assetHubApi, keypair, address } = useWallet();
const { hapticImpact, hapticNotification, showAlert } = useTelegram();
const [pools, setPools] = useState<StakingPool[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedPool, setSelectedPool] = useState<number | null>(null);
const [stakeAmount, setStakeAmount] = useState('');
const [unstakeAmount, setUnstakeAmount] = useState('');
const [activeTab, setActiveTab] = useState<'stake' | 'unstake' | 'rewards'>('stake');
useEffect(() => {
if (!assetHubApi || !isOpen) return;
const fetchPools = async () => {
setIsLoading(true);
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const poolEntries = await (assetHubApi.query.assetRewards as any)?.pools?.entries();
if (!poolEntries) {
setError('Staking palleti amade nîne');
setIsLoading(false);
return;
}
const stakingPools: StakingPool[] = [];
for (const [key, value] of poolEntries) {
const poolId = parseInt(key.args[0].toString());
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const poolData = value.toJSON() as any;
const lpTokenId = poolData.stakedAssetId?.interior?.x2?.[1]?.generalIndex ?? poolId;
let userStaked = '0';
let pendingRewards = '0';
let lpBalance = '0';
if (address) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const stakeInfo = await (assetHubApi.query.assetRewards as any).poolStakers([
poolId,
address,
]);
if (stakeInfo && stakeInfo.isSome) {
const stakeData = stakeInfo.unwrap().toJSON();
userStaked = stakeData.amount || '0';
}
// Fetch LP balance
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const lpBal = await (assetHubApi.query.poolAssets as any).account(lpTokenId, address);
if (lpBal && lpBal.isSome) {
const lpData = lpBal.unwrap().toJSON();
lpBalance = lpData.balance || '0';
}
} catch {
// Ignore errors
}
}
stakingPools.push({
poolId,
stakedAsset: LP_TOKEN_NAMES[lpTokenId] || `LP Token #${lpTokenId}`,
rewardAsset: 'PEZ',
rewardRatePerBlock: poolData.rewardRatePerBlock || '0',
totalStaked: poolData.totalTokensStaked || '0',
userStaked,
pendingRewards,
lpTokenId,
lpBalance,
});
}
setPools(stakingPools);
if (stakingPools.length > 0 && selectedPool === null) {
setSelectedPool(stakingPools[0].poolId);
}
} catch (err) {
console.error('Error fetching staking pools:', err);
setError('Staking pools bar nekirin');
} finally {
setIsLoading(false);
}
};
fetchPools();
}, [assetHubApi, isOpen, address, selectedPool]);
const formatAmount = (amount: string, decimals: number = 12): string => {
const value = Number(amount) / Math.pow(10, decimals);
return value.toLocaleString(undefined, { maximumFractionDigits: 4 });
};
const handleStake = async () => {
if (!assetHubApi || !keypair || selectedPool === null || !stakeAmount) return;
setIsProcessing(true);
setError(null);
try {
const pool = pools.find((p) => p.poolId === selectedPool);
if (!pool) throw new Error('Pool not found');
const amountBN = BigInt(Math.floor(parseFloat(stakeAmount) * 1e12));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tx = (assetHubApi.tx.assetRewards as any).stake(selectedPool, amountBN.toString());
const hash = await tx.signAndSend(keypair);
hapticNotification('success');
showAlert(`Stake serket! Hash: ${hash.toString().slice(0, 16)}...`);
setStakeAmount('');
// Refresh pools
setTimeout(() => {
window.location.reload();
}, 2000);
} catch (err) {
console.error('Stake error:', err);
setError(err instanceof Error ? err.message : 'Stake neserketî');
hapticNotification('error');
} finally {
setIsProcessing(false);
}
};
const handleUnstake = async () => {
if (!assetHubApi || !keypair || selectedPool === null || !unstakeAmount) return;
setIsProcessing(true);
setError(null);
try {
const amountBN = BigInt(Math.floor(parseFloat(unstakeAmount) * 1e12));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tx = (assetHubApi.tx.assetRewards as any).unstake(selectedPool, amountBN.toString());
const hash = await tx.signAndSend(keypair);
hapticNotification('success');
showAlert(`Unstake serket! Hash: ${hash.toString().slice(0, 16)}...`);
setUnstakeAmount('');
setTimeout(() => {
window.location.reload();
}, 2000);
} catch (err) {
console.error('Unstake error:', err);
setError(err instanceof Error ? err.message : 'Unstake neserketî');
hapticNotification('error');
} finally {
setIsProcessing(false);
}
};
const handleClaimRewards = async () => {
if (!assetHubApi || !keypair || selectedPool === null) return;
setIsProcessing(true);
setError(null);
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tx = (assetHubApi.tx.assetRewards as any).harvestRewards(selectedPool);
const hash = await tx.signAndSend(keypair);
hapticNotification('success');
showAlert(`Xelat hat stendin! Hash: ${hash.toString().slice(0, 16)}...`);
setTimeout(() => {
window.location.reload();
}, 2000);
} catch (err) {
console.error('Claim error:', err);
setError(err instanceof Error ? err.message : 'Xelat stendin neserketî');
hapticNotification('error');
} finally {
setIsProcessing(false);
}
};
const currentPool = pools.find((p) => p.poolId === selectedPool);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 bg-black/80 flex items-end justify-center">
<div className="bg-background w-full max-w-lg rounded-t-3xl max-h-[90vh] overflow-hidden flex flex-col animate-in slide-in-from-bottom duration-300">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border">
<h2 className="text-lg font-semibold">LP Staking</h2>
<button onClick={onClose} className="p-2 hover:bg-secondary rounded-lg transition-colors">
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
) : pools.length === 0 ? (
<div className="text-center py-12">
<AlertCircle className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground">Hêj staking pool tune ne</p>
</div>
) : (
<>
{/* Pool Selector */}
<div className="mb-4">
<label className="text-sm text-muted-foreground mb-2 block">Pool Hilbijêre</label>
<div className="grid grid-cols-3 gap-2">
{pools.map((pool) => (
<button
key={pool.poolId}
onClick={() => {
hapticImpact('light');
setSelectedPool(pool.poolId);
}}
className={cn(
'p-3 rounded-xl border text-center transition-all',
selectedPool === pool.poolId
? 'border-primary bg-primary/10'
: 'border-border bg-secondary/50'
)}
>
<div className="text-xs font-medium">{pool.stakedAsset}</div>
</button>
))}
</div>
</div>
{/* Pool Stats */}
{currentPool && (
<div className="bg-secondary/50 rounded-xl p-4 mb-4 border border-border">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<div className="text-muted-foreground">Giştî Staked</div>
<div className="font-medium">{formatAmount(currentPool.totalStaked)}</div>
</div>
<div>
<div className="text-muted-foreground">Te Stake Kiriye</div>
<div className="font-medium text-green-400">
{formatAmount(currentPool.userStaked)}
</div>
</div>
<div>
<div className="text-muted-foreground">LP Bakiye</div>
<div className="font-medium">{formatAmount(currentPool.lpBalance)}</div>
</div>
<div>
<div className="text-muted-foreground">Xelat</div>
<div className="font-medium text-yellow-400">
{formatAmount(currentPool.pendingRewards)} PEZ
</div>
</div>
</div>
</div>
)}
{/* Tabs */}
<div className="flex gap-1 bg-secondary/50 rounded-lg p-1 mb-4">
{[
{ id: 'stake' as const, label: 'Stake', icon: Lock },
{ id: 'unstake' as const, label: 'Unstake', icon: Unlock },
{ id: 'rewards' as const, label: 'Xelat', icon: Gift },
].map(({ id, label, icon: Icon }) => (
<button
key={id}
onClick={() => {
hapticImpact('light');
setActiveTab(id);
}}
className={cn(
'flex-1 py-2 rounded-md text-sm flex items-center justify-center gap-1 transition-colors',
activeTab === id
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground'
)}
>
<Icon className="w-4 h-4" />
{label}
</button>
))}
</div>
{/* Error */}
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-3 mb-4 text-sm text-red-400">
{error}
</div>
)}
{/* Stake Tab */}
{activeTab === 'stake' && currentPool && (
<div className="space-y-4">
<div>
<label className="text-sm text-muted-foreground mb-2 block">
Mîqdar ({currentPool.stakedAsset})
</label>
<div className="relative">
<input
type="number"
value={stakeAmount}
onChange={(e) => setStakeAmount(e.target.value)}
placeholder="0.0000"
className="w-full bg-secondary border border-border rounded-xl p-4 pr-20 text-lg"
/>
<button
onClick={() => setStakeAmount(formatAmount(currentPool.lpBalance))}
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-primary"
>
MAX
</button>
</div>
<div className="text-xs text-muted-foreground mt-1">
Bakiye: {formatAmount(currentPool.lpBalance)}
</div>
</div>
<button
onClick={handleStake}
disabled={isProcessing || !stakeAmount}
className="w-full py-4 bg-primary text-primary-foreground rounded-xl font-medium disabled:opacity-50 flex items-center justify-center gap-2"
>
{isProcessing ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Lock className="w-5 h-5" />
)}
{isProcessing ? 'Tê stake kirin...' : 'Stake Bike'}
</button>
</div>
)}
{/* Unstake Tab */}
{activeTab === 'unstake' && currentPool && (
<div className="space-y-4">
<div>
<label className="text-sm text-muted-foreground mb-2 block">
Mîqdar ({currentPool.stakedAsset})
</label>
<div className="relative">
<input
type="number"
value={unstakeAmount}
onChange={(e) => setUnstakeAmount(e.target.value)}
placeholder="0.0000"
className="w-full bg-secondary border border-border rounded-xl p-4 pr-20 text-lg"
/>
<button
onClick={() => setUnstakeAmount(formatAmount(currentPool.userStaked))}
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-primary"
>
MAX
</button>
</div>
<div className="text-xs text-muted-foreground mt-1">
Staked: {formatAmount(currentPool.userStaked)}
</div>
</div>
<button
onClick={handleUnstake}
disabled={isProcessing || !unstakeAmount}
className="w-full py-4 bg-orange-500 text-white rounded-xl font-medium disabled:opacity-50 flex items-center justify-center gap-2"
>
{isProcessing ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Unlock className="w-5 h-5" />
)}
{isProcessing ? 'Tê unstake kirin...' : 'Unstake Bike'}
</button>
</div>
)}
{/* Rewards Tab */}
{activeTab === 'rewards' && currentPool && (
<div className="space-y-4">
<div className="bg-gradient-to-r from-yellow-500/20 to-orange-500/20 border border-yellow-500/30 rounded-xl p-6 text-center">
<Gift className="w-12 h-12 text-yellow-400 mx-auto mb-3" />
<div className="text-2xl font-bold text-yellow-400 mb-1">
{formatAmount(currentPool.pendingRewards)} PEZ
</div>
<div className="text-sm text-muted-foreground">Xelatên li bendê</div>
</div>
<button
onClick={handleClaimRewards}
disabled={isProcessing || currentPool.pendingRewards === '0'}
className="w-full py-4 bg-gradient-to-r from-yellow-500 to-orange-500 text-white rounded-xl font-medium disabled:opacity-50 flex items-center justify-center gap-2"
>
{isProcessing ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Gift className="w-5 h-5" />
)}
{isProcessing ? 'Tê stendin...' : 'Xelatan Bistîne'}
</button>
</div>
)}
</>
)}
</div>
</div>
</div>
);
}
+50 -23
View File
@@ -17,11 +17,14 @@ import {
ScanLine,
ArrowLeftRight,
Droplets,
Coins,
Rocket,
} from 'lucide-react';
import QRCode from 'qrcode';
import { TokensCard } from './TokensCard';
import { SwapModal } from './SwapModal';
import { PoolsModal } from './PoolsModal';
import { LPStakingModal } from './LPStakingModal';
import { useWallet } from '@/contexts/WalletContext';
import { useTelegram } from '@/hooks/useTelegram';
import { formatAddress } from '@/lib/wallet-service';
@@ -45,7 +48,7 @@ interface Transaction {
export function WalletDashboard({ onDisconnect }: Props) {
const { address, balance, api, assetHubApi, disconnect, isLoading } = useWallet();
const { hapticImpact, hapticNotification } = useTelegram();
const { hapticImpact, hapticNotification, showAlert } = useTelegram();
const [copied, setCopied] = useState(false);
const [activeTab, setActiveTab] = useState<'main' | 'send' | 'receive' | 'history'>('main');
@@ -55,6 +58,7 @@ export function WalletDashboard({ onDisconnect }: Props) {
const [isLoadingTxs, setIsLoadingTxs] = useState(false);
const [isSwapModalOpen, setIsSwapModalOpen] = useState(false);
const [isPoolsModalOpen, setIsPoolsModalOpen] = useState(false);
const [isStakingModalOpen, setIsStakingModalOpen] = useState(false);
// Subscribe to PEZ balance (Asset ID: 1) - Uses Asset Hub API
useEffect(() => {
@@ -547,20 +551,19 @@ export function WalletDashboard({ onDisconnect }: Props) {
</div>
</div>
{/* Quick Actions - 2x2 Grid */}
<div className="px-4 pb-4 grid grid-cols-2 gap-3">
{/* Quick Actions - 2x3 Grid */}
<div className="px-4 pb-4 grid grid-cols-3 gap-2">
<button
onClick={() => {
hapticImpact('light');
setActiveTab('send');
}}
className="flex flex-col items-center gap-1 p-4 bg-gradient-to-r from-green-600/20 to-yellow-500/20 border border-green-500/30 rounded-xl"
className="flex flex-col items-center gap-1 p-3 bg-gradient-to-r from-green-600/20 to-yellow-500/20 border border-green-500/30 rounded-xl"
>
<div className="w-10 h-10 bg-green-500/20 rounded-full flex items-center justify-center">
<Send className="w-5 h-5 text-green-400" />
<div className="w-8 h-8 bg-green-500/20 rounded-full flex items-center justify-center">
<Send className="w-4 h-4 text-green-400" />
</div>
<span className="text-sm font-medium">Bişîne</span>
<span className="text-xs text-gray-500">(send)</span>
<span className="text-xs font-medium">Bişîne</span>
</button>
<button
@@ -568,13 +571,12 @@ export function WalletDashboard({ onDisconnect }: Props) {
hapticImpact('light');
setActiveTab('receive');
}}
className="flex flex-col items-center gap-1 p-4 bg-muted rounded-xl border border-border"
className="flex flex-col items-center gap-1 p-3 bg-muted rounded-xl border border-border"
>
<div className="w-10 h-10 bg-cyan-500/20 rounded-full flex items-center justify-center">
<QrCode className="w-5 h-5 text-cyan-400" />
<div className="w-8 h-8 bg-cyan-500/20 rounded-full flex items-center justify-center">
<QrCode className="w-4 h-4 text-cyan-400" />
</div>
<span className="text-sm font-medium">Werbigire</span>
<span className="text-xs text-gray-500">(receive)</span>
<span className="text-xs font-medium">Werbigire</span>
</button>
<button
@@ -582,13 +584,12 @@ export function WalletDashboard({ onDisconnect }: Props) {
hapticImpact('light');
setIsSwapModalOpen(true);
}}
className="flex flex-col items-center gap-1 p-4 bg-gradient-to-r from-blue-600/20 to-purple-500/20 border border-blue-500/30 rounded-xl"
className="flex flex-col items-center gap-1 p-3 bg-gradient-to-r from-blue-600/20 to-purple-500/20 border border-blue-500/30 rounded-xl"
>
<div className="w-10 h-10 bg-blue-500/20 rounded-full flex items-center justify-center">
<ArrowLeftRight className="w-5 h-5 text-blue-400" />
<div className="w-8 h-8 bg-blue-500/20 rounded-full flex items-center justify-center">
<ArrowLeftRight className="w-4 h-4 text-blue-400" />
</div>
<span className="text-sm font-medium">Swap</span>
<span className="text-xs text-gray-500">(guhertin)</span>
<span className="text-xs font-medium">Swap</span>
</button>
<button
@@ -596,13 +597,38 @@ export function WalletDashboard({ onDisconnect }: Props) {
hapticImpact('light');
setIsPoolsModalOpen(true);
}}
className="flex flex-col items-center gap-1 p-4 bg-gradient-to-r from-purple-600/20 to-pink-500/20 border border-purple-500/30 rounded-xl"
className="flex flex-col items-center gap-1 p-3 bg-gradient-to-r from-purple-600/20 to-pink-500/20 border border-purple-500/30 rounded-xl"
>
<div className="w-10 h-10 bg-purple-500/20 rounded-full flex items-center justify-center">
<Droplets className="w-5 h-5 text-purple-400" />
<div className="w-8 h-8 bg-purple-500/20 rounded-full flex items-center justify-center">
<Droplets className="w-4 h-4 text-purple-400" />
</div>
<span className="text-sm font-medium">Pools</span>
<span className="text-xs text-gray-500">(hewz)</span>
<span className="text-xs font-medium">Pools</span>
</button>
<button
onClick={() => {
hapticImpact('light');
setIsStakingModalOpen(true);
}}
className="flex flex-col items-center gap-1 p-3 bg-gradient-to-r from-yellow-600/20 to-orange-500/20 border border-yellow-500/30 rounded-xl"
>
<div className="w-8 h-8 bg-yellow-500/20 rounded-full flex items-center justify-center">
<Coins className="w-4 h-4 text-yellow-400" />
</div>
<span className="text-xs font-medium">Staking</span>
</button>
<button
onClick={() => {
hapticImpact('light');
showAlert('Presale tê de ye! Zûtirîn demekê de dê bête vekirin.');
}}
className="flex flex-col items-center gap-1 p-3 bg-gradient-to-r from-pink-600/20 to-red-500/20 border border-pink-500/30 rounded-xl"
>
<div className="w-8 h-8 bg-pink-500/20 rounded-full flex items-center justify-center">
<Rocket className="w-4 h-4 text-pink-400" />
</div>
<span className="text-xs font-medium">Presale</span>
</button>
</div>
@@ -698,6 +724,7 @@ export function WalletDashboard({ onDisconnect }: Props) {
{/* Modals */}
<SwapModal isOpen={isSwapModalOpen} onClose={() => setIsSwapModalOpen(false)} />
<PoolsModal isOpen={isPoolsModalOpen} onClose={() => setIsPoolsModalOpen(false)} />
<LPStakingModal isOpen={isStakingModalOpen} onClose={() => setIsStakingModalOpen(false)} />
</div>
);
}
+3 -3
View File
@@ -1,5 +1,5 @@
{
"version": "1.0.123",
"buildTime": "2026-02-06T22:10:09.600Z",
"buildNumber": 1770415809601
"version": "1.0.124",
"buildTime": "2026-02-06T22:20:16.985Z",
"buildNumber": 1770416416986
}