fix: AuthContext hoisting error and add presale launchpad UI

Frontend improvements for production readiness:
- Fixed AuthContext function hoisting issue (checkAdminStatus before use)
- Added complete Presale Launchpad UI (PresaleList, CreatePresale, PresaleDetail)
- Integrated with pallet-presale blockchain functionality
- Updated App.tsx routing for launchpad pages

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-20 18:32:08 +03:00
parent 3524f5c5c6
commit 9de2d853aa
5 changed files with 1360 additions and 31 deletions
+6
View File
@@ -38,6 +38,9 @@ const EducationPlatform = lazy(() => import('./pages/EducationPlatform'));
const P2PPlatform = lazy(() => import('./pages/P2PPlatform'));
const DEXDashboard = lazy(() => import('./components/dex/DEXDashboard').then(m => ({ default: m.DEXDashboard })));
const Presale = lazy(() => import('./pages/Presale'));
const PresaleList = lazy(() => import('./pages/launchpad/PresaleList'));
const PresaleDetail = lazy(() => import('./pages/launchpad/PresaleDetail'));
const CreatePresale = lazy(() => import('./pages/launchpad/CreatePresale'));
const NotFound = lazy(() => import('@/pages/NotFound'));
// Loading component
@@ -139,6 +142,9 @@ function App() {
</ProtectedRoute>
} />
<Route path="/presale" element={<Presale />} />
<Route path="/launchpad" element={<PresaleList />} />
<Route path="/launchpad/:id" element={<PresaleDetail />} />
<Route path="/launchpad/create" element={<CreatePresale />} />
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
+31 -31
View File
@@ -103,38 +103,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
clearInterval(timeoutChecker);
};
}, [user, updateLastActivity, checkSessionTimeout]);
useEffect(() => {
// Check active sessions and sets the user
supabase.auth.getSession().then(({ data: { session } }) => {
setUser(session?.user ?? null);
checkAdminStatus(); // Check admin status regardless of Supabase session
setLoading(false);
}).catch(() => {
// If Supabase is not available, still check wallet-based admin
checkAdminStatus();
setLoading(false);
});
// Listen for changes on auth state
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
setUser(session?.user ?? null);
checkAdminStatus(); // Check admin status on auth change
setLoading(false);
});
// Listen for wallet changes (from PolkadotContext)
const handleWalletChange = () => {
checkAdminStatus();
};
window.addEventListener('walletChanged', handleWalletChange);
return () => {
subscription.unsubscribe();
window.removeEventListener('walletChanged', handleWalletChange);
};
}, [checkAdminStatus]);
const checkAdminStatus = useCallback(async () => {
// Admin wallet whitelist (blockchain-based auth)
@@ -182,6 +151,37 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
}
}, []);
useEffect(() => {
// Check active sessions and sets the user
supabase.auth.getSession().then(({ data: { session } }) => {
setUser(session?.user ?? null);
checkAdminStatus(); // Check admin status regardless of Supabase session
setLoading(false);
}).catch(() => {
// If Supabase is not available, still check wallet-based admin
checkAdminStatus();
setLoading(false);
});
// Listen for changes on auth state
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
setUser(session?.user ?? null);
checkAdminStatus(); // Check admin status on auth change
setLoading(false);
});
// Listen for wallet changes (from PolkadotContext)
const handleWalletChange = () => {
checkAdminStatus();
};
window.addEventListener('walletChanged', handleWalletChange);
return () => {
subscription.unsubscribe();
window.removeEventListener('walletChanged', handleWalletChange);
};
}, [checkAdminStatus]);
const signIn = async (email: string, password: string) => {
try {
const { data, error } = await supabase.auth.signInWithPassword({
+510
View File
@@ -0,0 +1,510 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { usePolkadot } from '@/contexts/PolkadotContext';
import { useNavigate } from 'react-router-dom';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Separator } from '@/components/ui/separator';
import { ArrowLeft, Loader2, AlertCircle, CheckCircle2, Rocket } from 'lucide-react';
import { toast } from 'sonner';
export default function CreatePresale() {
const { t } = useTranslation();
const { api, selectedAccount, isApiReady } = usePolkadot();
const navigate = useNavigate();
const [creating, setCreating] = useState(false);
// Form state
const [formData, setFormData] = useState({
paymentAsset: '2', // wUSDT
rewardAsset: '1', // PEZ (or custom)
tokensForSale: '10000000', // 10M tokens for sale (with 6 decimals = 10M)
durationDays: '45',
isWhitelist: false,
minContribution: '10',
maxContribution: '10000',
hardCap: '500000',
enableVesting: false,
vestingImmediatePercent: '20',
vestingDurationDays: '180',
vestingCliffDays: '30',
gracePeriodHours: '24',
refundFeePercent: '10',
graceRefundFeePercent: '1',
});
const handleInputChange = (field: string, value: string | boolean) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const validateForm = () => {
const {
paymentAsset,
rewardAsset,
tokensForSale,
durationDays,
minContribution,
maxContribution,
hardCap,
refundFeePercent,
graceRefundFeePercent,
} = formData;
if (!paymentAsset || !rewardAsset) {
toast.error('Please specify both payment and reward assets');
return false;
}
if (parseFloat(tokensForSale) <= 0) {
toast.error('Tokens for sale must be greater than 0');
return false;
}
if (parseFloat(durationDays) <= 0) {
toast.error('Duration must be greater than 0');
return false;
}
if (parseFloat(minContribution) <= 0) {
toast.error('Min contribution must be greater than 0');
return false;
}
if (parseFloat(maxContribution) < parseFloat(minContribution)) {
toast.error('Max contribution must be >= min contribution');
return false;
}
if (parseFloat(hardCap) < parseFloat(minContribution)) {
toast.error('Hard cap must be >= min contribution');
return false;
}
if (
parseFloat(refundFeePercent) > 100 ||
parseFloat(graceRefundFeePercent) > 100
) {
toast.error('Fee percentages must be <= 100');
return false;
}
return true;
};
const handleCreate = async () => {
if (!api || !selectedAccount) {
toast.error('Please connect your wallet');
return;
}
if (!validateForm()) return;
setCreating(true);
try {
// Convert values to chain format
const paymentAssetId = parseInt(formData.paymentAsset);
const rewardAssetId = parseInt(formData.rewardAsset);
const tokensForSale = Math.floor(parseFloat(formData.tokensForSale) * 1_000_000); // with decimals
const durationBlocks = Math.floor(parseFloat(formData.durationDays) * 14400); // 1 day = 14400 blocks (6s)
const isWhitelist = formData.isWhitelist;
const minContribution = Math.floor(parseFloat(formData.minContribution) * 1_000_000);
const maxContribution = Math.floor(parseFloat(formData.maxContribution) * 1_000_000);
const hardCap = Math.floor(parseFloat(formData.hardCap) * 1_000_000);
const enableVesting = formData.enableVesting;
const vestingImmediatePercent = parseInt(formData.vestingImmediatePercent);
const vestingDurationBlocks = Math.floor(parseFloat(formData.vestingDurationDays) * 14400);
const vestingCliffBlocks = Math.floor(parseFloat(formData.vestingCliffDays) * 14400);
const gracePeriodBlocks = Math.floor(parseFloat(formData.gracePeriodHours) * 600); // 1 hour = 600 blocks
const refundFeePercent = parseInt(formData.refundFeePercent);
const graceRefundFeePercent = parseInt(formData.graceRefundFeePercent);
const tx = api.tx.presale.createPresale(
paymentAssetId,
rewardAssetId,
tokensForSale,
durationBlocks,
isWhitelist,
minContribution,
maxContribution,
hardCap,
enableVesting,
vestingImmediatePercent,
vestingDurationBlocks,
vestingCliffBlocks,
gracePeriodBlocks,
refundFeePercent,
graceRefundFeePercent
);
await tx.signAndSend(selectedAccount.address, ({ status, events }) => {
if (status.isInBlock) {
toast.success('Presale creation submitted!');
}
if (status.isFinalized) {
events.forEach(({ event }) => {
if (api.events.presale.PresaleCreated.is(event)) {
const [presaleId] = event.data;
toast.success(`Presale #${presaleId} created successfully!`);
setTimeout(() => {
navigate(`/launchpad/${presaleId}`);
}, 2000);
} else if (api.events.system.ExtrinsicFailed.is(event)) {
toast.error('Presale creation failed');
setCreating(false);
}
});
}
});
} catch (error: any) {
console.error('Create presale error:', error);
toast.error(error.message || 'Failed to create presale');
setCreating(false);
}
};
return (
<div className="container mx-auto px-4 py-8 max-w-4xl">
{/* Header */}
<div className="mb-6">
<Button variant="ghost" onClick={() => navigate('/launchpad')} className="mb-4 gap-2">
<ArrowLeft className="w-4 h-4" />
Back to Launchpad
</Button>
<h1 className="text-3xl font-bold mb-2">Create New Presale</h1>
<p className="text-muted-foreground">
Launch your token presale on PezkuwiChain Launchpad
</p>
</div>
{/* Info Alert */}
<Alert className="mb-6">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Platform fee: 2% on all contributions (50% Treasury, 25% Burn, 25% Stakers)
</AlertDescription>
</Alert>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Form */}
<div className="lg:col-span-2 space-y-6">
{/* Basic Settings */}
<Card className="p-6">
<h2 className="text-xl font-semibold mb-4">Basic Settings</h2>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="paymentAsset">Payment Asset ID</Label>
<Input
id="paymentAsset"
type="number"
value={formData.paymentAsset}
onChange={(e) => handleInputChange('paymentAsset', e.target.value)}
placeholder="2 (wUSDT)"
/>
<p className="text-xs text-muted-foreground mt-1">Default: 2 (wUSDT)</p>
</div>
<div>
<Label htmlFor="rewardAsset">Reward Asset ID</Label>
<Input
id="rewardAsset"
type="number"
value={formData.rewardAsset}
onChange={(e) => handleInputChange('rewardAsset', e.target.value)}
placeholder="1 (PEZ)"
/>
<p className="text-xs text-muted-foreground mt-1">Your token asset ID</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="tokensForSale">Tokens For Sale</Label>
<Input
id="tokensForSale"
type="number"
value={formData.tokensForSale}
onChange={(e) => handleInputChange('tokensForSale', e.target.value)}
placeholder="10000000"
/>
<p className="text-xs text-muted-foreground mt-1">
Total tokens available (with decimals)
</p>
</div>
<div>
<Label htmlFor="durationDays">Duration (Days)</Label>
<Input
id="durationDays"
type="number"
value={formData.durationDays}
onChange={(e) => handleInputChange('durationDays', e.target.value)}
placeholder="45"
/>
<p className="text-xs text-muted-foreground mt-1">Presale duration</p>
</div>
</div>
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
<div>
<Label htmlFor="isWhitelist" className="cursor-pointer">
Whitelist Only
</Label>
<p className="text-xs text-muted-foreground">
Restrict contributions to whitelisted accounts
</p>
</div>
<Switch
id="isWhitelist"
checked={formData.isWhitelist}
onCheckedChange={(checked) => handleInputChange('isWhitelist', checked)}
/>
</div>
</div>
</Card>
{/* Contribution Limits */}
<Card className="p-6">
<h2 className="text-xl font-semibold mb-4">Contribution Limits</h2>
<div className="space-y-4">
<div>
<Label htmlFor="minContribution">Min Contribution (USDT)</Label>
<Input
id="minContribution"
type="number"
value={formData.minContribution}
onChange={(e) => handleInputChange('minContribution', e.target.value)}
placeholder="10"
/>
</div>
<div>
<Label htmlFor="maxContribution">Max Contribution (USDT)</Label>
<Input
id="maxContribution"
type="number"
value={formData.maxContribution}
onChange={(e) => handleInputChange('maxContribution', e.target.value)}
placeholder="10000"
/>
</div>
<div>
<Label htmlFor="hardCap">Hard Cap (USDT)</Label>
<Input
id="hardCap"
type="number"
value={formData.hardCap}
onChange={(e) => handleInputChange('hardCap', e.target.value)}
placeholder="500000"
/>
<p className="text-xs text-muted-foreground mt-1">
Total amount to raise
</p>
</div>
</div>
</Card>
{/* Vesting */}
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">Vesting Schedule</h2>
<Switch
checked={formData.enableVesting}
onCheckedChange={(checked) => handleInputChange('enableVesting', checked)}
/>
</div>
{formData.enableVesting && (
<div className="space-y-4">
<div>
<Label htmlFor="vestingImmediatePercent">Immediate Release (%)</Label>
<Input
id="vestingImmediatePercent"
type="number"
value={formData.vestingImmediatePercent}
onChange={(e) =>
handleInputChange('vestingImmediatePercent', e.target.value)
}
placeholder="20"
max="100"
/>
<p className="text-xs text-muted-foreground mt-1">
Released immediately after presale
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="vestingDurationDays">Vesting Duration (Days)</Label>
<Input
id="vestingDurationDays"
type="number"
value={formData.vestingDurationDays}
onChange={(e) =>
handleInputChange('vestingDurationDays', e.target.value)
}
placeholder="180"
/>
</div>
<div>
<Label htmlFor="vestingCliffDays">Cliff Period (Days)</Label>
<Input
id="vestingCliffDays"
type="number"
value={formData.vestingCliffDays}
onChange={(e) =>
handleInputChange('vestingCliffDays', e.target.value)
}
placeholder="30"
/>
</div>
</div>
</div>
)}
</Card>
{/* Refund Policy */}
<Card className="p-6">
<h2 className="text-xl font-semibold mb-4">Refund Policy</h2>
<div className="space-y-4">
<div>
<Label htmlFor="gracePeriodHours">Grace Period (Hours)</Label>
<Input
id="gracePeriodHours"
type="number"
value={formData.gracePeriodHours}
onChange={(e) => handleInputChange('gracePeriodHours', e.target.value)}
placeholder="24"
/>
<p className="text-xs text-muted-foreground mt-1">
Lower fee period for refunds
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="graceRefundFeePercent">Grace Period Fee (%)</Label>
<Input
id="graceRefundFeePercent"
type="number"
value={formData.graceRefundFeePercent}
onChange={(e) =>
handleInputChange('graceRefundFeePercent', e.target.value)
}
placeholder="1"
max="100"
/>
</div>
<div>
<Label htmlFor="refundFeePercent">Normal Fee (%)</Label>
<Input
id="refundFeePercent"
type="number"
value={formData.refundFeePercent}
onChange={(e) => handleInputChange('refundFeePercent', e.target.value)}
placeholder="10"
max="100"
/>
</div>
</div>
</div>
</Card>
</div>
{/* Summary Sidebar */}
<div className="space-y-6">
<Card className="p-6 sticky top-6">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Rocket className="w-5 h-5" />
Presale Summary
</h3>
<Separator className="mb-4" />
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Tokens For Sale</span>
<span className="font-semibold">{parseFloat(formData.tokensForSale).toLocaleString()}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Estimated Rate</span>
<span className="font-semibold text-xs">
{formData.hardCap && formData.tokensForSale
? `1:${(parseFloat(formData.tokensForSale) / parseFloat(formData.hardCap)).toFixed(2)}`
: 'Dynamic'
}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Duration</span>
<span className="font-semibold">{formData.durationDays} days</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Hard Cap</span>
<span className="font-semibold">{formData.hardCap} USDT</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Min/Max</span>
<span className="font-semibold">
{formData.minContribution}/{formData.maxContribution}
</span>
</div>
<Separator />
<div className="flex justify-between">
<span className="text-muted-foreground">Access</span>
<Badge variant={formData.isWhitelist ? 'secondary' : 'default'}>
{formData.isWhitelist ? 'Whitelist' : 'Public'}
</Badge>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Vesting</span>
<Badge variant={formData.enableVesting ? 'default' : 'outline'}>
{formData.enableVesting ? 'Enabled' : 'Disabled'}
</Badge>
</div>
<Separator />
<div className="text-xs text-muted-foreground">
<p className="mb-2">Platform Fee: 2%</p>
<ul className="space-y-1">
<li> 50% Treasury</li>
<li> 25% Burn</li>
<li> 25% Stakers</li>
</ul>
</div>
</div>
<Button
className="w-full mt-6"
onClick={handleCreate}
disabled={creating || !selectedAccount}
>
{creating && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
{creating ? 'Creating...' : 'Create Presale'}
</Button>
{!selectedAccount && (
<Alert className="mt-4">
<AlertCircle className="h-4 w-4" />
<AlertDescription>Connect wallet to create presale</AlertDescription>
</Alert>
)}
</Card>
</div>
</div>
</div>
);
}
function Badge({ children, variant = 'default' }: { children: React.ReactNode; variant?: 'default' | 'secondary' | 'outline' }) {
const variants = {
default: 'bg-primary text-primary-foreground',
secondary: 'bg-secondary text-secondary-foreground',
outline: 'border border-input bg-background',
};
return (
<span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-semibold ${variants[variant]}`}>
{children}
</span>
);
}
+525
View File
@@ -0,0 +1,525 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { usePolkadot } from '@/contexts/PolkadotContext';
import { useWallet } from '@/contexts/WalletContext';
import { useParams, useNavigate } from 'react-router-dom';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Progress } from '@/components/ui/progress';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Loader2,
ArrowLeft,
TrendingUp,
Users,
Clock,
Target,
AlertCircle,
CheckCircle2,
Wallet,
RefreshCcw,
} from 'lucide-react';
import { toast } from 'sonner';
export default function PresaleDetail() {
const { id } = useParams();
const { t } = useTranslation();
const { api, selectedAccount, isApiReady } = usePolkadot();
const { balances } = useWallet();
const navigate = useNavigate();
const [presale, setPresale] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [contributing, setContributing] = useState(false);
const [refunding, setRefunding] = useState(false);
const [amount, setAmount] = useState('');
const [myContribution, setMyContribution] = useState('0');
const [currentBlock, setCurrentBlock] = useState(0);
const [totalRaised, setTotalRaised] = useState('0');
const [contributorsCount, setContributorsCount] = useState(0);
useEffect(() => {
if (isApiReady && id) {
loadPresaleData();
const interval = setInterval(loadPresaleData, 10000);
return () => clearInterval(interval);
}
}, [api, selectedAccount, isApiReady, id]);
const loadPresaleData = async () => {
if (!api || !id) return;
try {
const header = await api.rpc.chain.getHeader();
setCurrentBlock(header.number.toNumber());
const presaleData = await api.query.presale.presales(parseInt(id));
if (presaleData.isNone) {
toast.error('Presale not found');
navigate('/launchpad');
return;
}
const presaleInfo = presaleData.unwrap();
setPresale(presaleInfo.toHuman());
const raised = await api.query.presale.totalRaised(parseInt(id));
setTotalRaised(raised.toString());
const contributors = await api.query.presale.contributors(parseInt(id));
setContributorsCount(contributors.length);
if (selectedAccount) {
const contribution = await api.query.presale.contributions(
parseInt(id),
selectedAccount.address
);
setMyContribution(contribution.toString());
}
} catch (error) {
console.error('Error loading presale:', error);
toast.error('Failed to load presale data');
} finally {
setLoading(false);
}
};
const handleContribute = async () => {
if (!api || !selectedAccount || !amount || !id) return;
const amountValue = parseFloat(amount);
if (isNaN(amountValue) || amountValue <= 0) {
toast.error('Please enter a valid amount');
return;
}
setContributing(true);
try {
const amountInSmallestUnit = Math.floor(amountValue * 1_000_000);
const tx = api.tx.presale.contribute(parseInt(id), amountInSmallestUnit);
await tx.signAndSend(selectedAccount.address, ({ status, events }) => {
if (status.isInBlock) {
toast.success('Contribution submitted!');
}
if (status.isFinalized) {
events.forEach(({ event }) => {
if (api.events.system.ExtrinsicSuccess.is(event)) {
toast.success('Contribution successful!');
setAmount('');
loadPresaleData();
} else if (api.events.system.ExtrinsicFailed.is(event)) {
toast.error('Contribution failed');
}
});
setContributing(false);
}
});
} catch (error: any) {
console.error('Contribution error:', error);
toast.error(error.message || 'Failed to contribute');
setContributing(false);
}
};
const handleRefund = async () => {
if (!api || !selectedAccount || !id) return;
setRefunding(true);
try {
const tx = api.tx.presale.refund(parseInt(id));
await tx.signAndSend(selectedAccount.address, ({ status, events }) => {
if (status.isInBlock) {
toast.success('Refund submitted!');
}
if (status.isFinalized) {
events.forEach(({ event }) => {
if (api.events.system.ExtrinsicSuccess.is(event)) {
toast.success('Refund successful!');
loadPresaleData();
} else if (api.events.system.ExtrinsicFailed.is(event)) {
toast.error('Refund failed');
}
});
setRefunding(false);
}
});
} catch (error: any) {
console.error('Refund error:', error);
toast.error(error.message || 'Failed to refund');
setRefunding(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<Loader2 className="w-8 h-8 animate-spin" />
</div>
);
}
if (!presale) {
return null;
}
const getTimeRemaining = () => {
const endBlock = parseInt(presale.startBlock) + parseInt(presale.duration);
const remaining = endBlock - currentBlock;
if (remaining <= 0) return 'Ended';
const days = Math.floor((remaining * 6) / 86400);
const hours = Math.floor(((remaining * 6) % 86400) / 3600);
const minutes = Math.floor(((remaining * 6) % 3600) / 60);
return `${days}d ${hours}h ${minutes}m`;
};
const getProgress = () => {
const raised = parseFloat(totalRaised) / 1_000_000;
const cap = parseFloat(presale.limits.hardCap.replace(/,/g, '')) / 1_000_000;
return (raised / cap) * 100;
};
const calculateReward = () => {
const amountValue = parseFloat(amount);
if (isNaN(amountValue)) return 0;
// Dynamic calculation: (user_contribution / total_raised) * tokens_for_sale
const raised = parseFloat(totalRaised) || 1; // Avoid division by zero
const tokensForSale = parseFloat(presale.tokensForSale.replace(/,/g, ''));
return (amountValue / raised) * tokensForSale;
};
const getCurrentRate = () => {
if (!presale || !totalRaised) return 'Dynamic';
const raised = parseFloat(totalRaised) / 1_000_000;
if (raised === 0) return 'TBD';
const tokensForSale = parseFloat(presale.tokensForSale.replace(/,/g, '')) / 1_000_000;
return `1:${(tokensForSale / raised).toFixed(2)}`;
};
const isGracePeriod = () => {
const graceEnd = parseInt(presale.startBlock) + parseInt(presale.gracePeriodBlocks);
return currentBlock <= graceEnd;
};
const wusdtBalance = balances.find((b) => b.assetId === 2)?.balance || '0';
return (
<div className="container mx-auto px-4 py-8 max-w-6xl">
{/* Back Button */}
<Button
variant="ghost"
onClick={() => navigate('/launchpad')}
className="mb-6 gap-2"
>
<ArrowLeft className="w-4 h-4" />
Back to Launchpad
</Button>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
{/* Header */}
<Card className="p-6">
<div className="flex justify-between items-start mb-4">
<div>
<h1 className="text-3xl font-bold mb-2">Presale #{id}</h1>
<p className="text-muted-foreground">
Current Rate: {getCurrentRate()} (Dynamic)
</p>
</div>
<div className="flex gap-2">
<Badge
variant={
presale.status.type === 'Active'
? 'default'
: presale.status.type === 'Finalized'
? 'secondary'
: 'destructive'
}
>
{presale.status.type}
</Badge>
<Badge variant="outline">{presale.accessControl.type}</Badge>
</div>
</div>
{/* Progress */}
<div className="mb-6">
<div className="flex justify-between text-sm mb-2">
<span className="font-semibold">Progress</span>
<span className="font-semibold">{getProgress().toFixed(2)}%</span>
</div>
<Progress value={getProgress()} className="mb-2" />
<div className="flex justify-between text-sm text-muted-foreground">
<span>{(parseFloat(totalRaised) / 1_000_000).toFixed(2)} USDT Raised</span>
<span>
{(parseFloat(presale.limits.hardCap.replace(/,/g, '')) / 1_000_000).toFixed(0)} USDT Hard Cap
</span>
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center p-4 bg-muted rounded-lg">
<Users className="w-6 h-6 mx-auto mb-2 text-primary" />
<p className="text-2xl font-bold">{contributorsCount}</p>
<p className="text-xs text-muted-foreground">Contributors</p>
</div>
<div className="text-center p-4 bg-muted rounded-lg">
<Clock className="w-6 h-6 mx-auto mb-2 text-orange-500" />
<p className="text-lg font-bold">{getTimeRemaining()}</p>
<p className="text-xs text-muted-foreground">Time Left</p>
</div>
<div className="text-center p-4 bg-muted rounded-lg">
<Target className="w-6 h-6 mx-auto mb-2 text-green-500" />
<p className="text-lg font-bold">
{(parseFloat(presale.limits.minContribution.replace(/,/g, '')) / 1_000_000).toFixed(0)} USDT
</p>
<p className="text-xs text-muted-foreground">Min</p>
</div>
<div className="text-center p-4 bg-muted rounded-lg">
<Target className="w-6 h-6 mx-auto mb-2 text-blue-500" />
<p className="text-lg font-bold">
{(parseFloat(presale.limits.maxContribution.replace(/,/g, '')) / 1_000_000).toFixed(0)} USDT
</p>
<p className="text-xs text-muted-foreground">Max</p>
</div>
</div>
</Card>
{/* Tabs */}
<Tabs defaultValue="details">
<TabsList className="w-full">
<TabsTrigger value="details" className="flex-1">Details</TabsTrigger>
<TabsTrigger value="vesting" className="flex-1">Vesting</TabsTrigger>
<TabsTrigger value="refund" className="flex-1">Refund Policy</TabsTrigger>
</TabsList>
<TabsContent value="details">
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">Presale Information</h3>
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Payment Asset</span>
<span className="font-semibold">Asset #{presale.paymentAsset}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Reward Asset</span>
<span className="font-semibold">Asset #{presale.rewardAsset}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Tokens For Sale</span>
<span className="font-semibold">
{(parseFloat(presale.tokensForSale.replace(/,/g, '')) / 1_000_000).toLocaleString()}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Current Rate</span>
<span className="font-semibold">
{getCurrentRate()}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Duration</span>
<span className="font-semibold">
{Math.floor((parseInt(presale.duration) * 6) / 86400)} days
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Owner</span>
<span className="font-mono text-xs">{presale.owner.slice(0, 12)}...</span>
</div>
</div>
</Card>
</TabsContent>
<TabsContent value="vesting">
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">Vesting Schedule</h3>
{presale.vesting ? (
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Immediate Release</span>
<span className="font-semibold">
{presale.vesting.immediateReleasePercent}%
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Vesting Duration</span>
<span className="font-semibold">
{Math.floor((parseInt(presale.vesting.vestingDurationBlocks) * 6) / 86400)} days
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Cliff Period</span>
<span className="font-semibold">
{Math.floor((parseInt(presale.vesting.cliffBlocks) * 6) / 86400)} days
</span>
</div>
</div>
) : (
<p className="text-muted-foreground">No vesting schedule - tokens released immediately</p>
)}
</Card>
</TabsContent>
<TabsContent value="refund">
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">Refund Policy</h3>
<div className="space-y-4">
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{isGracePeriod() ? (
<span className="text-green-600 font-semibold">
Grace Period Active: {presale.graceRefundFeePercent}% fee
</span>
) : (
<span>
Normal Period: {presale.refundFeePercent}% fee
</span>
)}
</AlertDescription>
</Alert>
<div className="space-y-2 text-sm">
<p>
Grace Period ({Math.floor((parseInt(presale.gracePeriodBlocks) * 6) / 3600)}h):
<span className="font-semibold ml-2">{presale.graceRefundFeePercent}% fee</span>
</p>
<p>
After Grace Period:
<span className="font-semibold ml-2">{presale.refundFeePercent}% fee</span>
</p>
<p className="text-muted-foreground text-xs mt-4">
Platform fee split: 50% Treasury, 25% Burn, 25% Stakers
</p>
</div>
</div>
</Card>
</TabsContent>
</Tabs>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Contribute Card */}
{presale.status.type === 'Active' && (
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">Contribute</h3>
{!selectedAccount ? (
<Alert>
<Wallet className="h-4 w-4" />
<AlertDescription>
Please connect your wallet to contribute
</AlertDescription>
</Alert>
) : (
<div className="space-y-4">
<div>
<label className="text-sm font-medium mb-2 block">Amount (wUSDT)</label>
<Input
type="number"
placeholder="0.00"
value={amount}
onChange={(e) => setAmount(e.target.value)}
disabled={contributing}
/>
<p className="text-xs text-muted-foreground mt-1">
Balance: {(parseFloat(wusdtBalance) / 1_000_000).toFixed(2)} wUSDT
</p>
</div>
{amount && (
<Alert>
<CheckCircle2 className="h-4 w-4" />
<AlertDescription>
You will receive: <strong>{calculateReward().toFixed(2)}</strong> tokens
</AlertDescription>
</Alert>
)}
<Button
className="w-full"
onClick={handleContribute}
disabled={contributing || !amount}
>
{contributing && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
{contributing ? 'Contributing...' : 'Contribute Now'}
</Button>
<div className="text-xs text-center text-muted-foreground">
Platform fee: 2% (split 50-25-25)
</div>
</div>
)}
</Card>
)}
{/* My Contribution Card */}
{selectedAccount && parseFloat(myContribution) > 0 && (
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">My Contribution</h3>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-muted-foreground">Contributed</span>
<span className="font-bold">
{(parseFloat(myContribution) / 1_000_000).toFixed(2)} wUSDT
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Will Receive (Est.)</span>
<span className="font-bold">
{(() => {
const raised = parseFloat(totalRaised) / 1_000_000;
const myContr = parseFloat(myContribution) / 1_000_000;
const tokensForSale = parseFloat(presale.tokensForSale.replace(/,/g, '')) / 1_000_000;
return raised > 0 ? ((myContr / raised) * tokensForSale).toFixed(2) : '0.00';
})()}{' '}
tokens
</span>
</div>
{presale.status.type === 'Active' && (
<Button
variant="outline"
className="w-full mt-4 gap-2"
onClick={handleRefund}
disabled={refunding}
>
{refunding && <Loader2 className="w-4 h-4 animate-spin" />}
<RefreshCcw className="w-4 h-4" />
{refunding ? 'Processing...' : 'Request Refund'}
</Button>
)}
</div>
</Card>
)}
{/* Info Card */}
<Card className="p-6 bg-muted">
<h4 className="font-semibold mb-3">Important Information</h4>
<ul className="space-y-2 text-sm text-muted-foreground">
<li> Contributions are subject to 2% platform fee</li>
<li> Refunds available before presale ends (fees apply)</li>
<li> Lower fees during grace period</li>
<li> Check vesting schedule before contributing</li>
</ul>
</Card>
</div>
</div>
</div>
);
}
+288
View File
@@ -0,0 +1,288 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { usePolkadot } from '@/contexts/PolkadotContext';
import { useNavigate } from 'react-router-dom';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Badge } from '@/components/ui/badge';
import { Loader2, Plus, TrendingUp, Users, Clock, Target } from 'lucide-react';
interface PresaleInfo {
id: number;
owner: string;
paymentAsset: number;
rewardAsset: number;
tokensForSale: string;
startBlock: number;
duration: number;
status: 'Active' | 'Finalized' | 'Cancelled';
totalRaised: string;
contributorsCount: number;
limits: {
minContribution: string;
maxContribution: string;
hardCap: string;
};
accessControl: 'Public' | 'Whitelist';
}
export default function PresaleList() {
const { t } = useTranslation();
const { api, isApiReady } = usePolkadot();
const navigate = useNavigate();
const [presales, setPresales] = useState<PresaleInfo[]>([]);
const [loading, setLoading] = useState(true);
const [currentBlock, setCurrentBlock] = useState(0);
useEffect(() => {
if (isApiReady) {
loadPresales();
const interval = setInterval(loadPresales, 15000);
return () => clearInterval(interval);
}
}, [api, isApiReady]);
const loadPresales = async () => {
if (!api) return;
try {
// Get current block
const header = await api.rpc.chain.getHeader();
setCurrentBlock(header.number.toNumber());
// Get next presale ID to know how many presales exist
const nextId = await api.query.presale.nextPresaleId();
const count = nextId.toNumber();
const presaleList: PresaleInfo[] = [];
// Load all presales
for (let i = 0; i < count; i++) {
const presaleData = await api.query.presale.presales(i);
if (presaleData.isSome) {
const presale = presaleData.unwrap();
const totalRaised = await api.query.presale.totalRaised(i);
const contributors = await api.query.presale.contributors(i);
presaleList.push({
id: i,
owner: presale.owner.toString(),
paymentAsset: presale.paymentAsset.toNumber(),
rewardAsset: presale.rewardAsset.toNumber(),
tokensForSale: presale.tokensForSale.toString(),
startBlock: presale.startBlock.toNumber(),
duration: presale.duration.toNumber(),
status: presale.status.type,
totalRaised: totalRaised.toString(),
contributorsCount: contributors.length,
limits: {
minContribution: presale.limits.minContribution.toString(),
maxContribution: presale.limits.maxContribution.toString(),
hardCap: presale.limits.hardCap.toString(),
},
accessControl: presale.accessControl.type,
});
}
}
setPresales(presaleList.reverse()); // Show newest first
} catch (error) {
console.error('Error loading presales:', error);
} finally {
setLoading(false);
}
};
const getTimeRemaining = (startBlock: number, duration: number) => {
const endBlock = startBlock + duration;
const remaining = endBlock - currentBlock;
if (remaining <= 0) return 'Ended';
const days = Math.floor((remaining * 6) / 86400);
const hours = Math.floor(((remaining * 6) % 86400) / 3600);
return `${days}d ${hours}h`;
};
const getProgress = (raised: string, hardCap: string) => {
const raisedNum = parseFloat(raised) / 1_000_000;
const capNum = parseFloat(hardCap) / 1_000_000;
return (raisedNum / capNum) * 100;
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<Loader2 className="w-8 h-8 animate-spin" />
</div>
);
}
return (
<div className="container mx-auto px-4 py-8 max-w-7xl">
{/* Header */}
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-bold mb-2">
{t('presale.launchpad.title', 'PezkuwiChain Launchpad')}
</h1>
<p className="text-muted-foreground">
{t('presale.launchpad.subtitle', 'Discover and invest in new token presales')}
</p>
</div>
<Button onClick={() => navigate('/launchpad/create')} className="gap-2">
<Plus className="w-4 h-4" />
{t('presale.create.button', 'Create Presale')}
</Button>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<Card className="p-4">
<div className="flex items-center gap-3">
<TrendingUp className="w-8 h-8 text-primary" />
<div>
<p className="text-sm text-muted-foreground">Total Presales</p>
<p className="text-2xl font-bold">{presales.length}</p>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<Target className="w-8 h-8 text-green-500" />
<div>
<p className="text-sm text-muted-foreground">Active</p>
<p className="text-2xl font-bold">
{presales.filter(p => p.status === 'Active').length}
</p>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<Users className="w-8 h-8 text-blue-500" />
<div>
<p className="text-sm text-muted-foreground">Total Contributors</p>
<p className="text-2xl font-bold">
{presales.reduce((sum, p) => sum + p.contributorsCount, 0)}
</p>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<Clock className="w-8 h-8 text-orange-500" />
<div>
<p className="text-sm text-muted-foreground">Completed</p>
<p className="text-2xl font-bold">
{presales.filter(p => p.status === 'Finalized').length}
</p>
</div>
</div>
</Card>
</div>
{/* Presale Cards */}
{presales.length === 0 ? (
<Card className="p-12 text-center">
<Target className="w-16 h-16 mx-auto mb-4 text-muted-foreground" />
<h3 className="text-xl font-semibold mb-2">No Presales Yet</h3>
<p className="text-muted-foreground mb-4">
Be the first to create a presale on PezkuwiChain
</p>
<Button onClick={() => navigate('/launchpad/create')}>
Create First Presale
</Button>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{presales.map((presale) => (
<Card
key={presale.id}
className="p-6 hover:shadow-lg transition-shadow cursor-pointer"
onClick={() => navigate(`/launchpad/${presale.id}`)}
>
{/* Status Badge */}
<div className="flex justify-between items-start mb-4">
<Badge
variant={
presale.status === 'Active'
? 'default'
: presale.status === 'Finalized'
? 'secondary'
: 'destructive'
}
>
{presale.status}
</Badge>
<Badge variant="outline">{presale.accessControl}</Badge>
</div>
{/* Presale Info */}
<h3 className="text-xl font-bold mb-2">
Presale #{presale.id}
</h3>
<p className="text-sm text-muted-foreground mb-4">
{parseFloat(presale.totalRaised) > 0
? `Rate: 1:${((parseFloat(presale.tokensForSale) / parseFloat(presale.totalRaised))).toFixed(2)} (Dynamic)`
: `${(parseFloat(presale.tokensForSale) / 1_000_000).toLocaleString()} tokens for sale`
}
</p>
{/* Progress */}
<div className="mb-4">
<div className="flex justify-between text-sm mb-2">
<span>Progress</span>
<span className="font-semibold">
{getProgress(presale.totalRaised, presale.limits.hardCap).toFixed(1)}%
</span>
</div>
<Progress
value={getProgress(presale.totalRaised, presale.limits.hardCap)}
/>
<div className="flex justify-between text-xs text-muted-foreground mt-1">
<span>
{(parseFloat(presale.totalRaised) / 1_000_000).toFixed(2)} USDT
</span>
<span>
{(parseFloat(presale.limits.hardCap) / 1_000_000).toFixed(0)} USDT
</span>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<p className="text-xs text-muted-foreground">Contributors</p>
<p className="text-lg font-semibold">{presale.contributorsCount}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Time Left</p>
<p className="text-lg font-semibold">
{presale.status === 'Active'
? getTimeRemaining(presale.startBlock, presale.duration)
: '-'}
</p>
</div>
</div>
{/* CTA */}
<Button
className="w-full"
variant={presale.status === 'Active' ? 'default' : 'secondary'}
onClick={(e) => {
e.stopPropagation();
navigate(`/launchpad/${presale.id}`);
}}
>
{presale.status === 'Active' ? 'Contribute Now' : 'View Details'}
</Button>
</Card>
))}
</div>
)}
</div>
);
}