From 9de2d853aaaa86f7dc9d2ea0dbd8baae42ae490f Mon Sep 17 00:00:00 2001 From: Kurdistan Tech Ministry Date: Thu, 20 Nov 2025 18:32:08 +0300 Subject: [PATCH] fix: AuthContext hoisting error and add presale launchpad UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/App.tsx | 6 + web/src/contexts/AuthContext.tsx | 62 +-- web/src/pages/launchpad/CreatePresale.tsx | 510 +++++++++++++++++++++ web/src/pages/launchpad/PresaleDetail.tsx | 525 ++++++++++++++++++++++ web/src/pages/launchpad/PresaleList.tsx | 288 ++++++++++++ 5 files changed, 1360 insertions(+), 31 deletions(-) create mode 100644 web/src/pages/launchpad/CreatePresale.tsx create mode 100644 web/src/pages/launchpad/PresaleDetail.tsx create mode 100644 web/src/pages/launchpad/PresaleList.tsx 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 +

+
+
+
+ + + handleInputChange('vestingDurationDays', e.target.value) + } + placeholder="180" + /> +
+
+ + + handleInputChange('vestingCliffDays', e.target.value) + } + placeholder="30" + /> +
+
+
+ )} +
+ + {/* Refund Policy */} + +

Refund Policy

+
+
+ + handleInputChange('gracePeriodHours', e.target.value)} + placeholder="24" + /> +

+ Lower fee period for refunds +

+
+
+
+ + + handleInputChange('graceRefundFeePercent', e.target.value) + } + placeholder="1" + max="100" + /> +
+
+ + handleInputChange('refundFeePercent', e.target.value)} + placeholder="10" + max="100" + /> +
+
+
+
+
+ + {/* 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 */} + +
+ ))} +
+ )} +
+ ); +}