diff --git a/web/src/App.tsx b/web/src/App.tsx
index 29e64dd7..90e07981 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -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() {
} />
} />
+ } />
+ } />
+ } />
} />
diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx
index 26aa4ab4..6162a0de 100644
--- a/web/src/contexts/AuthContext.tsx
+++ b/web/src/contexts/AuthContext.tsx
@@ -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({
diff --git a/web/src/pages/launchpad/CreatePresale.tsx b/web/src/pages/launchpad/CreatePresale.tsx
new file mode 100644
index 00000000..e5f2543d
--- /dev/null
+++ b/web/src/pages/launchpad/CreatePresale.tsx
@@ -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 (
+
+ {/* Header */}
+
+
+
Create New Presale
+
+ Launch your token presale on PezkuwiChain Launchpad
+
+
+
+ {/* Info Alert */}
+
+
+
+ Platform fee: 2% on all contributions (50% Treasury, 25% Burn, 25% Stakers)
+
+
+
+
+ {/* Form */}
+
+ {/* Basic Settings */}
+
+ Basic Settings
+
+
+
+
+
handleInputChange('paymentAsset', e.target.value)}
+ placeholder="2 (wUSDT)"
+ />
+
Default: 2 (wUSDT)
+
+
+
+
handleInputChange('rewardAsset', e.target.value)}
+ placeholder="1 (PEZ)"
+ />
+
Your token asset ID
+
+
+
+
+
+
+
handleInputChange('tokensForSale', e.target.value)}
+ placeholder="10000000"
+ />
+
+ Total tokens available (with decimals)
+
+
+
+
+
handleInputChange('durationDays', e.target.value)}
+ placeholder="45"
+ />
+
Presale duration
+
+
+
+
+
+
+
+ Restrict contributions to whitelisted accounts
+
+
+
handleInputChange('isWhitelist', checked)}
+ />
+
+
+
+
+ {/* Contribution Limits */}
+
+ Contribution Limits
+
+
+
+ handleInputChange('minContribution', e.target.value)}
+ placeholder="10"
+ />
+
+
+
+ handleInputChange('maxContribution', e.target.value)}
+ placeholder="10000"
+ />
+
+
+
+
handleInputChange('hardCap', e.target.value)}
+ placeholder="500000"
+ />
+
+ Total amount to raise
+
+
+
+
+
+ {/* Vesting */}
+
+
+
Vesting Schedule
+ handleInputChange('enableVesting', checked)}
+ />
+
+
+ {formData.enableVesting && (
+
+
+
+
+ handleInputChange('vestingImmediatePercent', e.target.value)
+ }
+ placeholder="20"
+ max="100"
+ />
+
+ Released immediately after presale
+
+
+
+
+ )}
+
+
+ {/* Refund Policy */}
+
+ Refund Policy
+
+
+
+
handleInputChange('gracePeriodHours', e.target.value)}
+ placeholder="24"
+ />
+
+ Lower fee period for refunds
+
+
+
+
+
+
+
+ {/* Summary Sidebar */}
+
+
+
+
+ Presale Summary
+
+
+
+
+ Tokens For Sale
+ {parseFloat(formData.tokensForSale).toLocaleString()}
+
+
+ Estimated Rate
+
+ {formData.hardCap && formData.tokensForSale
+ ? `1:${(parseFloat(formData.tokensForSale) / parseFloat(formData.hardCap)).toFixed(2)}`
+ : 'Dynamic'
+ }
+
+
+
+ Duration
+ {formData.durationDays} days
+
+
+ Hard Cap
+ {formData.hardCap} USDT
+
+
+ Min/Max
+
+ {formData.minContribution}/{formData.maxContribution}
+
+
+
+
+ Access
+
+ {formData.isWhitelist ? 'Whitelist' : 'Public'}
+
+
+
+ Vesting
+
+ {formData.enableVesting ? 'Enabled' : 'Disabled'}
+
+
+
+
+
Platform Fee: 2%
+
+ - • 50% → Treasury
+ - • 25% → Burn
+ - • 25% → Stakers
+
+
+
+
+
+
+ {!selectedAccount && (
+
+
+ Connect wallet to create presale
+
+ )}
+
+
+
+
+ );
+}
+
+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 (
+
+ {children}
+
+ );
+}
diff --git a/web/src/pages/launchpad/PresaleDetail.tsx b/web/src/pages/launchpad/PresaleDetail.tsx
new file mode 100644
index 00000000..328d6861
--- /dev/null
+++ b/web/src/pages/launchpad/PresaleDetail.tsx
@@ -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(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 (
+
+
+
+ );
+ }
+
+ 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 (
+
+ {/* Back Button */}
+
+
+
+ {/* Main Content */}
+
+ {/* Header */}
+
+
+
+
Presale #{id}
+
+ Current Rate: {getCurrentRate()} (Dynamic)
+
+
+
+
+ {presale.status.type}
+
+ {presale.accessControl.type}
+
+
+
+ {/* Progress */}
+
+
+ Progress
+ {getProgress().toFixed(2)}%
+
+
+
+ {(parseFloat(totalRaised) / 1_000_000).toFixed(2)} USDT Raised
+
+ {(parseFloat(presale.limits.hardCap.replace(/,/g, '')) / 1_000_000).toFixed(0)} USDT Hard Cap
+
+
+
+
+ {/* Stats Grid */}
+
+
+
+
{contributorsCount}
+
Contributors
+
+
+
+
{getTimeRemaining()}
+
Time Left
+
+
+
+
+ {(parseFloat(presale.limits.minContribution.replace(/,/g, '')) / 1_000_000).toFixed(0)} USDT
+
+
Min
+
+
+
+
+ {(parseFloat(presale.limits.maxContribution.replace(/,/g, '')) / 1_000_000).toFixed(0)} USDT
+
+
Max
+
+
+
+
+ {/* Tabs */}
+
+
+ Details
+ Vesting
+ Refund Policy
+
+
+
+
+ Presale Information
+
+
+ Payment Asset
+ Asset #{presale.paymentAsset}
+
+
+ Reward Asset
+ Asset #{presale.rewardAsset}
+
+
+ Tokens For Sale
+
+ {(parseFloat(presale.tokensForSale.replace(/,/g, '')) / 1_000_000).toLocaleString()}
+
+
+
+ Current Rate
+
+ {getCurrentRate()}
+
+
+
+ Duration
+
+ {Math.floor((parseInt(presale.duration) * 6) / 86400)} days
+
+
+
+ Owner
+ {presale.owner.slice(0, 12)}...
+
+
+
+
+
+
+
+ Vesting Schedule
+ {presale.vesting ? (
+
+
+ Immediate Release
+
+ {presale.vesting.immediateReleasePercent}%
+
+
+
+ Vesting Duration
+
+ {Math.floor((parseInt(presale.vesting.vestingDurationBlocks) * 6) / 86400)} days
+
+
+
+ Cliff Period
+
+ {Math.floor((parseInt(presale.vesting.cliffBlocks) * 6) / 86400)} days
+
+
+
+ ) : (
+ No vesting schedule - tokens released immediately
+ )}
+
+
+
+
+
+ Refund Policy
+
+
+
+
+ {isGracePeriod() ? (
+
+ Grace Period Active: {presale.graceRefundFeePercent}% fee
+
+ ) : (
+
+ Normal Period: {presale.refundFeePercent}% fee
+
+ )}
+
+
+
+
+ Grace Period ({Math.floor((parseInt(presale.gracePeriodBlocks) * 6) / 3600)}h):
+ {presale.graceRefundFeePercent}% fee
+
+
+ After Grace Period:
+ {presale.refundFeePercent}% fee
+
+
+ Platform fee split: 50% Treasury, 25% Burn, 25% Stakers
+
+
+
+
+
+
+
+
+ {/* Sidebar */}
+
+ {/* Contribute Card */}
+ {presale.status.type === 'Active' && (
+
+ Contribute
+
+ {!selectedAccount ? (
+
+
+
+ Please connect your wallet to contribute
+
+
+ ) : (
+
+
+
+
setAmount(e.target.value)}
+ disabled={contributing}
+ />
+
+ Balance: {(parseFloat(wusdtBalance) / 1_000_000).toFixed(2)} wUSDT
+
+
+
+ {amount && (
+
+
+
+ You will receive: {calculateReward().toFixed(2)} tokens
+
+
+ )}
+
+
+
+
+ Platform fee: 2% (split 50-25-25)
+
+
+ )}
+
+ )}
+
+ {/* My Contribution Card */}
+ {selectedAccount && parseFloat(myContribution) > 0 && (
+
+ My Contribution
+
+
+ Contributed
+
+ {(parseFloat(myContribution) / 1_000_000).toFixed(2)} wUSDT
+
+
+
+ Will Receive (Est.)
+
+ {(() => {
+ 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
+
+
+
+ {presale.status.type === 'Active' && (
+
+ )}
+
+
+ )}
+
+ {/* Info Card */}
+
+ Important Information
+
+ - • Contributions are subject to 2% platform fee
+ - • Refunds available before presale ends (fees apply)
+ - • Lower fees during grace period
+ - • Check vesting schedule before contributing
+
+
+
+
+
+ );
+}
diff --git a/web/src/pages/launchpad/PresaleList.tsx b/web/src/pages/launchpad/PresaleList.tsx
new file mode 100644
index 00000000..6324f5b7
--- /dev/null
+++ b/web/src/pages/launchpad/PresaleList.tsx
@@ -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([]);
+ 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 (
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+ {t('presale.launchpad.title', 'PezkuwiChain Launchpad')}
+
+
+ {t('presale.launchpad.subtitle', 'Discover and invest in new token presales')}
+
+
+
+
+
+ {/* Stats */}
+
+
+
+
+
+
Total Presales
+
{presales.length}
+
+
+
+
+
+
+
+
Active
+
+ {presales.filter(p => p.status === 'Active').length}
+
+
+
+
+
+
+
+
+
Total Contributors
+
+ {presales.reduce((sum, p) => sum + p.contributorsCount, 0)}
+
+
+
+
+
+
+
+
+
Completed
+
+ {presales.filter(p => p.status === 'Finalized').length}
+
+
+
+
+
+
+ {/* Presale Cards */}
+ {presales.length === 0 ? (
+
+
+ No Presales Yet
+
+ Be the first to create a presale on PezkuwiChain
+
+
+
+ ) : (
+
+ {presales.map((presale) => (
+
navigate(`/launchpad/${presale.id}`)}
+ >
+ {/* Status Badge */}
+
+
+ {presale.status}
+
+ {presale.accessControl}
+
+
+ {/* Presale Info */}
+
+ Presale #{presale.id}
+
+
+ {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`
+ }
+
+
+ {/* Progress */}
+
+
+ Progress
+
+ {getProgress(presale.totalRaised, presale.limits.hardCap).toFixed(1)}%
+
+
+
+
+
+ {(parseFloat(presale.totalRaised) / 1_000_000).toFixed(2)} USDT
+
+
+ {(parseFloat(presale.limits.hardCap) / 1_000_000).toFixed(0)} USDT
+
+
+
+
+ {/* Stats */}
+
+
+
Contributors
+
{presale.contributorsCount}
+
+
+
Time Left
+
+ {presale.status === 'Active'
+ ? getTimeRemaining(presale.startBlock, presale.duration)
+ : '-'}
+
+
+
+
+ {/* CTA */}
+
+
+ ))}
+
+ )}
+
+ );
+}