mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-04-22 02:07:55 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user