mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-04-22 05:37:56 +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 P2PPlatform = lazy(() => import('./pages/P2PPlatform'));
|
||||||
const DEXDashboard = lazy(() => import('./components/dex/DEXDashboard').then(m => ({ default: m.DEXDashboard })));
|
const DEXDashboard = lazy(() => import('./components/dex/DEXDashboard').then(m => ({ default: m.DEXDashboard })));
|
||||||
const Presale = lazy(() => import('./pages/Presale'));
|
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'));
|
const NotFound = lazy(() => import('@/pages/NotFound'));
|
||||||
|
|
||||||
// Loading component
|
// Loading component
|
||||||
@@ -139,6 +142,9 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
<Route path="/presale" element={<Presale />} />
|
<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 />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -103,38 +103,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
clearInterval(timeoutChecker);
|
clearInterval(timeoutChecker);
|
||||||
};
|
};
|
||||||
}, [user, updateLastActivity, checkSessionTimeout]);
|
}, [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 () => {
|
const checkAdminStatus = useCallback(async () => {
|
||||||
// Admin wallet whitelist (blockchain-based auth)
|
// 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) => {
|
const signIn = async (email: string, password: string) => {
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase.auth.signInWithPassword({
|
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