diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..e1241c0 Binary files /dev/null and b/.DS_Store differ diff --git a/ui/app/(vscode)/burn/[tokenAddress]/page.tsx b/ui/app/(vscode)/burn/[tokenAddress]/page.tsx new file mode 100644 index 0000000..db92089 --- /dev/null +++ b/ui/app/(vscode)/burn/[tokenAddress]/page.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { BurnContent } from '@/components/BurnContent'; +import { useParams } from 'next/navigation'; + +export default function BurnPage() { + const params = useParams(); + const tokenAddress = params.tokenAddress as string; + + // Token symbol and userBalance will be fetched in BurnContent component + // For now, pass empty strings - BurnContent should handle fetching this data + + return ; +} diff --git a/ui/app/(vscode)/claim/page.tsx b/ui/app/(vscode)/claim/page.tsx new file mode 100644 index 0000000..0fcaa05 --- /dev/null +++ b/ui/app/(vscode)/claim/page.tsx @@ -0,0 +1,5 @@ +import { ClaimContent } from '@/components/ClaimContent'; + +export default function ClaimPage() { + return ; +} diff --git a/ui/app/(vscode)/faq/page.tsx b/ui/app/(vscode)/faq/page.tsx new file mode 100644 index 0000000..7b6e9d7 --- /dev/null +++ b/ui/app/(vscode)/faq/page.tsx @@ -0,0 +1,30 @@ +import Link from 'next/link'; + +export default function FaqPage() { + return ( +
+

FAQ

+

{'//'}Why are all ZC launched tokens (including $ZC) mintable?

+

Only the ZC protocol (NOT the token dev) can mint tokens. It will do so automatically at the end of each Quantum Market to pay users whose PRs get merged. This aligns incentives with token price growth, rewarding all users who create value.

+ +

{'//'}What is the utility of $ZC?

+

$ZC represents a stake in the Z Combinator treasury, which receives a portion of all token mints from platform launches. Other launched tokens on ZC have utilities as determined by their founders. More $ZC utilities coming soon.

+ +

{'//'}How does staking work? What are the rewards for staking?

+

All ZC launched tokens will have native staking. Users who lock their tokens in the vault will earn rewards from protocol-minted tokens. Currently only available for $ZC and $oogway.

+ +

{'//'}Are there trading fees?

+

There are no trading fees for any ZC launched token currently.

+ +

{'//'}As a dev, isn't it weird that I have to dump my tokens to fund myself?

+

Projects relying on trading fees are unsustainable. Controlled token emissions let founders fuel growth through incentives, creating long-term value. Both users and founders get rich by contributing to and sharing ownership of a valuable project.

+ +

{'//'}How can you get involved?

+

> If you want to found a startup, launch a ZC token and follow the steps on the landing page.

+

> If you want help grow existing projects, submit PRs to and trade Quantum Markets for any ZC launched project (including ZC itself!) to earn substantial token rewards.

+ +

{'//'}Have other questions?

+

Join our discord and ask them!

+
+ ); +} \ No newline at end of file diff --git a/ui/app/(vscode)/history/[tokenAddress]/page.tsx b/ui/app/(vscode)/history/[tokenAddress]/page.tsx new file mode 100644 index 0000000..61eeb45 --- /dev/null +++ b/ui/app/(vscode)/history/[tokenAddress]/page.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { HistoryContent } from '@/components/HistoryContent'; +import { useParams } from 'next/navigation'; + +export default function HistoryPage() { + const params = useParams(); + const tokenAddress = params.tokenAddress as string; + + // Extract token symbol from URL if needed, or fetch it + // For now, we'll pass it as a query param or fetch it in HistoryContent + + return ; +} \ No newline at end of file diff --git a/ui/app/(vscode)/holders/[tokenAddress]/page.tsx b/ui/app/(vscode)/holders/[tokenAddress]/page.tsx new file mode 100644 index 0000000..cac0efc --- /dev/null +++ b/ui/app/(vscode)/holders/[tokenAddress]/page.tsx @@ -0,0 +1,11 @@ +'use client'; + +import { HoldersContent } from '@/components/HoldersContent'; +import { useParams } from 'next/navigation'; + +export default function HoldersPage() { + const params = useParams(); + const tokenAddress = params.tokenAddress as string; + + return ; +} \ No newline at end of file diff --git a/ui/app/(vscode)/launch/page.tsx b/ui/app/(vscode)/launch/page.tsx new file mode 100644 index 0000000..a24e766 --- /dev/null +++ b/ui/app/(vscode)/launch/page.tsx @@ -0,0 +1,5 @@ +import { LaunchContent } from '@/components/LaunchContent'; + +export default function LaunchPage() { + return ; +} \ No newline at end of file diff --git a/ui/app/(vscode)/layout.tsx b/ui/app/(vscode)/layout.tsx new file mode 100644 index 0000000..57158bd --- /dev/null +++ b/ui/app/(vscode)/layout.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { usePathname } from 'next/navigation'; +import { Sidebar } from '@/components/Sidebar'; +import { Header } from '@/components/Header'; +import { LineNumbers } from '@/components/LineNumbers'; +import { Footer } from '@/components/Footer'; +import { TabProvider } from '@/contexts/TabContext'; + +function VscodeLayoutContent({ + children, +}: { + children: React.ReactNode; +}) { + const [lineCount, setLineCount] = useState(1); + const contentRef = useRef(null); + const pathname = usePathname(); + + useEffect(() => { + const updateLineCount = () => { + if (contentRef.current) { + const contentHeight = contentRef.current.scrollHeight; + const lineHeight = 24; + const calculatedLines = Math.max(Math.ceil(contentHeight / lineHeight), 1); + setLineCount(calculatedLines); + } + }; + + // Initial update + const frameId = requestAnimationFrame(updateLineCount); + + // Recalculate on window resize + window.addEventListener('resize', updateLineCount); + + // Watch for DOM changes (when content loads dynamically) + let observer: MutationObserver | null = null; + if (contentRef.current) { + observer = new MutationObserver(updateLineCount); + observer.observe(contentRef.current, { + childList: true, + subtree: true, + attributes: true, + characterData: true + }); + } + + return () => { + cancelAnimationFrame(frameId); + window.removeEventListener('resize', updateLineCount); + if (observer) { + observer.disconnect(); + } + }; + }, [pathname, children]); + + return ( +
+ + + {/* Main Content */} +
+
+ + {/* Content Area with Line Numbers */} +
+ + + {/* Main Content Column */} +
+ {children} +
+
+
+ +
+
+ ); +} + +export default function VscodeLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/ui/app/(vscode)/page.tsx b/ui/app/(vscode)/page.tsx new file mode 100644 index 0000000..6a9b995 --- /dev/null +++ b/ui/app/(vscode)/page.tsx @@ -0,0 +1,39 @@ +import Image from 'next/image'; + +export default function LandingPage() { + return ( +
+

+ Z + Combinator +

+

{'//'}What is ZC?

+

A launchpad that helps founders hit PMF

+

{'//'}Thesis

+

The highest signal product feedback is a ready-to-merge PR made and selected by your users.

+

{'//'}What problems are ZC solving for you as a founder?

+

> I don't know what to build b/c

+

> I'm getting no feedback (at worst) and bad feedback (at best) b/c

+

> I'm poorly incentivizing my users to give me good feedback b/c

+

> I don't know how valueable each piece of feedback is

+

{'//'}How does ZC solve these problems?

+

From Zero to PMF with ZC:

+

1. Come up with an idea and build the MVP.

+

2. Open source your code and launch a ZC token for it.

+

3. ZC spins up a Percent Quantum Market (QM) for selecting the best user-submitted PR to merge.

+

4. Invite your users to submit PRs and trade the QM.

+

5. When the QM ends, the best performing PR gets merged and tokens get minted to pay the user who made the PR an amount proportional to how much the PR increased your token price.

+

6. Rerun steps 3-5 (ZC does this) while you build until you hit PMF.

+

{'//'}Want to help build ZC?

+

Submit PRs to the ZC codebase and trade the ZC QMs to shape the future of the protocol.

+

{'//'}Have questions?

+

Join our discord and ask them!

+
+ ); +} \ No newline at end of file diff --git a/ui/app/(vscode)/portfolio/page.tsx b/ui/app/(vscode)/portfolio/page.tsx new file mode 100644 index 0000000..bf9bcc7 --- /dev/null +++ b/ui/app/(vscode)/portfolio/page.tsx @@ -0,0 +1,933 @@ +'use client'; + +import { useWallet } from '@/components/WalletProvider'; +import { ClaimButton } from '@/components/ClaimButton'; +import { SecureVerificationModal } from '@/components/SecureVerificationModal'; +import { useState, useEffect } from 'react'; +import { usePrivy } from '@privy-io/react-auth'; +import { useTabContext } from '@/contexts/TabContext'; +import { useRouter, usePathname } from 'next/navigation'; + +interface TokenLaunch { + id: number; + launch_time: string; + creator_wallet: string; + token_address: string; + token_metadata_url: string; + token_name?: string; + token_symbol?: string; + created_at: string; + creator_twitter?: string; + creator_github?: string; + is_creator_designated?: boolean; + verified?: boolean; +} + +interface VerifiedTokenLaunch extends TokenLaunch { + verified: boolean; + userBalance?: string; +} + +interface Presale { + id: number; + token_address: string; + creator_wallet: string; + token_name?: string; + token_symbol?: string; + token_metadata_url: string; + presale_tokens?: string[]; + creator_twitter?: string; + creator_github?: string; + status: string; + created_at: string; +} + + +export default function PortfolioPage() { + const { wallet, isPrivyAuthenticated, connecting, externalWallet, hasTwitter, hasGithub, twitterUsername, githubUsername } = useWallet(); + const { ready, login, authenticated, linkWallet } = usePrivy(); + const { addTab } = useTabContext(); + const router = useRouter(); + const pathname = usePathname(); + const [launches, setLaunches] = useState([]); + const [presales, setPresales] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [hasVerified, setHasVerified] = useState(false); + const [loadingBalances, setLoadingBalances] = useState>(new Set()); + const [copiedWallet, setCopiedWallet] = useState(false); + const [copiedTokens, setCopiedTokens] = useState>(new Set()); + const [retryCount, setRetryCount] = useState(0); + const [showVerificationModal, setShowVerificationModal] = useState(false); + const [needsVerification, setNeedsVerification] = useState(false); + const [viewMode, setViewMode] = useState<'verified' | 'all' | 'presale'>('verified'); + const [tokenMetadata, setTokenMetadata] = useState>({}); + + + // Check if user needs to verify designated claims + useEffect(() => { + const checkDesignatedClaims = async () => { + if (!isPrivyAuthenticated || hasVerified) return; + if (!hasTwitter && !hasGithub) return; + + try { + // Check if there are any designated claims for this user + const params = new URLSearchParams(); + if (twitterUsername) params.append('twitter', twitterUsername); + if (githubUsername) params.append('github', githubUsername); + + const response = await fetch(`/api/verify-designated`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + twitter: twitterUsername || undefined, + github: githubUsername || undefined + }) + }); + const data = await response.json(); + + if (data.claims && data.claims.length > 0) { + // User has designated tokens that need verification + const unverifiedClaims = data.claims.filter((c: { verified_wallet?: string; has_verified_wallet?: boolean }) => !c.verified_wallet); + if (unverifiedClaims.length > 0) { + setNeedsVerification(true); + } + } + } catch (error) { + console.error('Error checking designated claims:', error); + } + }; + + checkDesignatedClaims(); + }, [isPrivyAuthenticated, hasTwitter, hasGithub, twitterUsername, githubUsername, hasVerified]); + + useEffect(() => { + console.log('Portfolio Page State:', { + ready, + isPrivyAuthenticated, + wallet: wallet?.toString(), + connecting + }); + + const fetchLaunches = async () => { + if (!wallet) { + setLaunches([]); + setLoading(false); + setError(null); + return; + } + + setError(null); + setLoading(true); + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout + + // Build URL with social profiles if available + const params = new URLSearchParams(); + params.append('creator', wallet.toString()); + params.append('includeSocials', 'true'); + + // Add social profile URLs if connected + if (hasTwitter && twitterUsername) { + // Send both twitter.com and x.com URLs to match either format in database + params.append('twitterUrl', twitterUsername); + } + if (hasGithub && githubUsername) { + params.append('githubUrl', `https://github.com/${githubUsername}`); + } + + const response = await fetch(`/api/launches`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + creator: params.get('creator'), + includeSocials: params.get('includeSocials') === 'true', + twitterUrl: params.get('twitterUrl'), + githubUrl: params.get('githubUrl') + }), + signal: controller.signal + }); + clearTimeout(timeoutId); + const data = await response.json(); + + if (response.ok) { + const allLaunches: TokenLaunch[] = data.launches || []; + + // Batch verify tokens - process in chunks for better performance + const chunkSize = 5; + const chunks = []; + + for (let i = 0; i < allLaunches.length; i += chunkSize) { + chunks.push(allLaunches.slice(i, i + chunkSize)); + } + + const verifiedChunks = await Promise.all( + chunks.map(async (chunk) => { + return Promise.all( + chunk.map(async (launch) => { + try { + // Use the verified property from the database, not the exists check + const verified = launch.verified || false; + + let userBalance = '--'; + if (wallet) { + // Always fetch live balance from API + try { + const balanceResponse = await fetch(`/api/balance/${launch.token_address}/${wallet.toString()}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tokenAddress: launch.token_address, + walletAddress: wallet.toString() + }) + }); + if (balanceResponse.ok) { + const balanceData = await balanceResponse.json(); + userBalance = balanceData.balance || '--'; + } + } catch (error) { + console.error('Error fetching live balance for', launch.token_address, error); + // Keep balance as '--' if fetch fails + } + } + + return { + ...launch, + verified, + userBalance, + is_creator_designated: launch.is_creator_designated + }; + } catch (error) { + console.error(`Error fetching balance for ${launch.token_address}:`, error); + return { + ...launch, + verified: launch.verified || false, + userBalance: '--' + }; + } + }) + ); + }) + ); + + const verifiedLaunches = verifiedChunks.flat(); + + // Store all launches, filtering will be done in the UI based on viewMode + setLaunches(verifiedLaunches); + + // Fetch metadata for all launches + verifiedLaunches.forEach((launch) => { + if (launch.token_metadata_url) { + fetch(launch.token_metadata_url) + .then(res => res.json()) + .then(metadata => { + setTokenMetadata(prev => ({ + ...prev, + [launch.token_address]: { image: metadata.image } + })); + }) + .catch(err => console.error(`Error fetching metadata for ${launch.token_address}:`, err)); + } + }); + } else { + console.error('Failed to fetch launches:', data.error); + setError(data.error || 'Failed to fetch launches'); + setLaunches([]); + } + } catch (error: unknown) { + console.error('Error fetching launches:', error); + if (error instanceof Error && error.name === 'AbortError') { + setError('Request timeout. Please refresh the page.'); + } else { + setError('Failed to load your tokens. Please try again.'); + } + setLaunches([]); + } finally { + setLoading(false); + } + }; + + fetchLaunches(); + }, [wallet, retryCount, connecting, isPrivyAuthenticated, ready, hasTwitter, hasGithub, twitterUsername, githubUsername]); + + // Fetch presales + useEffect(() => { + const fetchPresales = async () => { + if (!wallet) { + setPresales([]); + return; + } + + try { + const response = await fetch(`/api/presale?creator=${wallet.toString()}`); + const data = await response.json(); + + if (response.ok) { + setPresales(data.presales || []); + + // Fetch metadata for all presales + (data.presales || []).forEach((presale: Presale) => { + if (presale.token_metadata_url) { + fetch(presale.token_metadata_url) + .then(res => res.json()) + .then(metadata => { + setTokenMetadata(prev => ({ + ...prev, + [presale.token_address]: { image: metadata.image } + })); + }) + .catch(err => console.error(`Error fetching metadata for ${presale.token_address}:`, err)); + } + }); + } else { + console.error('Failed to fetch presales:', data.error); + setPresales([]); + } + } catch (error) { + console.error('Error fetching presales:', error); + setPresales([]); + } + }; + + fetchPresales(); + }, [wallet, retryCount]); + + const formatDate = (dateString: string, includeTime: boolean = true) => { + const date = new Date(dateString); + if (includeTime) { + return date.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } else { + return date.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' + }); + } + }; + + const formatNumberShort = (value: number | string) => { + const num = typeof value === 'string' ? parseFloat(value) : value; + if (isNaN(num)) return '--'; + + if (num >= 1_000_000_000) { + return `${(num / 1_000_000_000).toFixed(2)}B`; + } else if (num >= 1_000_000) { + return `${(num / 1_000_000).toFixed(2)}M`; + } else if (num >= 1_000) { + return `${(num / 1_000).toFixed(2)}K`; + } + return num.toLocaleString(undefined, { maximumFractionDigits: 2 }); + }; + + const refreshTokenBalance = async (tokenAddress: string, delayMs: number = 0) => { + if (!wallet) return; + + // Set loading state + setLoadingBalances(prev => new Set(prev).add(tokenAddress)); + + try { + // Wait for the specified delay to allow blockchain to propagate + if (delayMs > 0) { + await new Promise(resolve => setTimeout(resolve, delayMs)); + } + + // Retry logic for balance fetch + let attempts = 0; + const maxAttempts = 3; + let balanceData = null; + + while (attempts < maxAttempts && !balanceData) { + try { + const balanceResponse = await fetch(`/api/balance/${tokenAddress}/${wallet.toString()}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tokenAddress, + walletAddress: wallet.toString() + }) + }); + if (balanceResponse.ok) { + balanceData = await balanceResponse.json(); + } + } catch { + attempts++; + if (attempts < maxAttempts) { + await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1 second between retries + } + } + attempts++; + } + + if (balanceData) { + const newBalance = balanceData.balance || '0'; + // Update the balance for this specific token + setLaunches(prevLaunches => + prevLaunches.map(launch => + launch.token_address === tokenAddress + ? { ...launch, userBalance: newBalance } + : launch + ) + ); + } + } catch (error) { + console.error('Error refreshing balance:', error); + } finally { + // Remove loading state + setLoadingBalances(prev => { + const newSet = new Set(prev); + newSet.delete(tokenAddress); + return newSet; + }); + } + }; + + // Force refresh - refetches data + const forceRefresh = () => { + setRetryCount(prev => prev + 1); + }; + + const copyWalletAddress = async () => { + if (!wallet) return; + + try { + await navigator.clipboard.writeText(wallet.toString()); + setCopiedWallet(true); + setTimeout(() => setCopiedWallet(false), 2000); + } catch (error) { + console.error('Failed to copy wallet address:', error); + } + }; + + const handleConnectWallet = () => { + try { + if (!authenticated) { + login(); + } else { + linkWallet(); + } + } catch (err) { + console.error('Failed to connect wallet:', err); + setError('Failed to connect wallet. Please try again.'); + } + }; + + const copyTokenAddress = async (tokenAddress: string) => { + try { + await navigator.clipboard.writeText(tokenAddress); + setCopiedTokens(prev => new Set(prev).add(tokenAddress)); + setTimeout(() => { + setCopiedTokens(prev => { + const newSet = new Set(prev); + newSet.delete(tokenAddress); + return newSet; + }); + }, 2000); + } catch (error) { + console.error('Failed to copy token address:', error); + } + }; + + return ( + <> +

Portfolio

+ + {needsVerification && !hasVerified && ( +
+

Verification Required

+

+ You have designated tokens waiting to be claimed. Please verify your wallet to access them. +

+ +
+ )} + +

{'//'}Your launched ZC tokens

+ + {wallet && ( +
+
+

+ Active Wallet {externalWallet ? '(Connected)' : '(Embedded)'}: +

+ +
+
+ )} + + {!ready || connecting ? ( +

Connecting to wallet...

+ ) : !isPrivyAuthenticated ? ( +

Please login to view your launches

+ ) : !wallet ? ( + + ) : loading ? ( +

Loading your tokens...

+ ) : error ? ( +
+

{error}

+ +
+ ) : ( + <> + {launches.length > 0 && (() => { + return ( +
+
+ + + + {wallet && ( + + )} +
+
+ ); + })()} + +
+ {(() => { + // Handle presale view + if (viewMode === 'presale') { + return presales.length === 0 ? ( +

No presales yet

+ ) : ( +
+ {presales.map((presale) => { + const getStatusColor = (status: string) => { + switch (status.toLowerCase()) { + case 'pending': + return 'bg-yellow-500/20 text-yellow-400'; + case 'launched': + return 'bg-green-500/20 text-green-400'; + case 'cancelled': + return 'bg-red-500/20 text-red-400'; + default: + return 'bg-gray-500/20 text-gray-400'; + } + }; + + return ( +
+
+ {/* Token Icon */} +
+ {tokenMetadata[presale.token_address]?.image ? ( + {presale.token_symbol + ) : ( +
+ )} +
+ +
+ {/* Top Row */} +
+
+

+ {presale.token_symbol || 'N/A'} +

+ + {presale.token_name || 'Unnamed Token'} + + + {presale.status.toUpperCase()} + +
+

+ Created: {formatDate(presale.created_at)} +

+
+ + {/* Bottom Row */} +
+ +
+
+
+
+ ); + })} +
+ ); + } + + // Handle token views (verified/all) + const filteredLaunches = viewMode === 'verified' + ? launches.filter(launch => launch.verified) + : launches; + + return filteredLaunches.length === 0 ? ( +

No tokens {viewMode === 'verified' ? 'verified' : 'launched'} yet

+ ) : ( +
+ {filteredLaunches.map((launch) => ( +
+
+ {/* Token Icon */} +
+ {tokenMetadata[launch.token_address]?.image ? ( + {launch.token_symbol + ) : ( +
+ )} +
+ +
+ {/* Desktop Layout */} +
+
+
+

+ {launch.token_symbol || 'N/A'} +

+
+ + {launch.token_name || 'Unnamed Token'} + + {launch.is_creator_designated && ( + + Designated + + )} + +
+
+ Balance: + + {launch.userBalance === '--' + ? '--' + : parseFloat(launch.userBalance || '0').toLocaleString(undefined, { maximumFractionDigits: 2 })} + + {loadingBalances.has(launch.token_address) && ( +
+
+
+ )} +
+
+

+ Launched: {formatDate(launch.launch_time)} +

+
+
+ + {/* Mobile Layout */} +
+ {/* First Row: Symbol, Name, Copy icon, Designated badge */} +
+

+ {launch.token_symbol || 'N/A'} +

+ + {launch.token_name || 'Unnamed Token'} + + + {launch.is_creator_designated && ( + + Designated + + )} +
+ + {/* Second Row: Balance, Claim button */} +
+
+ Bal: + + {launch.userBalance === '--' + ? '--' + : formatNumberShort(launch.userBalance || '0')} + + {loadingBalances.has(launch.token_address) && ( +
+
+
+ )} +
+ refreshTokenBalance(launch.token_address, 5000)} + disabled={!launch.is_creator_designated && (launch.creator_twitter || launch.creator_github) ? true : false} + disabledReason="Rewards designated" + isMobile={true} + /> +
+
+ + {/* Bottom Row - Desktop */} +
+
+ + +
+
+ + + +
+
+ refreshTokenBalance(launch.token_address, 5000)} + disabled={!launch.is_creator_designated && (launch.creator_twitter || launch.creator_github) ? true : false} + disabledReason="Rewards designated" + /> +
+
+ + {/* Bottom Rows - Mobile */} +
+ {/* First Row: Holders, History */} +
+ + +
+ + {/* Second Row: Transfer, Sell, Burn */} +
+ + + +
+
+
+
+
+ ))} +
+ ); + })()} +
+ + )} + + setShowVerificationModal(false)} + onSuccess={() => { + setHasVerified(true); + setNeedsVerification(false); + // Reload launches to show newly accessible tokens + window.location.reload(); + }} + /> + + ); +} \ No newline at end of file diff --git a/ui/app/(vscode)/presale/[tokenAddress]/page.tsx b/ui/app/(vscode)/presale/[tokenAddress]/page.tsx new file mode 100644 index 0000000..7d97804 --- /dev/null +++ b/ui/app/(vscode)/presale/[tokenAddress]/page.tsx @@ -0,0 +1,5 @@ +import { PresaleContent } from '@/components/PresaleContent'; + +export default function PresalePage() { + return ; +} \ No newline at end of file diff --git a/ui/app/(vscode)/projects/page.tsx b/ui/app/(vscode)/projects/page.tsx new file mode 100644 index 0000000..b83465f --- /dev/null +++ b/ui/app/(vscode)/projects/page.tsx @@ -0,0 +1,318 @@ +'use client'; + +import { TokenCardVSCode } from '@/components/TokenCardVSCode'; +import { useEffect, useState, useMemo } from 'react'; +import { useWallet } from '@/components/WalletProvider'; +import { useTabContext } from '@/contexts/TabContext'; +import { useRouter, usePathname } from 'next/navigation'; + +interface TokenLaunch { + id: number; + launch_time: string; + creator_wallet: string; + token_address: string; + token_metadata_url: string; + token_name: string | null; + token_symbol: string | null; + creator_twitter: string | null; + creator_github: string | null; + created_at: string; + totalClaimed?: string; + availableToClaim?: string; + verified?: boolean; +} + +interface TokenMetadata { + name: string; + symbol: string; + image: string; + website?: string; + twitter?: string; + caEnding?: string; + description?: string; +} + +interface MarketData { + price: number; + liquidity: number; + total_supply: number; + circulating_supply: number; + fdv: number; + market_cap: number; +} + +export default function ProjectsPage() { + const { wallet, externalWallet } = useWallet(); + const { addTab } = useTabContext(); + const router = useRouter(); + const pathname = usePathname(); + const [tokens, setTokens] = useState([]); + const [loading, setLoading] = useState(true); + const [viewMode, setViewMode] = useState<'verified' | 'all'>('verified'); + const [verifiedPage, setVerifiedPage] = useState(1); + const [allPage, setAllPage] = useState(1); + const [tokenMetadata, setTokenMetadata] = useState>({}); + const [marketData, setMarketData] = useState>({}); + + const ITEMS_PER_PAGE = 10; + + useEffect(() => { + fetchTokens(); + }, []); + + // Fetch market data when switching to 'all' view + useEffect(() => { + if (viewMode === 'all' && tokens.length > 0) { + tokens.forEach((token) => { + // Only fetch if we don't already have the data + if (!marketData[token.token_address]) { + fetchMarketData(token.token_address); + } + }); + } + }, [viewMode, tokens]); + + const fetchTokens = async (forceRefresh = false) => { + try { + const response = await fetch('/api/tokens', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh: forceRefresh }) + }); + if (response.ok) { + const data = await response.json(); + setTokens(data.tokens); + + // Fetch metadata for all tokens + data.tokens.forEach((token: TokenLaunch) => { + fetchTokenMetadata(token.token_address, token.token_metadata_url); + }); + + // Only fetch market data for verified tokens on initial load + const verifiedTokens = data.tokens.filter((token: TokenLaunch) => + token.verified + ); + verifiedTokens.forEach((token: TokenLaunch) => { + fetchMarketData(token.token_address); + }); + + // If we got cached data and it's been more than 30 seconds since page load, + // silently fetch fresh data in background + if (data.cached && !forceRefresh) { + setTimeout(() => { + fetch('/api/tokens', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh: true }) + }) + .then(res => res.json()) + .then(freshData => { + if (freshData.tokens) { + setTokens(freshData.tokens); + freshData.tokens.forEach((token: TokenLaunch) => { + fetchTokenMetadata(token.token_address, token.token_metadata_url); + }); + + // Only fetch market data for verified tokens or if in 'all' view + const tokensToFetchMarketData = viewMode === 'all' + ? freshData.tokens + : freshData.tokens.filter((token: TokenLaunch) => + token.verified + ); + tokensToFetchMarketData.forEach((token: TokenLaunch) => { + fetchMarketData(token.token_address); + }); + } + }) + .catch(console.error); + }, 1000); // Fetch fresh data after 1 second + } + } + } catch (error) { + console.error('Error fetching tokens:', error); + } finally { + setLoading(false); + } + }; + + const fetchTokenMetadata = async (tokenAddress: string, metadataUrl: string) => { + try { + const response = await fetch(metadataUrl); + if (response.ok) { + const metadata: TokenMetadata = await response.json(); + setTokenMetadata(prev => ({ + ...prev, + [tokenAddress]: metadata + })); + } + } catch (error) { + console.error(`Error fetching metadata for ${tokenAddress}:`, error); + } + }; + + const fetchMarketData = async (tokenAddress: string) => { + try { + const response = await fetch(`/api/market-data/${tokenAddress}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tokenAddress }) + }); + if (response.ok) { + const result = await response.json(); + if (result.success && result.data) { + setMarketData(prev => ({ + ...prev, + [tokenAddress]: result.data + })); + } + } + } catch (error) { + console.error(`Error fetching market data for ${tokenAddress}:`, error); + } + }; + + const handleRowClick = (token: TokenLaunch) => { + addTab('history', token.token_address, token.token_symbol || 'Unknown', pathname); + router.push(`/history/${token.token_address}`); + }; + + // Memoize filtered tokens to avoid recalculating on every render + const filteredTokens = useMemo(() => { + // Apply verified filter if in verified mode + if (viewMode === 'verified') { + return tokens.filter(token => + token.verified + ); + } + + return tokens; + }, [tokens, viewMode]); + + // Calculate pagination + const currentPage = viewMode === 'verified' ? verifiedPage : allPage; + const setCurrentPage = viewMode === 'verified' ? setVerifiedPage : setAllPage; + + const totalPages = Math.ceil(filteredTokens.length / ITEMS_PER_PAGE); + const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; + const endIndex = startIndex + ITEMS_PER_PAGE; + const paginatedTokens = filteredTokens.slice(startIndex, endIndex); + + // Calculate cumulative market cap + const cumulativeMarketCap = useMemo(() => { + return filteredTokens.reduce((total, token) => { + const market = marketData[token.token_address]; + return total + (market?.market_cap || 0); + }, 0); + }, [filteredTokens, marketData]); + + const formatMarketCap = (marketCap: number) => { + if (!marketCap || marketCap === 0) return '-'; + if (marketCap >= 1_000_000) { + return `$${(marketCap / 1_000_000).toFixed(2)}M`; + } else if (marketCap >= 1_000) { + return `$${(marketCap / 1_000).toFixed(2)}K`; + } + return `$${marketCap.toFixed(2)}`; + }; + + return ( + <> +

Projects

+

{'//'}See ZC launched projects here

+ +
+ + +
+ + {'//'}Total MCap: + {'//'}Cumulative Market Cap: + + + {formatMarketCap(cumulativeMarketCap)} + +
+
+ +
+ {loading ? ( +

+ Loading tokens... +

+ ) : filteredTokens.length === 0 ? ( +

+ No tokens launched yet +

+ ) : ( +
+ {paginatedTokens.map((token) => { + const metadata = tokenMetadata[token.token_address]; + const market = marketData[token.token_address]; + return ( + handleRowClick(token)} + isCreator={!!(externalWallet && wallet && token.creator_wallet === wallet.toBase58())} + /> + ); + })} +
+ )} + + {/* Pagination Controls */} + {totalPages > 1 && ( +
+ + + Page {currentPage} of {totalPages} + + +
+ )} +
+ + ); +} \ No newline at end of file diff --git a/ui/app/(vscode)/stake/page.tsx b/ui/app/(vscode)/stake/page.tsx new file mode 100644 index 0000000..a843527 --- /dev/null +++ b/ui/app/(vscode)/stake/page.tsx @@ -0,0 +1,5 @@ +import { StakeContent } from '@/components/StakeContent'; + +export default function StakePage() { + return ; +} \ No newline at end of file diff --git a/ui/app/swap/constants.ts b/ui/app/(vscode)/swap/constants.ts similarity index 100% rename from ui/app/swap/constants.ts rename to ui/app/(vscode)/swap/constants.ts diff --git a/ui/app/(vscode)/swap/page.tsx b/ui/app/(vscode)/swap/page.tsx new file mode 100644 index 0000000..991d1b4 --- /dev/null +++ b/ui/app/(vscode)/swap/page.tsx @@ -0,0 +1,5 @@ +import { SwapContent } from '@/components/SwapContent'; + +export default function SwapPage() { + return ; +} diff --git a/ui/app/swap/services/balanceService.ts b/ui/app/(vscode)/swap/services/balanceService.ts similarity index 100% rename from ui/app/swap/services/balanceService.ts rename to ui/app/(vscode)/swap/services/balanceService.ts diff --git a/ui/app/swap/services/jupiterSwapService.ts b/ui/app/(vscode)/swap/services/jupiterSwapService.ts similarity index 100% rename from ui/app/swap/services/jupiterSwapService.ts rename to ui/app/(vscode)/swap/services/jupiterSwapService.ts diff --git a/ui/app/swap/services/quoteService.ts b/ui/app/(vscode)/swap/services/quoteService.ts similarity index 100% rename from ui/app/swap/services/quoteService.ts rename to ui/app/(vscode)/swap/services/quoteService.ts diff --git a/ui/app/swap/services/swapService.ts b/ui/app/(vscode)/swap/services/swapService.ts similarity index 100% rename from ui/app/swap/services/swapService.ts rename to ui/app/(vscode)/swap/services/swapService.ts diff --git a/ui/app/swap/types.ts b/ui/app/(vscode)/swap/types.ts similarity index 100% rename from ui/app/swap/types.ts rename to ui/app/(vscode)/swap/types.ts diff --git a/ui/app/swap/utils/formatUtils.ts b/ui/app/(vscode)/swap/utils/formatUtils.ts similarity index 100% rename from ui/app/swap/utils/formatUtils.ts rename to ui/app/(vscode)/swap/utils/formatUtils.ts diff --git a/ui/app/swap/utils/poolUtils.ts b/ui/app/(vscode)/swap/utils/poolUtils.ts similarity index 100% rename from ui/app/swap/utils/poolUtils.ts rename to ui/app/(vscode)/swap/utils/poolUtils.ts diff --git a/ui/app/swap/utils/routingUtils.ts b/ui/app/(vscode)/swap/utils/routingUtils.ts similarity index 100% rename from ui/app/swap/utils/routingUtils.ts rename to ui/app/(vscode)/swap/utils/routingUtils.ts diff --git a/ui/app/swap/utils/tokenUtils.ts b/ui/app/(vscode)/swap/utils/tokenUtils.ts similarity index 100% rename from ui/app/swap/utils/tokenUtils.ts rename to ui/app/(vscode)/swap/utils/tokenUtils.ts diff --git a/ui/app/(vscode)/transfer/[tokenAddress]/page.tsx b/ui/app/(vscode)/transfer/[tokenAddress]/page.tsx new file mode 100644 index 0000000..c8a8365 --- /dev/null +++ b/ui/app/(vscode)/transfer/[tokenAddress]/page.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { TransferContent } from '@/components/TransferContent'; +import { useParams } from 'next/navigation'; + +export default function TransferPage() { + const params = useParams(); + const tokenAddress = params.tokenAddress as string; + + // Token symbol and userBalance will be fetched in TransferContent component + // For now, pass empty strings - TransferContent should handle fetching this data + + return ; +} \ No newline at end of file diff --git a/ui/app/claim/page.tsx b/ui/app/claim/page.tsx deleted file mode 100644 index e09bc86..0000000 --- a/ui/app/claim/page.tsx +++ /dev/null @@ -1,86 +0,0 @@ -'use client'; - -import { Navigation } from '@/components/Navigation'; -import { usePrivy } from '@privy-io/react-auth'; -import { useWallet } from '@/components/WalletProvider'; -import { useEffect } from 'react'; -import { useRouter } from 'next/navigation'; - -export default function ClaimPage() { - const { login, ready, authenticated } = usePrivy(); - const { isPrivyAuthenticated } = useWallet(); - const router = useRouter(); - - // Redirect if already authenticated - useEffect(() => { - if (isPrivyAuthenticated && authenticated) { - router.push('/manage'); - } - }, [isPrivyAuthenticated, authenticated, router]); - - const handleSocialLogin = (provider: 'twitter' | 'github') => { - login({ - loginMethods: [provider] - // Embedded Solana wallet is created automatically for social logins - }); - }; - - if (!ready) { - return ( -
-
-
-
-

𝓩 Claim

-

Loading...

-
-
-
-
- ); - } - - return ( -
-
-
-
-

𝓩 Claim

- -
-

- Claim your Z Combinator rewards by connecting your X or GitHub account. - This will create an embedded wallet for you to receive tokens. NOTE: If you launched a token, manage your token on the Portfolio page. Only use this page if you were designated a token from someone else. -

- -
- - - -
- -
-

- By connecting, you'll create an embedded wallet that you can export later. - No external wallet connection is required. -

-
- - -
-
-
-
-
- ); -} \ No newline at end of file diff --git a/ui/app/dev-faq/page.tsx b/ui/app/dev-faq/page.tsx deleted file mode 100644 index a513820..0000000 --- a/ui/app/dev-faq/page.tsx +++ /dev/null @@ -1,29 +0,0 @@ -'use client'; - -import { Navigation } from '@/components/Navigation'; - -export default function DeveloperFAQ() { - - return ( -
-
-
-
-

𝓩 Developer FAQ

- -
-
-

Isn't it weird that I have to dump my tokens to actually bootstrap?

-

Any project that relies on trading fees for funding is unsustainable and doomed for failure. With a constant but controlled stream of tokens, founders can fuel growth via incentives for product and attention bootstrapping, leading to much larger gains in the long term.

-

Once real growth occurs, both users and founders are made rich by contributing to and sharing ownership of a valuable project and its token.

-

Short term projects thrive on trading fee models. Long term projects thrive using token emissions to fund operations and incentivize growth, creating true value.

-
-
- - -
-
-
-
- ); -} diff --git a/ui/app/faq/page.tsx b/ui/app/faq/page.tsx deleted file mode 100644 index ebaff8d..0000000 --- a/ui/app/faq/page.tsx +++ /dev/null @@ -1,42 +0,0 @@ -'use client'; - -import { Navigation } from '@/components/Navigation'; - -export default function FAQ() { - - return ( -
-
-
-
-

𝓩 FAQ

- -
-
-

Why are tokens mintable?

-

Developers DO NOT have the ability to mint tokens. The protocol programmatically mints 1M tokens every 24 hours, only claimable by the developer, while the token has no trading fees. Developers cannot mint any more tokens. This ensures the developer only gets paid when market cap goes up, and they have incentive to keep building for the long term.

-
- -
-

How does staking work? What are the rewards for staking?

-

All tokens will have native staking built into their launches. The protocol automatically mints tokens to reward the staking vault. Users in the staking vault lock their tokens to earn a part of these rewards. Currently, ZC and oogway are the only tokens with staking.

-
- -
-

What are fees? What is the utility of the token?

-

For the Z Combinator token and protocol, a small portion of all token mints for all launches on the platform are sent to the Z Combinator treasury. The Z Combinator token represents a stake of this treasury. For other launchpad tokens, fees are based on the individual products themselves and each has their own utility.

-
- -
-

How can devs get involved?

-

Launch ideas, projects, or anything in between. Collect market feedback quickly to iterate into building something people actually want to use!

-
-
- - -
-
-
-
- ); -} \ No newline at end of file diff --git a/ui/app/history/[tokenAddress]/page.tsx b/ui/app/history/[tokenAddress]/page.tsx deleted file mode 100644 index b0a5ef2..0000000 --- a/ui/app/history/[tokenAddress]/page.tsx +++ /dev/null @@ -1,644 +0,0 @@ -'use client'; - -import { useParams, useSearchParams } from 'next/navigation'; -import Link from 'next/link'; -import { useState, useEffect, useMemo, useCallback } from 'react'; -import { useWallet } from '@/components/WalletProvider'; - -interface Transaction { - signature: string; - timestamp: number; - type: 'transfer' | 'buy' | 'sell' | 'burn' | 'mint' | 'unknown'; - amount: string; - solAmount?: string; - fromWallet: string; - toWallet: string; - fromLabel: string; - toLabel: string; - memo?: string | null; - rawTransaction?: unknown; -} - -interface TokenInfo { - address: string; - symbol: string; - name: string; - totalSupply: string; - imageUri?: string; -} - -interface LaunchInfo { - creatorWallet: string; - creatorTwitter?: string; - creatorGithub?: string; - isCreatorDesignated: boolean; - verifiedWallet?: string; - verifiedEmbeddedWallet?: string; -} - -// Removed dummy data - now using real transaction API - -export default function TransactionHistoryPage() { - const params = useParams(); - const searchParams = useSearchParams(); - const { wallet } = useWallet(); - const tokenAddress = params.tokenAddress as string; - - const fromPage = searchParams.get('from'); - - const [tokenInfo, setTokenInfo] = useState({ - address: tokenAddress, - symbol: 'Loading...', - name: 'Loading...', - totalSupply: '0', - imageUri: undefined - }); - - const [launchInfo, setLaunchInfo] = useState(null); - - const [transactionPages, setTransactionPages] = useState([]); - const [currentTransactions, setCurrentTransactions] = useState([]); - const [loading, setLoading] = useState(true); - const [loadingPage, setLoadingPage] = useState(false); - const [currentPage, setCurrentPage] = useState(0); - const [hasMorePages, setHasMorePages] = useState(true); - const [lastSignature, setLastSignature] = useState(null); - const [expandedTransactions, setExpandedTransactions] = useState>(new Set()); - const TRANSACTIONS_PER_PAGE = 10; - - - // Helper function to truncate addresses - memoized - const truncateAddress = useCallback((address: string) => { - if (!address || address.length < 12) return address; - return `${address.slice(0, 6)}...${address.slice(-6)}`; - }, []); - - // Helper function to replace user's wallet with "You" - memoized - const processLabel = useCallback((label: string, walletAddress: string) => { - // Check if the wallet address matches the user's wallet - if (wallet && walletAddress === wallet.toBase58()) { - return 'You'; - } - // If label is an address, truncate it - if (label === walletAddress || label.match(/^[A-Za-z0-9]{40,}$/)) { - return truncateAddress(label); - } - // Otherwise return the label as-is - return label; - }, [wallet, truncateAddress]); - - // Helper function to calculate percentage of supply - const calculateSupplyPercentage = (amount: string) => { - const amountNum = parseFloat(amount.replace(/,/g, '')); - const totalSupplyNum = parseFloat(tokenInfo.totalSupply.replace(/,/g, '')); - if (totalSupplyNum === 0) return '0.00'; - return ((amountNum / totalSupplyNum) * 100).toFixed(4); - }; - - // Helper function to format token amounts with K/M/B - const formatTokenAmount = (amount: string | undefined) => { - if (!amount) return '0'; - // Remove commas before parsing (amounts come formatted as "1,000,000") - const num = parseFloat(amount.replace(/,/g, '')); - if (num >= 1_000_000_000) { - const billions = num / 1_000_000_000; - return billions >= 10 ? `${Math.floor(billions)}B` : `${billions.toFixed(1)}B`; - } else if (num >= 1_000_000) { - const millions = num / 1_000_000; - return millions >= 10 ? `${Math.floor(millions)}M` : `${millions.toFixed(1)}M`; - } else if (num >= 1_000) { - const thousands = num / 1_000; - return thousands >= 10 ? `${Math.floor(thousands)}K` : `${thousands.toFixed(1)}K`; - } - return Math.floor(num).toString(); - }; - - - // Check if current user is the dev - const isUserDev = wallet && launchInfo?.creatorWallet && wallet.toBase58() === launchInfo.creatorWallet; - - // Load initial data - useEffect(() => { - const loadInitialData = async () => { - setLoading(true); - - // Fetch real token info from multiple sources - let creatorWallet = ''; - try { - const [launchesResponse, supplyResponse, designatedResponse] = await Promise.all([ - fetch(`/api/launches`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token: tokenAddress }) - }), - fetch(`/api/token-info/${tokenAddress}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tokenAddress }) - }), - fetch(`/api/designated-claims/${tokenAddress}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tokenAddress }) - }) - ]); - - let tokenSymbol = 'Unknown'; - let tokenName = 'Unknown Token'; - let tokenImageUri: string | undefined; - let totalSupply = '1000000000'; - let creatorTwitter: string | undefined; - let creatorGithub: string | undefined; - let isCreatorDesignated = false; - - // Get token metadata from launches API - if (launchesResponse.ok) { - const launchesData = await launchesResponse.json(); - const launch = launchesData.launches?.[0]; - if (launch) { - tokenSymbol = launch.token_symbol || 'Unknown'; - tokenName = launch.token_name || 'Unknown Token'; - tokenImageUri = launch.image_uri; - - // If no image_uri in DB, try to fetch from metadata URL - if (!tokenImageUri && launch.token_metadata_url) { - try { - const metadataResponse = await fetch(launch.token_metadata_url); - if (metadataResponse.ok) { - const metadata = await metadataResponse.json(); - tokenImageUri = metadata.image; - } - } catch (error) { - // Failed to fetch metadata, continue without image - } - } - - creatorWallet = launch.creator_wallet || ''; - // Use creator_twitter and creator_github from token_launches table - creatorTwitter = launch.creator_twitter; - creatorGithub = launch.creator_github; - isCreatorDesignated = launch.is_creator_designated || false; - } - } - - // Get designated claim data if available for wallet addresses - let verifiedWallet: string | undefined; - let verifiedEmbeddedWallet: string | undefined; - - if (designatedResponse.ok) { - const designatedData = await designatedResponse.json(); - if (designatedData.claim) { - // Use designated_claims for the original launcher if different - if (designatedData.claim.original_launcher) { - creatorWallet = designatedData.claim.original_launcher; - } - // Get verified wallet addresses from designated_claims - verifiedWallet = designatedData.claim.verified_wallet; - verifiedEmbeddedWallet = designatedData.claim.verified_embedded_wallet; - // If there's a verified_at date, the claim has been verified - if (designatedData.claim.verified_at) { - isCreatorDesignated = true; - } - } - } - - // Get real token supply from blockchain - if (supplyResponse.ok) { - const supplyData = await supplyResponse.json(); - totalSupply = supplyData.supply || '1000000000'; - } - - setTokenInfo({ - address: tokenAddress, - symbol: tokenSymbol, - name: tokenName, - totalSupply, - imageUri: tokenImageUri - }); - - if (creatorWallet) { - setLaunchInfo({ - creatorWallet, - creatorTwitter, - creatorGithub, - isCreatorDesignated, - verifiedWallet, - verifiedEmbeddedWallet - }); - } - } catch (error) { - setTokenInfo({ - address: tokenAddress, - symbol: 'Unknown', - name: 'Unknown Token', - totalSupply: '1000000000', - imageUri: undefined - }); - } - - // Fetch first page of transactions if we have a creator wallet - if (creatorWallet) { - try { - const response = await fetch(`/api/transactions/${tokenAddress}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - tokenAddress, - walletAddress: creatorWallet, - limit: TRANSACTIONS_PER_PAGE, - fetchLabels: wallet && creatorWallet === wallet.toBase58() - }) - }); - - if (!response.ok) { - throw new Error('Failed to fetch transactions'); - } - - const data = await response.json(); - const transactions = data.transactions || []; - const hasMore = data.hasMore || false; - const newLastSignature = data.lastSignature || null; - - if (transactions.length > 0) { - // Process transactions to apply "You" label and truncate addresses - const processedTransactions = transactions.map((tx: Transaction) => ({ - ...tx, - fromLabel: processLabel(tx.fromLabel, tx.fromWallet), - toLabel: processLabel(tx.toLabel, tx.toWallet) - })); - - setTransactionPages([processedTransactions]); - setCurrentTransactions(processedTransactions); - setHasMorePages(hasMore); - setLastSignature(newLastSignature); - } - } catch (error) { - // Error fetching transactions - } - } - - setLoading(false); - }; - - loadInitialData(); - }, [tokenAddress]); // eslint-disable-line react-hooks/exhaustive-deps - - // Handle page navigation - memoized - const navigateToPage = useCallback(async (newPage: number) => { - if (newPage < 0) return; - - if (!launchInfo?.creatorWallet) { - return; - } - - setCurrentPage(newPage); - - // Check if we already have this page cached - if (transactionPages[newPage]) { - // Apply "You" label and address truncation to cached page as well - const cachedTransactions = transactionPages[newPage]; - const processedCachedTransactions = cachedTransactions.map((tx: Transaction) => ({ - ...tx, - fromLabel: processLabel(tx.fromLabel, tx.fromWallet), - toLabel: processLabel(tx.toLabel, tx.toWallet) - })); - setCurrentTransactions(processedCachedTransactions); - return; - } - - // Need to fetch this page - setLoadingPage(true); - - try { - const response = await fetch(`/api/transactions/${tokenAddress}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - tokenAddress, - walletAddress: launchInfo.creatorWallet, - limit: TRANSACTIONS_PER_PAGE, - fetchLabels: isUserDev, - ...(lastSignature && { before: lastSignature }) - }) - }); - - if (!response.ok) { - throw new Error('Failed to fetch transactions'); - } - - const data = await response.json(); - const transactions = data.transactions || []; - const hasMore = data.hasMore || false; - const newLastSignature = data.lastSignature || null; - - if (transactions.length > 0) { - // Process transactions to apply "You" label and truncate addresses - const processedTransactions = transactions.map((tx: Transaction) => ({ - ...tx, - fromLabel: processLabel(tx.fromLabel, tx.fromWallet), - toLabel: processLabel(tx.toLabel, tx.toWallet) - })); - - // Update cached pages - const newPages = [...transactionPages]; - newPages[newPage] = processedTransactions; - setTransactionPages(newPages); - - setCurrentTransactions(processedTransactions); - setHasMorePages(hasMore); - setLastSignature(newLastSignature); - } - } catch (error) { - // Error fetching page - } finally { - setLoadingPage(false); - } - }, [tokenAddress, lastSignature, TRANSACTIONS_PER_PAGE, processLabel, transactionPages, launchInfo]); - - const formatDate = (timestamp: number) => { - // Helius timestamp is in seconds, but JavaScript Date expects milliseconds - const date = new Date(timestamp * 1000); - return date.toLocaleString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: '2-digit', - minute: '2-digit' - }); - }; - - - // Helper to check if label is a social label (not a wallet address) - const isSocialLabel = (label: string, wallet: string) => { - return label !== wallet && !label.match(/^[A-Za-z0-9]{6}\.\.\.[A-Za-z0-9]{6}$/); - }; - - const getTransactionDescription = (tx: Transaction) => { - switch (tx.type) { - case 'transfer': - return { - action: 'Reward', - description: `${formatTokenAmount(tx.amount)} to `, - toUser: tx.toLabel, - toUserIsSocial: isSocialLabel(tx.toLabel, tx.toWallet) - }; - case 'mint': - return { - action: 'Claim', - description: formatTokenAmount(tx.amount), - toUser: '', - toUserIsSocial: false - }; - case 'sell': - return { - action: 'Sell', - description: tx.solAmount ? `${tx.solAmount} SOL` : `${formatTokenAmount(tx.amount)} ${tokenInfo.symbol}`, - toUser: '', - toUserIsSocial: false - }; - case 'buy': - return { - action: 'Buy', - description: formatTokenAmount(tx.amount), - toUser: '', - toUserIsSocial: false - }; - case 'burn': - return { - action: 'Burn', - description: formatTokenAmount(tx.amount), - toUser: '', - toUserIsSocial: false - }; - default: - return { - action: 'Unknown', - description: 'transaction', - toUser: '', - toUserIsSocial: false - }; - } - }; - - const getTypeColor = (type: string) => { - switch (type) { - case 'transfer': return 'text-blue-400'; - case 'buy': return 'text-green-400'; - case 'sell': return 'text-orange-400'; - case 'burn': return 'text-red-400'; - case 'mint': return 'text-purple-400'; - default: return 'text-gray-300'; - } - }; - - const getTypeIcon = (type: string) => { - switch (type) { - case 'transfer': - return ( - - - - ); - case 'mint': - return ( - - - - ); - case 'sell': - return ( - - - - ); - case 'buy': - return ( - - - - ); - case 'burn': - return ( - - - - ); - default: - return ( - - - - ); - } - }; - - return ( -
-
- {/* Header */} -
- - - - - -

Dev Transaction History

-
- -
-
- {tokenInfo.imageUri && ( - {tokenInfo.symbol} { - e.currentTarget.src = 'data:image/svg+xml,'; - }} - /> - )} - {tokenInfo.symbol} - {tokenInfo.name} - { - navigator.clipboard.writeText(tokenInfo.address); - }} - className="text-gray-300 font-mono cursor-pointer hover:text-white transition-colors" - title="Click to copy full address" - > - {tokenInfo.address.slice(0, 6)}...{tokenInfo.address.slice(-6)} - -
-
- - - {/* Transactions */} - {loading ? ( -
-
-
- ) : ( -
- {currentTransactions.length === 0 ? ( -

- No transactions found -

- ) : ( - currentTransactions.map((tx) => { - const isExpanded = expandedTransactions.has(tx.signature); - const hasMemo = tx.memo && tx.memo.trim().length > 0; - - return ( -
- {/* Transaction Row */} -
-
- -
- - {(() => { - const desc = getTransactionDescription(tx); - return ( - <> - {desc.action} - : {desc.description} - {desc.toUser && ( - - {desc.toUser} - - )} - - ); - })()} - - - ({calculateSupplyPercentage(tx.amount)}%) - -
-
-
- - {formatDate(tx.timestamp)} - - - - - - -
-
- {/* Memo Expansion */} - {hasMemo && isExpanded && ( -
-

- {tx.memo} -

-
- )} -
- ); - }) - )} -
- )} - - {/* Pagination */} - {!loading && (currentPage > 0 || hasMorePages) && ( -
- - - Page {currentPage + 1} - - -
- )} -
-
- ); -} \ No newline at end of file diff --git a/ui/app/holders/[tokenAddress]/page.tsx b/ui/app/holders/[tokenAddress]/page.tsx deleted file mode 100644 index f286703..0000000 --- a/ui/app/holders/[tokenAddress]/page.tsx +++ /dev/null @@ -1,538 +0,0 @@ -'use client'; - -import { useParams } from 'next/navigation'; -import Link from 'next/link'; -import { useState, useEffect, useCallback } from 'react'; -import { useWallet } from '@/components/WalletProvider'; -import { PublicKey } from '@solana/web3.js'; - -interface Holder { - id?: number; - wallet_address: string; - token_balance: string; - staked_balance: string; - telegram_username?: string | null; - x_username?: string | null; - discord_username?: string | null; - custom_label?: string | null; - created_at?: string; - updated_at?: string; - last_sync_at?: string; - percentage?: number; -} - -interface HolderStats { - totalHolders: number; - totalBalance: string; - lastSyncTime: string | null; -} - -interface TokenInfo { - address: string; - symbol: string; - name: string; -} - -// Helper function to check if a wallet address is on curve (not a PDA) -function isOnCurve(address: string): boolean { - try { - const pubkey = new PublicKey(address); - return PublicKey.isOnCurve(pubkey.toBuffer()); - } catch { - return false; // Invalid address format - } -} - -export default function HoldersPage() { - const params = useParams(); - const tokenAddress = params.tokenAddress as string; - const { wallet } = useWallet(); - - const [tokenInfo, setTokenInfo] = useState({ - address: tokenAddress, - symbol: '', - name: '' - }); - - const [loading, setLoading] = useState(true); - const [accessDenied, setAccessDenied] = useState(false); - const [editingHolder, setEditingHolder] = useState(null); - const [editForm, setEditForm] = useState({ - telegram_username: '', - discord_username: '', - x_username: '', - custom_label: '' - }); - const [syncing, setSyncing] = useState(false); - const [allHolders, setAllHolders] = useState([]); - const [holderStats, setHolderStats] = useState({ - totalHolders: 0, - totalBalance: '0', - lastSyncTime: null - }); - const [searchQuery, setSearchQuery] = useState(''); - - // Load holders from database - const fetchHolders = useCallback(async () => { - try { - const response = await fetch(`/api/holders/${tokenAddress}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tokenAddress }) - }); - if (response.ok) { - const data = await response.json(); - // Filter out off-curve wallets (PDAs) and 0-balance holders before displaying - const onCurveHolders = data.holders.filter((holder: Holder) => - isOnCurve(holder.wallet_address) && parseFloat(holder.token_balance) > 0 - ); - const holdersWithPercentage = calculatePercentages(onCurveHolders, data.stats.totalBalance); - setAllHolders(holdersWithPercentage); - // Update stats to reflect only on-curve holders - setHolderStats({ - ...data.stats, - totalHolders: onCurveHolders.length - }); - } else { - console.error('Failed to fetch holders:', response.status); - setAllHolders([]); - } - } catch (error) { - console.error('Error fetching holders:', error); - setAllHolders([]); - } - }, [tokenAddress]); - - const calculatePercentages = (holders: Holder[], totalBalance: string): Holder[] => { - const total = parseFloat(totalBalance); - if (total === 0) return holders; - - return holders.map(holder => ({ - ...holder, - percentage: (parseFloat(holder.token_balance) / total) * 100 - })); - }; - - const triggerSync = useCallback(async () => { - setSyncing(true); - try { - const response = await fetch(`/api/holders/${tokenAddress}/sync`, { - method: 'POST' - }); - - if (response.ok) { - // Refresh holders after sync - await fetchHolders(); - } else { - console.error('Failed to sync holders:', response.status); - } - } catch (error) { - console.error('Error syncing holders:', error); - } finally { - setSyncing(false); - } - }, [tokenAddress, fetchHolders]); - - // Filter holders based on search query - const filteredHolders = allHolders.filter(holder => { - if (!searchQuery) return true; - - const query = searchQuery.toLowerCase(); - const walletMatch = holder.wallet_address.toLowerCase().includes(query); - const telegramMatch = holder.telegram_username?.toLowerCase().includes(query); - const discordMatch = holder.discord_username?.toLowerCase().includes(query); - const xMatch = holder.x_username?.toLowerCase().includes(query); - const customLabelMatch = holder.custom_label?.toLowerCase().includes(query); - - return walletMatch || telegramMatch || discordMatch || xMatch || customLabelMatch; - }); - - const [currentPage, setCurrentPage] = useState(0); - const holdersPerPage = 10; - const totalPages = Math.ceil(filteredHolders.length / holdersPerPage); - const holders = filteredHolders.slice( - currentPage * holdersPerPage, - (currentPage + 1) * holdersPerPage - ); - - // Reset to first page when search query changes - useEffect(() => { - setCurrentPage(0); - }, [searchQuery]); - - useEffect(() => { - const initializePage = async () => { - // First check if wallet is connected - if (!wallet) { - setAccessDenied(true); - setLoading(false); - return; - } - - try { - setLoading(true); - const response = await fetch(`/api/launches`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ token: tokenAddress }) - }); - const data = await response.json(); - - if (response.ok && data.launches && data.launches.length > 0) { - const launch = data.launches[0]; - - // Check if connected wallet is the creator - const walletAddress = wallet.toString(); - const creatorAddress = launch.creator_wallet; - - if (walletAddress !== creatorAddress) { - setAccessDenied(true); - setLoading(false); - return; - } - - setTokenInfo({ - address: tokenAddress, - symbol: launch.token_symbol || 'Unknown', - name: launch.token_name || 'Unknown Token' - }); - - // Load holders from database - await fetchHolders(); - - // Trigger background sync - triggerSync(); - } else { - // Token not found in launches - setAccessDenied(true); - } - } catch (error) { - console.error('Error initializing page:', error); - setAccessDenied(true); - } finally { - setLoading(false); - } - }; - - initializePage(); - }, [tokenAddress, wallet, fetchHolders, triggerSync]); - - const formatAddress = (address: string) => { - return `${address.slice(0, 6)}...${address.slice(-6)}`; - }; - - const handleEditClick = (holder: Holder) => { - setEditingHolder(holder.wallet_address); - setEditForm({ - telegram_username: holder.telegram_username || '', - discord_username: holder.discord_username || '', - x_username: holder.x_username || '', - custom_label: holder.custom_label || '' - }); - }; - - const handleCancelEdit = () => { - setEditingHolder(null); - setEditForm({ - telegram_username: '', - discord_username: '', - x_username: '', - custom_label: '' - }); - }; - - const handleSaveEdit = async (holderAddress: string) => { - try { - const response = await fetch(`/api/holders/${tokenAddress}/${holderAddress}/labels`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(editForm) - }); - - if (response.ok) { - // Refresh holders to show updated labels - await fetchHolders(); - console.log('Social labels saved successfully'); - } else { - console.error('Failed to save social labels:', response.status); - } - } catch (error) { - console.error('Error saving social labels:', error); - } - - setEditingHolder(null); - setEditForm({ - telegram_username: '', - discord_username: '', - x_username: '', - custom_label: '' - }); - }; - - const handleInputChange = (field: 'telegram_username' | 'discord_username' | 'x_username' | 'custom_label', value: string) => { - setEditForm(prev => ({ ...prev, [field]: value })); - }; - - // Handle access control rendering - if (loading) { - return ( -
-
-

Loading...

-
-
- ); - } - - if (accessDenied) { - return ( -
-
-

Access Denied

-
-

- {!wallet - ? "Please connect your wallet to view token holders." - : "You are not the creator of this token. Only token creators can view and manage holder information." - } -

-
-
-
- ); - } - - return ( -
-
- {/* Header */} -
- - - - - -

Holders

-
- -
-
-
-

Contract Address

-

{tokenInfo.address}

-
-
- Total Holders: - {holderStats.totalHolders} -
-
- -
- Symbol: - {tokenInfo.symbol} - Name: - {tokenInfo.name} -
-
- -
-
-
- {holderStats.lastSyncTime && ( - - Last sync: {new Date(holderStats.lastSyncTime).toLocaleString()} - - )} - {syncing && ( - Syncing holders... - )} -
- -
- -
- setSearchQuery(e.target.value)} - className="w-full pb-2 bg-transparent border-b border-white text-white text-lg placeholder:text-gray-300 focus:outline-none pr-8" - /> - {searchQuery && ( - - )} -
-
- -
- {holders.map((holder, index) => ( -
-
-
- #{currentPage * holdersPerPage + index + 1} - - {formatAddress(holder.wallet_address)} - - {wallet && holder.wallet_address === wallet.toBase58() && ( - - You - - )} - {holder.custom_label && ( - - {holder.custom_label} - - )} -
-
- - {parseFloat(holder.token_balance).toLocaleString(undefined, { maximumFractionDigits: 2 })} - - - {holder.percentage?.toFixed(2)}% - - -
-
- - {editingHolder === holder.wallet_address ? ( -
-
- handleInputChange('telegram_username', e.target.value)} - className="px-2 py-1 bg-gray-900 border border-gray-700 rounded text-sm text-white placeholder:text-gray-300-temp focus:outline-none focus:border-blue-500" - /> - handleInputChange('discord_username', e.target.value)} - className="px-2 py-1 bg-gray-900 border border-gray-700 rounded text-sm text-white placeholder:text-gray-300-temp focus:outline-none focus:border-purple-500" - /> - handleInputChange('x_username', e.target.value)} - className="px-2 py-1 bg-gray-900 border border-gray-700 rounded text-sm text-white placeholder:text-gray-300-temp focus:outline-none focus:border-sky-500" - /> - handleInputChange('custom_label', e.target.value)} - className="px-2 py-1 bg-gray-900 border border-gray-700 rounded text-sm text-white placeholder:text-gray-300-temp focus:outline-none focus:border-yellow-500" - /> -
-
- - -
-
- ) : ( - <> - {(holder.telegram_username || holder.discord_username || holder.x_username) && ( -
- {holder.telegram_username && ( - - TG: {holder.telegram_username} - - )} - {holder.discord_username && ( - - DC: {holder.discord_username} - - )} - {holder.x_username && ( - - X: {holder.x_username} - - )} -
- )} - - )} -
- ))} -
- - {totalPages > 1 && ( -
- - - Page {currentPage + 1} of {totalPages} - - -
- )} - - - {holders.length === 0 && ( -

No holders found for this token

- )} -
-
- ); -} \ No newline at end of file diff --git a/ui/app/layout.tsx b/ui/app/layout.tsx index 51c0f01..13f1905 100644 --- a/ui/app/layout.tsx +++ b/ui/app/layout.tsx @@ -4,7 +4,6 @@ import "./globals.css"; import { PrivyProviderWrapper } from "@/components/PrivyProviderWrapper"; import { WalletContextProvider } from "@/components/WalletProvider"; import { ToastContainer } from "@/components/Toast"; -import { AnnouncementBanner } from "@/components/AnnouncementBanner"; const inter = Inter({ subsets: ["latin"], @@ -46,7 +45,6 @@ export default function RootLayout({ > - {children} diff --git a/ui/app/manage/page.tsx b/ui/app/manage/page.tsx index 889596c..1ccb06f 100644 --- a/ui/app/manage/page.tsx +++ b/ui/app/manage/page.tsx @@ -2,8 +2,6 @@ import { useWallet } from '@/components/WalletProvider'; import { ClaimButton } from '@/components/ClaimButton'; -import { TransferModal } from '@/components/TransferModal'; -import { BurnModal } from '@/components/BurnModal'; import { Navigation } from '@/components/Navigation'; import { SecureVerificationModal } from '@/components/SecureVerificationModal'; import { useState, useEffect } from 'react'; @@ -51,21 +49,8 @@ export default function ManagePage() { const [launches, setLaunches] = useState([]); const [presales, setPresales] = useState([]); const [loading, setLoading] = useState(true); - const [loadingPresales, setLoadingPresales] = useState(true); const [error, setError] = useState(null); const [hasVerified, setHasVerified] = useState(false); - const [transferModal, setTransferModal] = useState<{ isOpen: boolean; tokenAddress: string; tokenSymbol: string; userBalance: string }>({ - isOpen: false, - tokenAddress: '', - tokenSymbol: '', - userBalance: '0' - }); - const [burnModal, setBurnModal] = useState<{ isOpen: boolean; tokenAddress: string; tokenSymbol: string; userBalance: string }>({ - isOpen: false, - tokenAddress: '', - tokenSymbol: '', - userBalance: '0' - }); const [loadingBalances, setLoadingBalances] = useState>(new Set()); const [copiedWallet, setCopiedWallet] = useState(false); const [copiedTokens, setCopiedTokens] = useState>(new Set()); @@ -255,12 +240,9 @@ export default function ManagePage() { const fetchPresales = async () => { if (!wallet) { setPresales([]); - setLoadingPresales(false); return; } - setLoadingPresales(true); - try { const response = await fetch(`/api/presale?creator=${wallet.toString()}`); const data = await response.json(); @@ -274,8 +256,6 @@ export default function ManagePage() { } catch (error) { console.error('Error fetching presales:', error); setPresales([]); - } finally { - setLoadingPresales(false); } }; @@ -293,32 +273,6 @@ export default function ManagePage() { }); }; - const openTransferModal = (tokenAddress: string, tokenSymbol: string, userBalance: string) => { - setTransferModal({ - isOpen: true, - tokenAddress, - tokenSymbol, - userBalance - }); - }; - - const closeTransferModal = () => { - setTransferModal(prev => ({ ...prev, isOpen: false })); - }; - - const openBurnModal = (tokenAddress: string, tokenSymbol: string, userBalance: string) => { - setBurnModal({ - isOpen: true, - tokenAddress, - tokenSymbol, - userBalance - }); - }; - - const closeBurnModal = () => { - setBurnModal(prev => ({ ...prev, isOpen: false })); - }; - const refreshTokenBalance = async (tokenAddress: string, delayMs: number = 0) => { if (!wallet) return; @@ -624,24 +578,24 @@ export default function ManagePage() {
- + - +
)} - refreshTokenBalance(transferModal.tokenAddress, 5000)} - /> - - refreshTokenBalance(burnModal.tokenAddress, 5000)} - /> - setShowVerificationModal(false)} diff --git a/ui/app/page.tsx b/ui/app/page.tsx deleted file mode 100644 index a95681e..0000000 --- a/ui/app/page.tsx +++ /dev/null @@ -1,922 +0,0 @@ -'use client'; - -import { useState, useEffect, useRef, useCallback } from 'react'; -import Link from 'next/link'; -import Image from 'next/image'; -import ZCombinatorLogo from '@/components/ZCombinatorLogo'; -import { Navigation } from '@/components/Navigation'; - -export default function Home() { - const [showGradients, setShowGradients] = useState(false); - const [heroOffset, setHeroOffset] = useState(0); - const [logoOffset, setLogoOffset] = useState(0); - const [logoSize, setLogoSize] = useState('h-[40rem]'); - const [currentCard, setCurrentCard] = useState(0); - const [heroReady, setHeroReady] = useState(false); - const carouselRef = useRef(null); - - const aiWorkers = [ - { - title: 'GVQ', - subtitle: 'ZC1', - skills: ['Scout founder', 'Bagscreener founder', 'Scannoors member', 'Ex TradFi quant'], - apps: ['slack', 'microsoft', 'teams', 'chrome', 'jira', 'teams'], - twitter: 'https://x.com/GVQ_xx', - pfp: '/gvq-pfp.jpg' - }, - { - title: 'Oogway', - subtitle: 'ZC1', - skills: ['Percent founder', '$oogway founder', 'Ex OlympusDAO policy team', 'Ex TradFi investment banking'], - apps: ['quickbooks', 'zapier', 'discord', 'kubernetes', 'slack', 'teams', 'github', 'docusign'], - twitter: 'https://x.com/oogway_defi', - pfp: '/oogway-pfp.jpg' - }, - { - title: 'Aelix', - subtitle: 'ZC1', - skills: ['Percent co-founder', '$oogway co-founder', 'CS+Math+Phil at Duke', 'Ex SWE at AWS Cloud'], - apps: ['jira', 'spotify', 'slack', 'teams'], - twitter: 'https://x.com/waniak_', - pfp: '/aelix-pfp.jpg' - }, - { - title: 'You', - subtitle: 'ZC1', - skills: ['Building DeFi protocols', 'Smart contract optimization', 'Cross-chain bridging'], - apps: ['notion', 'kubernetes', 'slack', 'teams', 'discord', 'docusign', 'spotify'], - twitter: undefined, - pfp: '/z-pfp.jpg' - }, - { - title: 'You', - subtitle: 'ZC1', - skills: ['NFT marketplace development', 'Token economics design', 'Community governance'], - apps: ['quickbooks', 'excel', 'slack', 'teams'], - twitter: undefined, - pfp: '/z-pfp.jpg' - }, - { - title: 'You', - subtitle: 'ZC1', - skills: ['Layer 2 scaling solutions', 'MEV protection systems', 'Wallet integration'], - apps: ['hubspot', 'canva', 'google', 'slack', 'teams'], - twitter: undefined, - pfp: '/z-pfp.jpg' - }, - { - title: 'You', - subtitle: 'ZC1', - skills: ['Solidity development', 'Protocol auditing', 'Gas optimization'], - apps: ['salesforce', 'hubspot', 'linkedin', 'slack', 'teams'], - twitter: undefined, - pfp: '/z-pfp.jpg' - }, - { - title: 'You', - subtitle: 'ZC1', - skills: ['Decentralized identity systems', 'ZK proof implementation', 'Oracle integration'], - apps: ['docusign', 'adobe', 'dropbox', 'slack', 'teams'], - twitter: undefined, - pfp: '/z-pfp.jpg' - }, - { - title: 'You', - subtitle: 'ZC1', - skills: ['DAO tooling', 'Staking mechanisms', 'Liquidity mining strategies'], - apps: ['github', 'jira', 'kubernetes', 'docker', 'slack'], - twitter: undefined, - pfp: '/z-pfp.jpg' - }, - { - title: 'You', - subtitle: 'ZC1', - skills: ['On-chain analytics', 'AMM development', 'Tokenomics modeling'], - apps: ['zendesk', 'intercom', 'slack', 'teams', 'notion'], - twitter: undefined, - pfp: '/z-pfp.jpg' - } - ]; - - const scrollCarouselRight = () => { - if (carouselRef.current && currentCard < aiWorkers.length - 1) { - const nextIndex = currentCard + 1; - setCurrentCard(nextIndex); - const cardWidth = window.innerWidth < 640 ? 386 : 426; // Mobile: 360px + 26px gap, Desktop: 400px + 26px gap - carouselRef.current.scrollTo({ - left: nextIndex * cardWidth, - behavior: 'smooth' - }); - } - }; - - const scrollCarouselLeft = () => { - if (carouselRef.current && currentCard > 0) { - const prevIndex = currentCard - 1; - setCurrentCard(prevIndex); - const cardWidth = window.innerWidth < 640 ? 386 : 426; // Mobile: 360px + 26px gap, Desktop: 400px + 26px gap - carouselRef.current.scrollTo({ - left: prevIndex * cardWidth, - behavior: 'smooth' - }); - } - }; - - const scrollTimeoutRef = useRef(null); - - const handleCarouselScroll = useCallback(() => { - if (scrollTimeoutRef.current) { - clearTimeout(scrollTimeoutRef.current); - } - - scrollTimeoutRef.current = setTimeout(() => { - if (carouselRef.current) { - const scrollLeft = carouselRef.current.scrollLeft; - const scrollWidth = carouselRef.current.scrollWidth; - const clientWidth = carouselRef.current.clientWidth; - const cardWidth = window.innerWidth < 640 ? 386 : 426; // Mobile: 360px + 26px gap, Desktop: 400px + 26px gap - - // Calculate which card is currently in view - let newIndex = Math.round(scrollLeft / cardWidth); - - // Check if we're at the very start - if (scrollLeft <= 10) { - newIndex = 0; - } - // Check if we're at the very end - else if (scrollLeft + clientWidth >= scrollWidth - 10) { - newIndex = aiWorkers.length - 1; - } - - setCurrentCard(newIndex); - } - }, 50); // Debounce by 50ms - }, [aiWorkers.length]); - - useEffect(() => { - // Scroll to top on page load - window.scrollTo(0, 0); - - // Calculate viewport height minus header and some padding - const calculateHeroPosition = () => { - const viewportHeight = window.innerHeight; - const viewportWidth = window.innerWidth; - const headerHeight = 200; // Approximate header + announcement height - const textHeight = 180; // Adjusted for two title lines + subheader + button - setHeroOffset(viewportHeight - headerHeight - 100 - textHeight); // Adjust for all text - - // Calculate dynamic logo size based on viewport - // Base size is 40rem (640px) for 1512x982 viewport - // Use average of width and height scaling for better responsiveness - const widthScale = viewportWidth / 1512; - const heightScale = viewportHeight / 982; - const scaleFactor = (widthScale + heightScale) / 2; // Average for balanced scaling - const baseLogoHeight = 640; - const logoHeight = Math.max(320, Math.min(2400, baseLogoHeight * scaleFactor)); // Increased max to 150rem - - console.log('Viewport:', viewportWidth, 'x', viewportHeight); - console.log('Scale factor:', scaleFactor); - console.log('Logo height:', logoHeight); - - // Set logo size class based on calculated height - if (logoHeight <= 400) { - setLogoSize('h-[25rem]'); - } else if (logoHeight <= 480) { - setLogoSize('h-[30rem]'); - } else if (logoHeight <= 560) { - setLogoSize('h-[35rem]'); - } else if (logoHeight <= 640) { - setLogoSize('h-[40rem]'); - } else if (logoHeight <= 720) { - setLogoSize('h-[45rem]'); - } else if (logoHeight <= 800) { - setLogoSize('h-[50rem]'); - } else if (logoHeight <= 880) { - setLogoSize('h-[55rem]'); - } else if (logoHeight <= 960) { - setLogoSize('h-[60rem]'); - } else if (logoHeight <= 1040) { - setLogoSize('h-[65rem]'); - } else if (logoHeight <= 1120) { - setLogoSize('h-[70rem]'); - } else if (logoHeight <= 1200) { - setLogoSize('h-[75rem]'); - } else if (logoHeight <= 1280) { - setLogoSize('h-[80rem]'); - } else if (logoHeight <= 1360) { - setLogoSize('h-[85rem]'); - } else if (logoHeight <= 1440) { - setLogoSize('h-[90rem]'); - } else if (logoHeight <= 1520) { - setLogoSize('h-[95rem]'); - } else if (logoHeight <= 1600) { - setLogoSize('h-[100rem]'); - } else if (logoHeight <= 1760) { - setLogoSize('h-[110rem]'); - } else if (logoHeight <= 1920) { - setLogoSize('h-[120rem]'); - } else if (logoHeight <= 2080) { - setLogoSize('h-[130rem]'); - } else if (logoHeight <= 2240) { - setLogoSize('h-[140rem]'); - } else { - setLogoSize('h-[150rem]'); - } - - // Calculate logo position - bottom aligned - setLogoOffset(viewportHeight - headerHeight - logoHeight); - - // Mark hero as ready after calculations - setHeroReady(true); - }; - - calculateHeroPosition(); - window.addEventListener('resize', calculateHeroPosition); - - const handleScroll = () => { - setShowGradients(window.scrollY > 50); - }; - - window.addEventListener('scroll', handleScroll); - return () => { - window.removeEventListener('scroll', handleScroll); - window.removeEventListener('resize', calculateHeroPosition); - }; - }, []); - - return ( -
- {/* Main Content */} -
-
- {/* Sticky Header */} -
-
-

-
- -
- Z -

- - - - - - GitHub - -
-
- - {/* Scrollable Content */} -
- {/* Gradient overlay that covers content */} - {showGradients && ( -
- )} -
- {/* Logo positioned on right side - desktop only */} -
- Z Logo -
- - {/* Hero Text positioned at bottom of initial viewport */} -
-
-

Instant distribution

-

for your product

-

Use tokens to bootstrap attention and reward real users.

- - LAUNCH - -
- {/* Mobile version with logo above text */} -
- Z Logo -

Instant distribution

-

for your product

-

Use tokens to bootstrap attention and reward real users.

- - LAUNCH - -
-
- - {/* Features Section */} -
-
-

Token launchpads are broken.

-

- Fixed supply and volume-based rewards incentivize short term building and investing. This leads developers to: -

-
-
- - - -

Bundle supply, maximize volume, then dump their tokens

-
-
- - - -

Struggle to fund themselves

-
-
- - - -

Chase attention instead of building

-
-
- - - -

Abandon projects they have no meaningful stake in

-
-
-

Z Combinator fixes this.

- - {/* Sticky Cards Container */} -
- {/* First Card - Autonomously picks up tasks */} -
-
-
-

No fees.

-

- Tokens launched on Z Combinator have 0 platform fees, compared to over 2% on some other launchpads. A better world for traders is a better world for devs. -

-

- All Z Combinator tokens have an initial supply of 1 billion tokens, and are immediately tradable on a bonding curve. When enough liquidity is raised, it migrates to a normal AMM that also has 0 trading fees. -

-
-
- {/* Static SOL tokens visualization */} -
- - {/* Define grayscale filter */} - - - - - - - {/* Static SOL tokens positioned across the canvas */} - - - - - - - - - - - - - - - - -
-
-
-
- - {/* Spacer for scroll - desktop only */} -
- - {/* Second Card - Orchestrates multiple AI agents */} -
-
-
-

Programmatic supply minting.

-

- Instead, the protocol mints 1M tokens every 24 hours to the builder. Builders cannot mint tokens themselves, preventing rugs. -

-

- This ensures builders are rewarded when market cap increases, not volume, while continually having funding to keep building for the long term. -

-
-
- {/* Static clock images */} -
- {/* Background smaller clocks */} - - - - - - - - - - {/* Main clock in center */} - 24h programmatic minting -
-
-
-
- - {/* Spacer for scroll - desktop only */} -
- - {/* Third Card - Placeholder */} -
-
-
-

Manage new mints.

-

- Builders can easily claim newly minted supply, track and reward contributions, and host their community. -

-

- With controlled, consistent token emissions, the best builders will use their tokens to incentivize sustainable product growth without scaring off traders. -

-
-
- {/* Token Distribution Network visualization */} -
- - {/* Define grayscale gradient for tokens */} - - - - - - - - - - - - - - - - - - {/* Connection lines from center to outer nodes */} - - - - - - - - - - - - - - {/* Animated flowing dots along connection lines */} - - - - - - - - - - - - - - - - - - - - {/* Outer token nodes with varied sizes, positions, and pulsing animation */} - - - - - - - - - - - - - - {/* Central hub token (larger) with pulsing animation */} - - -
-
-
-
- - {/* Spacer for scroll - desktop only */} -
-
-
-
- - {/* Constrained content from mission statement onwards */} -
- {/* Mission Statement - Full Width */} -
-

- Z Combinator enables token launches that align builders and holders via time-controlled token emissions. -

-
-
- ZC Community - {/* Gradient overlays for fade effect */} -
-
-
-
-
- -
- {/* First class of builders section */} -
-

- The first class of ZC builders. -

-

- These devs are proving that tokens can be used to their full potential to quickly bootstrap network effects and invalidate their ideas, laying the foundation for a new class of builders. -

-
- - {/* Dev Carousel */} -
-
- {/* Gradient overlays for fade effect */} - {currentCard > 0 && ( -
- )} - {currentCard < aiWorkers.length - 1 && ( -
- )} - - {/* Carousel container */} -
- {aiWorkers.map((worker, index) => ( -
-
- {/* Main content area */} -
- {/* Card header */} -
-
-

{worker.title}

- {worker.twitter && ( - - - - - - )} -
-

{worker.subtitle}

-
- - {/* Bio section */} -
-
- Bio -
-
- {worker.skills.map((skill, skillIndex) => ( -
- {skill} -
- ))} -
-
-
- - {/* Profile picture with gradient */} - {worker.pfp && ( -
- {`${worker.title} -
-
- )} -
- - {/* Lock icon and text overlay for "You" cards */} - {worker.title === 'You' && ( -
- - - -

TBA

-

(could be you)

-
- )} -
- ))} -
-
- - {/* Navigation buttons */} - {currentCard > 0 && ( - - )} - {currentCard < aiWorkers.length - 1 && ( - - )} -
-
- -
- {/* What is $ZC? Section */} -
-

What is $ZC?

- -

- $ZC is the first token launched on Z Combinator. We use the exact same product we're delivering to all developers on our platform. -

- $ZC represents a stake in all launches on our platform. Z Combinator takes a small portion of all token mints for each launch on the platform, aligning us and all $ZC holders with every developer through shared token ownership. -

- We are using $ZC emissions to continuously reward contributors who launch projects and drive attention, usage, and feedback. Visible, direct rewards pull in more contributors and accelerate growth. Great builders will mirror this with their own daily mints to bootstrap their networks and compress the time it takes to iterate on market feedback. -

- There are four ways to earn emissions right now: -

- - {/* Statistics Grid */} -
- {/* Stat 1 - Top Left */} -
-
-
-

- LAUNCH GOOD PROJECTS -

-

- +$ZC -

-
- - {/* Stat 2 - Top Right (mobile) / Second (desktop) */} -
-
-

- ONBOARD DEVS -

-

- +$ZC -

-
- - {/* Stat 3 - Bottom Left (mobile) / Third (desktop) */} -
-
-
-

- DRIVE ATTENTION -

-

- +$ZC -

-
- - {/* Stat 4 - Bottom Right (mobile) / Fourth (desktop) */} -
-

- GIVE GOOD FEEDBACK -

-

- +$ZC -

-
-
- - {/* Launch Button */} -
- - LAUNCH - -
-
-
-
{/* End of max-width wrapper */} -
-
- - {/* Polkadot pattern with gradient fade */} -
-
-
-
-
-
- - -
- ); -} diff --git a/ui/app/swap/page.tsx b/ui/app/swap/page.tsx deleted file mode 100644 index d730ee7..0000000 --- a/ui/app/swap/page.tsx +++ /dev/null @@ -1,830 +0,0 @@ -'use client'; - -import { useState, useEffect } from 'react'; -import { Navigation } from '@/components/Navigation'; -import { useWallet } from '@/components/WalletProvider'; -import { usePrivy } from '@privy-io/react-auth'; -import { Connection, PublicKey, Transaction, LAMPORTS_PER_SOL, TransactionMessage, VersionedTransaction, AddressLookupTableAccount } from '@solana/web3.js'; -import { TOKEN_PROGRAM_ID, getAccount, getAssociatedTokenAddress } from '@solana/spl-token'; -import { CpAmm } from '@meteora-ag/cp-amm-sdk'; -import { DynamicBondingCurveClient } from '@meteora-ag/dynamic-bonding-curve-sdk'; -import BN from 'bn.js'; -import { showToast } from '@/components/Toast'; - -// Import refactored services -import { getQuote } from './services/quoteService'; -import { executeSwap } from './services/swapService'; - -const RPC_URL = process.env.NEXT_PUBLIC_RPC_URL || 'https://api.mainnet-beta.solana.com'; -const ALT_ADDRESS = '8wUFS6aQ4fSN7BnvXJP83ZDRbVgq3KzPHeVsWqVWJk4B'; - -const SOL_TO_ZC_POOL = 'CCZdbVvDqPN8DmMLVELfnt9G1Q9pQNt3bTGifSpUY9Ad'; -const ZC_TO_TEST_POOL = 'EGXMUVs2c7xQv12prySkRwNTznCNgLiVwnNByEP9Xg6i'; -const ZC_TO_SHIRTLESS_POOL = 'EcE7GyMLvTK6tLWz2q7FopWqoW5836BbBh78nteon9vQ'; // ZC ↔ SHIRTLESS (ZC-quoted) -const SHIRTLESS_TO_GITPOST_POOL = '7LpSRp9R1KaVvgpgjrWfCLB476x4CKKVvf5ZmbpMugVU'; // SHIRTLESS ↔ GitPost (SHIRTLESS-quoted) -const WSOL = new PublicKey('So11111111111111111111111111111111111111112'); -const ZC_MINT = new PublicKey('GVvPZpC6ymCoiHzYJ7CWZ8LhVn9tL2AUpRjSAsLh6jZC'); -const TEST_MINT = new PublicKey('9q7QYACmxQmj1XATGua2eXpWfZHztibB4gw59FJobCts'); -const SHIRTLESS_MINT = new PublicKey('34mjcwkHeZWqJ8Qe3WuMJjHnCZ1pZeAd3AQ1ZJkKH6is'); -const GITPOST_MINT = new PublicKey('BSu52RaorX691LxPyGmLp2UiPzM6Az8w2Txd9gxbZN14'); -const PERC_MINT = new PublicKey('zcQPTGhdiTMFM6erwko2DWBTkN8nCnAGM7MUX9RpERC'); - -type Token = 'SOL' | 'ZC' | 'TEST' | 'SHIRTLESS' | 'GITPOST' | 'PERC'; - -interface SolanaWalletProvider { - signAndSendTransaction: (transaction: Transaction) => Promise<{ signature: string }>; -} - -interface WindowWithWallets extends Window { - solana?: SolanaWalletProvider; - solflare?: SolanaWalletProvider; -} - -export default function SwapPage() { - const { wallet, isPrivyAuthenticated } = useWallet(); - const { login, authenticated, linkWallet } = usePrivy(); - const [fromToken, setFromToken] = useState('SOL'); - const [toToken, setToToken] = useState('ZC'); - const [amount, setAmount] = useState(''); - const [estimatedOutput, setEstimatedOutput] = useState(''); - const [priceImpact, setPriceImpact] = useState(''); - const [isSwapping, setIsSwapping] = useState(false); - const [isCalculating, setIsCalculating] = useState(false); - const [slippage] = useState('1'); - const [lastQuoteTime, setLastQuoteTime] = useState(0); - const [quoteRefreshCountdown, setQuoteRefreshCountdown] = useState(10); - const [refreshTrigger, setRefreshTrigger] = useState(0); - const [showFromSelector, setShowFromSelector] = useState(false); - const [showToSelector, setShowToSelector] = useState(false); - const [balances, setBalances] = useState>({ SOL: '0', ZC: '0', TEST: '0', SHIRTLESS: '0', GITPOST: '0', PERC: '0' }); - const [isLoadingBalances, setIsLoadingBalances] = useState(false); - const [copiedWallet, setCopiedWallet] = useState(false); - const [refreshingBalancesAfterSwap, setRefreshingBalancesAfterSwap] = useState(false); - const [isMaxAmount, setIsMaxAmount] = useState(false); - - const getTokenSymbol = (token: Token): string => { - if (token === 'SOL') return 'SOL'; - if (token === 'ZC') return 'ZC'; - if (token === 'TEST') return 'TEST'; - if (token === 'SHIRTLESS') return 'SHIRTLESS'; - if (token === 'GITPOST') return 'POST'; - if (token === 'PERC') return 'PERC'; - return token; - }; - - const getTokenIcon = (token: Token) => { - if (token === 'SOL') return '/solana_logo.png'; - if (token === 'ZC') return '/zcombinator-logo.png'; - if (token === 'TEST') return '/percent.png'; - if (token === 'SHIRTLESS') return '/shirtless-logo.png'; - if (token === 'GITPOST') return '/gitpost-logo.png'; - if (token === 'PERC') return '/percent.png'; - return '/percent.png'; - }; - - const formatBalance = (balance: string): string => { - const bal = parseFloat(balance); - if (bal >= 1000000000) return (bal / 1000000000).toFixed(2).replace(/\.?0+$/, '') + 'B'; - if (bal >= 1000000) return (bal / 1000000).toFixed(2).replace(/\.?0+$/, '') + 'M'; - if (bal >= 1000) return (bal / 1000).toFixed(2).replace(/\.?0+$/, '') + 'K'; - return parseFloat(bal.toFixed(4)).toString(); - }; - - const copyWalletAddress = () => { - if (wallet) { - navigator.clipboard.writeText(wallet.toBase58()); - setCopiedWallet(true); - setTimeout(() => setCopiedWallet(false), 2000); - } - }; - - - const fetchBalances = async () => { - if (!wallet) return; - - setIsLoadingBalances(true); - try { - const connection = new Connection(RPC_URL, 'confirmed'); - const newBalances: Record = { SOL: '0', ZC: '0', TEST: '0', SHIRTLESS: '0', GITPOST: '0', PERC: '0' }; - - // Fetch SOL balance - const solBalance = await connection.getBalance(wallet); - newBalances.SOL = (solBalance / LAMPORTS_PER_SOL).toFixed(4); - - // Fetch ZC balance - try { - const zcAta = await getAssociatedTokenAddress(ZC_MINT, wallet, true); - const zcAccount = await getAccount(connection, zcAta); - newBalances.ZC = (Number(zcAccount.amount) / Math.pow(10, 6)).toFixed(4); - } catch (e) { - newBalances.ZC = '0'; - } - - // Fetch TEST balance - try { - const testAta = await getAssociatedTokenAddress(TEST_MINT, wallet, true); - const testAccount = await getAccount(connection, testAta); - newBalances.TEST = (Number(testAccount.amount) / Math.pow(10, 6)).toFixed(4); - } catch (e) { - newBalances.TEST = '0'; - } - - // Fetch SHIRTLESS balance - try { - const shirtlessAta = await getAssociatedTokenAddress(SHIRTLESS_MINT, wallet, true); - const shirtlessAccount = await getAccount(connection, shirtlessAta); - newBalances.SHIRTLESS = (Number(shirtlessAccount.amount) / Math.pow(10, 6)).toFixed(4); - } catch (e) { - newBalances.SHIRTLESS = '0'; - } - - // Fetch GITPOST balance - try { - const gitpostAta = await getAssociatedTokenAddress(GITPOST_MINT, wallet, true); - const gitpostAccount = await getAccount(connection, gitpostAta); - newBalances.GITPOST = (Number(gitpostAccount.amount) / Math.pow(10, 6)).toFixed(4); - } catch (e) { - newBalances.GITPOST = '0'; - } - - // Fetch PERC balance - try { - const percAta = await getAssociatedTokenAddress(PERC_MINT, wallet, true); - const percAccount = await getAccount(connection, percAta); - newBalances.PERC = (Number(percAccount.amount) / Math.pow(10, 6)).toFixed(4); - } catch (e) { - newBalances.PERC = '0'; - } - - setBalances(newBalances); - } catch (error) { - console.error('Error fetching balances:', error); - } finally { - setIsLoadingBalances(false); - } - }; - - // Fetch balances on mount and when wallet changes - useEffect(() => { - if (wallet && isPrivyAuthenticated) { - fetchBalances(); - } - }, [wallet, isPrivyAuthenticated]); - - // Close dropdowns when clicking outside - useEffect(() => { - const handleClickOutside = () => { - setShowFromSelector(false); - setShowToSelector(false); - }; - - if (showFromSelector || showToSelector) { - document.addEventListener('click', handleClickOutside); - return () => document.removeEventListener('click', handleClickOutside); - } - }, [showFromSelector, showToSelector]); - - const switchTokens = () => { - setFromToken(toToken); - setToToken(fromToken); - setAmount(''); - setEstimatedOutput(''); - setIsMaxAmount(false); - }; - - // Determine swap route based on from/to tokens and migration status - const getSwapRoute = (from: Token, to: Token): 'direct-cp' | 'direct-dbc' | 'double' | 'triple' | 'invalid' => { - if (from === to) return 'invalid'; - - // Direct CP-AMM swaps - if ((from === 'SOL' && to === 'ZC') || (from === 'ZC' && to === 'SOL')) return 'direct-cp'; - - // Direct DBC swaps - if ((from === 'ZC' && to === 'TEST') || (from === 'TEST' && to === 'ZC')) return 'direct-dbc'; - if ((from === 'ZC' && to === 'SHIRTLESS') || (from === 'SHIRTLESS' && to === 'ZC')) return 'direct-dbc'; - if ((from === 'SHIRTLESS' && to === 'GITPOST') || (from === 'GITPOST' && to === 'SHIRTLESS')) return 'direct-dbc'; - if ((from === 'ZC' && to === 'PERC') || (from === 'PERC' && to === 'ZC')) return 'direct-dbc'; - - // Double swaps (2 hops) - if (from === 'SOL' && to === 'TEST') return 'double'; // SOL → ZC → TEST - if (from === 'TEST' && to === 'SOL') return 'double'; // TEST → ZC → SOL - if (from === 'SOL' && to === 'SHIRTLESS') return 'double'; // SOL → ZC → SHIRTLESS - if (from === 'SHIRTLESS' && to === 'SOL') return 'double'; // SHIRTLESS → ZC → SOL - if (from === 'ZC' && to === 'GITPOST') return 'double'; - if (from === 'GITPOST' && to === 'ZC') return 'double'; - if (from === 'SOL' && to === 'PERC') return 'double'; // SOL → ZC → PERC - if (from === 'PERC' && to === 'SOL') return 'double'; // PERC → ZC → SOL - - // Triple swaps (3 hops) - if (from === 'TEST' && to === 'SHIRTLESS') return 'triple'; // TEST → ZC → SHIRTLESS - if (from === 'SHIRTLESS' && to === 'TEST') return 'triple'; // SHIRTLESS → ZC → TEST - if (from === 'TEST' && to === 'GITPOST') return 'triple'; // TEST → ZC → SHIRTLESS → GITPOST - if (from === 'GITPOST' && to === 'TEST') return 'triple'; // GITPOST → SHIRTLESS → ZC → TEST - if (from === 'SOL' && to === 'GITPOST') return 'triple'; - if (from === 'GITPOST' && to === 'SOL') return 'triple'; - - return 'invalid'; - }; - - const getTokenDecimals = (token: Token): number => { - if (token === 'SOL') return 9; - if (token === 'ZC') return 6; - if (token === 'TEST') return 6; - if (token === 'SHIRTLESS') return 6; - if (token === 'GITPOST') return 6; - if (token === 'PERC') return 6; - return 6; - }; - - const getTokenMint = (token: Token): PublicKey => { - if (token === 'SOL') return WSOL; - if (token === 'ZC') return ZC_MINT; - if (token === 'TEST') return TEST_MINT; - if (token === 'SHIRTLESS') return SHIRTLESS_MINT; - if (token === 'GITPOST') return GITPOST_MINT; - if (token === 'PERC') return PERC_MINT; - return TEST_MINT; - }; - - useEffect(() => { - if (!amount || parseFloat(amount) <= 0) { - setEstimatedOutput(''); - setPriceImpact(''); - return; - } - - const route = getSwapRoute(fromToken, toToken); - if (route === 'invalid') { - setEstimatedOutput(''); - setPriceImpact(''); - return; - } - - const calculateQuote = async () => { - setIsCalculating(true); - try { - const connection = new Connection(RPC_URL, 'confirmed'); - - const quoteResult = await getQuote( - connection, - fromToken, - toToken, - amount, - parseFloat(slippage) - ); - - if (quoteResult) { - setEstimatedOutput(quoteResult.outputAmount); - if (quoteResult.priceImpact) { - setPriceImpact(quoteResult.priceImpact); - } - setLastQuoteTime(Date.now()); - } - } catch (error) { - console.error('Error calculating quote:', error); - setEstimatedOutput('Error'); - } finally { - setIsCalculating(false); - } - }; - - const debounce = setTimeout(calculateQuote, 500); - return () => clearTimeout(debounce); - }, [amount, fromToken, toToken, slippage, refreshTrigger]); - - // Auto-refresh quotes every 10 seconds and update countdown - useEffect(() => { - if (!amount || parseFloat(amount) <= 0 || !estimatedOutput || estimatedOutput === 'Error') { - setQuoteRefreshCountdown(10); - return; - } - - // Update countdown every second - const countdownInterval = setInterval(() => { - const elapsed = Math.floor((Date.now() - lastQuoteTime) / 1000); - const remaining = Math.max(0, 10 - elapsed); - setQuoteRefreshCountdown(remaining); - }, 1000); - - // Trigger refresh every 10 seconds - const refreshInterval = setInterval(() => { - setRefreshTrigger(prev => prev + 1); - }, 10000); - - return () => { - clearInterval(countdownInterval); - clearInterval(refreshInterval); - }; - }, [amount, estimatedOutput, lastQuoteTime]); - - const handleConnectWallet = () => { - try { - if (!authenticated) { - login(); - } else { - linkWallet(); - } - } catch (err) { - console.error('Failed to connect wallet:', err); - showToast('error', 'Failed to connect wallet. Please try again.'); - } - }; - - const handleSwap = async () => { - const walletProvider = (window as WindowWithWallets).solana || (window as WindowWithWallets).solflare; - if (!wallet || !isPrivyAuthenticated || !walletProvider) { - showToast('error', 'Please connect your wallet'); - return; - } - - if (!amount || parseFloat(amount) <= 0) { - showToast('error', 'Please enter an amount'); - return; - } - - setIsSwapping(true); - try { - const connection = new Connection(RPC_URL, 'confirmed'); - - const result = await executeSwap({ - connection, - wallet, - fromToken, - toToken, - amount, - slippage: parseFloat(slippage), - isMaxAmount, - walletProvider - }); - - showToast('success', 'Swap successful!'); - - // Reset form - setAmount(''); - setEstimatedOutput(''); - setIsMaxAmount(false); - - // Refresh balances after 10 seconds - setRefreshingBalancesAfterSwap(true); - setTimeout(async () => { - await fetchBalances(); - setRefreshingBalancesAfterSwap(false); - }, 10000); - } catch (error: any) { - console.error('Swap error:', error); - showToast('error', error?.message || 'Swap failed'); - } finally { - setIsSwapping(false); - } - }; - - return ( -
- -
-
-
- {/* Header */} -
-

𝓩 Swap

-
- - {/* Wallet Info */} - {isPrivyAuthenticated && wallet && ( -
-
-
- Connected Wallet - - {refreshingBalancesAfterSwap && ( - - - - - )} -
- {isLoadingBalances && ( -
- - - - - Refreshing... -
- )} -
-
- {(['SOL', 'ZC', 'SHIRTLESS', 'GITPOST', 'PERC'] as Token[]).map((token) => ( -
- {getTokenIcon(token).startsWith('/') ? ( - {token} - ) : ( -
- {getTokenIcon(token)} -
- )} -
-
- {(() => { - const balance = parseFloat(balances[token]); - if (balance >= 1000000000) return (balance / 1000000000).toFixed(2).replace(/\.?0+$/, '') + 'B'; - if (balance >= 1000000) return (balance / 1000000).toFixed(2).replace(/\.?0+$/, '') + 'M'; - if (balance >= 1000) return (balance / 1000).toFixed(2).replace(/\.?0+$/, '') + 'K'; - // Show up to 4 decimal places without trailing zeros - return parseFloat(balance.toFixed(4)).toString(); - })()} -
-
{getTokenSymbol(token)}
-
-
- ))} -
-
- )} - - {/* Swap Container */} -
- {/* From Token */} -
-
- -
- Balance: - {getTokenIcon(fromToken).startsWith('/') ? ( - {fromToken} - ) : ( -
- {getTokenIcon(fromToken)} -
- )} - {formatBalance(balances[fromToken])} -
-
-
-
- { - setAmount(e.target.value); - setIsMaxAmount(false); - }} - placeholder="0.0" - className="w-full bg-transparent text-3xl font-semibold focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none pr-16" - step="any" - /> - -
- -
-
- - {showFromSelector && ( -
e.stopPropagation()} - className="absolute top-full mt-2 left-0 bg-[#1E1E1E] border border-gray-700 rounded-xl overflow-hidden shadow-xl z-50 min-w-[160px]" - > - {(['SOL', 'ZC', 'SHIRTLESS', 'GITPOST', 'PERC'] as Token[]).filter(t => t !== fromToken && t !== toToken).map((token) => ( - - ))} -
- )} -
-
-
-
- - {/* Switch Button */} -
- -
- - {/* To Token */} -
-
- -
- Balance: - {getTokenIcon(toToken).startsWith('/') ? ( - {toToken} - ) : ( -
- {getTokenIcon(toToken)} -
- )} - {formatBalance(balances[toToken])} -
-
-
-
- -
-
- - {showToSelector && ( -
e.stopPropagation()} - className="absolute top-full mt-2 left-0 bg-[#1E1E1E] border border-gray-700 rounded-xl overflow-hidden shadow-xl z-10 min-w-[160px]" - > - {(['SOL', 'ZC', 'SHIRTLESS', 'GITPOST', 'PERC'] as Token[]).filter(t => t !== fromToken && t !== toToken).map((token) => ( - - ))} -
- )} -
-
-
- - {/* Swap Info */} - {estimatedOutput && estimatedOutput !== 'Error' && ( -
-
- Rate -
- 1 {getTokenSymbol(fromToken)} = {(parseFloat(estimatedOutput) / parseFloat(amount || '1')).toFixed(6)} {getTokenSymbol(toToken)} - {quoteRefreshCountdown > 0 && ( - ({quoteRefreshCountdown}s) - )} -
-
- {priceImpact && ( -
- Price impact - = 10 ? 'text-red-400' : parseFloat(priceImpact) >= 5 ? 'text-yellow-400' : 'text-green-400'}> - {parseFloat(priceImpact).toFixed(2)}% - -
- )} -
- Route - - {getSwapRoute(fromToken, toToken) === 'direct-cp' && ( - <> - {getTokenIcon(fromToken).startsWith('/') ? ( - {fromToken} - ) : ( -
- {getTokenIcon(fromToken)} -
- )} - - {getTokenIcon(toToken).startsWith('/') ? ( - {toToken} - ) : ( -
- {getTokenIcon(toToken)} -
- )} - - )} - {getSwapRoute(fromToken, toToken) === 'direct-dbc' && ( - <> - {getTokenIcon(fromToken).startsWith('/') ? ( - {fromToken} - ) : ( -
- {getTokenIcon(fromToken)} -
- )} - - {getTokenIcon(toToken).startsWith('/') ? ( - {toToken} - ) : ( -
- {getTokenIcon(toToken)} -
- )} - - )} - {getSwapRoute(fromToken, toToken) === 'double' && (() => { - // Determine middle token for double swaps - // ZC ↔ GITPOST routes through SHIRTLESS - // SOL ↔ SHIRTLESS routes through ZC - // All other double swaps route through ZC or SOL - let middleToken: Token; - if ((fromToken === 'ZC' && toToken === 'GITPOST') || (fromToken === 'GITPOST' && toToken === 'ZC')) { - middleToken = 'SHIRTLESS'; - } else if ((fromToken === 'SOL' && toToken === 'SHIRTLESS') || (fromToken === 'SHIRTLESS' && toToken === 'SOL')) { - middleToken = 'ZC'; - } else { - middleToken = 'ZC'; - } - - return ( - <> - {getTokenIcon(fromToken).startsWith('/') ? ( - {fromToken} - ) : ( -
- {getTokenIcon(fromToken)} -
- )} - - {getTokenIcon(middleToken).startsWith('/') ? ( - {middleToken} - ) : ( -
- {getTokenIcon(middleToken)} -
- )} - - {getTokenIcon(toToken).startsWith('/') ? ( - {toToken} - ) : ( -
- {getTokenIcon(toToken)} -
- )} - - ); - })()} - {getSwapRoute(fromToken, toToken) === 'triple' && ( - <> - {getTokenIcon(fromToken).startsWith('/') ? ( - {fromToken} - ) : ( -
- {getTokenIcon(fromToken)} -
- )} - - {getTokenIcon('ZC').startsWith('/') ? ( - ZC - ) : ( -
- {getTokenIcon('ZC')} -
- )} - - {getTokenIcon('SHIRTLESS').startsWith('/') ? ( - SHIRTLESS - ) : ( -
- {getTokenIcon('SHIRTLESS')} -
- )} - - {getTokenIcon(toToken).startsWith('/') ? ( - {toToken} - ) : ( -
- {getTokenIcon(toToken)} -
- )} - - )} -
-
-
- )} - - {/* Swap Button */} - -
- - {/* Info Text */} -

- {getSwapRoute(fromToken, toToken) === 'direct-cp' && 'Direct swap via CP-AMM pool. '} - {getSwapRoute(fromToken, toToken) === 'direct-dbc' && 'Direct swap via DBC pool. '} - {getSwapRoute(fromToken, toToken) === 'double' && 'Multi-hop swap (2 hops). Executes in 1 transaction. '} - {getSwapRoute(fromToken, toToken) === 'triple' && 'Multi-hop swap (3 hops). Executes in 1 transaction. '} - Balances refresh 10 seconds after swap. Gas fees apply. -

-
-
-
-
- ); -} diff --git a/ui/components/ActivityBar.tsx b/ui/components/ActivityBar.tsx new file mode 100644 index 0000000..4f76b87 --- /dev/null +++ b/ui/components/ActivityBar.tsx @@ -0,0 +1,59 @@ +'use client'; + +export function ActivityBar() { + return ( +
+ {/* Files Icon - Active */} +
+
+
+ + + +
+
+ + {/* Search Icon */} +
+ + + +
+ + {/* Source Control Icon */} +
+ + + + + + +
+ + {/* Debug Icon */} +
+ + + + +
+ + {/* Extensions Icon */} +
+ + + + + + +
+
+ ); +} diff --git a/ui/components/BurnModal.tsx b/ui/components/BurnContent.tsx similarity index 50% rename from ui/components/BurnModal.tsx rename to ui/components/BurnContent.tsx index 77e81d9..c041ba9 100644 --- a/ui/components/BurnModal.tsx +++ b/ui/components/BurnContent.tsx @@ -1,34 +1,86 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useWallet } from '@/components/WalletProvider'; import { PublicKey, Transaction } from '@solana/web3.js'; import { createBurnInstruction, getAssociatedTokenAddress, TOKEN_PROGRAM_ID, - getMint + getMint, + getAccount } from '@solana/spl-token'; import { useSignTransaction } from '@privy-io/react-auth/solana'; import { showToast } from '@/components/Toast'; +import { useLaunchInfo } from '@/hooks/useTokenData'; -interface BurnModalProps { - isOpen: boolean; - onClose: () => void; +interface BurnContentProps { tokenAddress: string; tokenSymbol: string; userBalance: string; - onSuccess?: () => void; } - -export function BurnModal({ isOpen, onClose, tokenAddress, tokenSymbol, userBalance, onSuccess }: BurnModalProps) { +export function BurnContent({ tokenAddress, tokenSymbol: initialSymbol, userBalance: initialBalance }: BurnContentProps) { const { wallet, activeWallet } = useWallet(); const { signTransaction } = useSignTransaction(); const [amount, setAmount] = useState(''); const [isBurning, setIsBurning] = useState(false); const [errors, setErrors] = useState<{ amount?: string }>({}); const [burnProgress, setBurnProgress] = useState(''); + const [userBalance, setUserBalance] = useState(initialBalance); + const [tokenSymbol, setTokenSymbol] = useState(initialSymbol); + const [tokenName, setTokenName] = useState(''); + const [tokenImageUri, setTokenImageUri] = useState(); + + // Fetch token info from API + const { launchData } = useLaunchInfo(tokenAddress); + + useEffect(() => { + const launch = launchData?.launches?.[0]; + if (launch) { + setTokenSymbol(launch.token_symbol || initialSymbol); + setTokenName(launch.token_name || ''); + + if (launch.image_uri) { + setTokenImageUri(launch.image_uri); + } else if (launch.token_metadata_url) { + fetch(launch.token_metadata_url) + .then(res => res.json()) + .then(metadata => { + if (metadata.image) { + setTokenImageUri(metadata.image); + } + }) + .catch(() => {}); + } + } + }, [launchData, initialSymbol]); + + // Fetch user balance + useEffect(() => { + if (!wallet) return; + + const fetchBalance = async () => { + try { + const { Connection } = await import('@solana/web3.js'); + const connection = new Connection(process.env.NEXT_PUBLIC_RPC_URL || 'https://api.mainnet-beta.solana.com'); + + const mintPublicKey = new PublicKey(tokenAddress); + const tokenAccount = await getAssociatedTokenAddress(mintPublicKey, wallet); + + const accountInfo = await getAccount(connection, tokenAccount); + const mintInfo = await getMint(connection, mintPublicKey); + + const balance = Number(accountInfo.amount) / Math.pow(10, mintInfo.decimals); + setUserBalance(balance.toString()); + } catch (error) { + console.error('Error fetching balance:', error); + setUserBalance('0'); + } + }; + + fetchBalance(); + }, [wallet, tokenAddress]); // Helper function to safely parse user balance const parseUserBalance = (balance: string): number => { @@ -168,15 +220,15 @@ export function BurnModal({ isOpen, onClose, tokenAddress, tokenSymbol, userBala // Show success toast showToast('success', `Successfully burned ${amount} ${tokenSymbol} tokens`); - // Reset form and close modal + // Reset form and refresh balance setAmount(''); - // Call success callback to refresh balance - if (onSuccess) { - onSuccess(); - } - - onClose(); + // Refresh balance + const mintPublicKey2 = new PublicKey(tokenAddress); + const tokenAccount2 = await getAssociatedTokenAddress(mintPublicKey2, ownerPublicKey); + const accountInfo = await getAccount(connection, tokenAccount2); + const balance = Number(accountInfo.amount) / Math.pow(10, decimals); + setUserBalance(balance.toString()); } catch (error) { console.error('Burn error:', error); @@ -199,63 +251,93 @@ export function BurnModal({ isOpen, onClose, tokenAddress, tokenSymbol, userBala } }; - if (!isOpen) return null; - return ( -
-
-

Burn {tokenSymbol}

+
+ {/* Header */} +

Burn Tokens

+

+ {'//'}Permanently burn ${tokenSymbol} tokens +

+ + {/* Token Info */} +
+
+ {tokenImageUri && ( + {tokenSymbol} { + e.currentTarget.src = 'data:image/svg+xml,'; + }} + /> + )} + {tokenSymbol} + {tokenName && {tokenName}} + { + navigator.clipboard.writeText(tokenAddress); + }} + className="text-gray-300 cursor-pointer hover:text-[#b2e9fe] transition-colors" + title="Click to copy full address" + > + {tokenAddress.slice(0, 6)}...{tokenAddress.slice(-6)} + +
+
+ {/* Burn Form */} +
-
-

Amount

- handleAmountChange(e.target.value)} - placeholder="0.00" - className="w-full px-4 py-3 bg-black border border-gray-800 text-xl text-white placeholder:text-gray-300-temp focus:outline-none focus:border-white" - /> -
- Available: {userBalance === '--' ? '--' : parseUserBalance(userBalance).toLocaleString()} - +
+
+ + + Available: {userBalance === '--' ? '--' : parseUserBalance(userBalance).toLocaleString()} {tokenSymbol} +
- {errors.amount &&

{errors.amount}

} +
+
+ handleAmountChange(e.target.value)} + placeholder="0.00" + className="w-full bg-transparent text-3xl font-semibold focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none pr-16" + style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }} + disabled={isBurning} + /> + +
+
+ {errors.amount &&

{errors.amount}

}
{burnProgress && (
-

{burnProgress}

+

{burnProgress}

)} -
-
-
); -} \ No newline at end of file +} diff --git a/ui/components/ClaimButton.tsx b/ui/components/ClaimButton.tsx index 7cfef45..8cd9b0d 100644 --- a/ui/components/ClaimButton.tsx +++ b/ui/components/ClaimButton.tsx @@ -24,11 +24,12 @@ interface ClaimButtonProps { onSuccess?: () => void; disabled?: boolean; disabledReason?: string; + isMobile?: boolean; } type ClaimInfo = ClaimInfoResponse; -export function ClaimButton({ tokenAddress, tokenSymbol, onSuccess, disabled = false, disabledReason }: ClaimButtonProps) { +export function ClaimButton({ tokenAddress, tokenSymbol, onSuccess, disabled = false, disabledReason, isMobile = false }: ClaimButtonProps) { const { wallet, activeWallet } = useWallet(); const { signTransaction } = useSignTransaction(); const [claimInfo, setClaimInfo] = useState(null); @@ -76,6 +77,17 @@ export function ClaimButton({ tokenAddress, tokenSymbol, onSuccess, disabled = f return `${minutes}m`; }; + const formatNumberShort = (value: number) => { + if (value >= 1_000_000_000) { + return `${(value / 1_000_000_000).toFixed(2)}B`; + } else if (value >= 1_000_000) { + return `${(value / 1_000_000).toFixed(2)}M`; + } else if (value >= 1_000) { + return `${(value / 1_000).toFixed(2)}K`; + } + return value.toLocaleString(undefined, { maximumFractionDigits: 2 }); + }; + useEffect(() => { fetchClaimInfo(); }, [fetchClaimInfo]); @@ -259,24 +271,24 @@ export function ClaimButton({ tokenAddress, tokenSymbol, onSuccess, disabled = f if (disabled) { return ( -
- Claim +
+ [Claim]
); } if (loading) { return ( -
- Loading claim info... +
+ [Loading claim info...]
); } if (!claimInfo) { return ( -
- Failed to load claim information +
+ {isMobile ? '[Failed]' : '[Failed to load claim information]'}
); } @@ -287,24 +299,27 @@ export function ClaimButton({ tokenAddress, tokenSymbol, onSuccess, disabled = f return (
{!claimInfo.canClaimNow && timeRemaining ? ( -
- Next Claim: {timeRemaining} +
+ [Next Claim: {timeRemaining}]
) : ( )} diff --git a/ui/components/ClaimContent.tsx b/ui/components/ClaimContent.tsx new file mode 100644 index 0000000..dbf58dc --- /dev/null +++ b/ui/components/ClaimContent.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { usePrivy } from '@privy-io/react-auth'; +import { useWallet } from '@/components/WalletProvider'; + +export function ClaimContent() { + const { login, ready } = usePrivy(); + const { isPrivyAuthenticated } = useWallet(); + + const handleSocialLogin = (provider: 'twitter' | 'github') => { + login({ + loginMethods: [provider] + }); + }; + + if (!ready) { + return ( + <> +

Claim

+

Loading...

+ + ); + } + + if (isPrivyAuthenticated) { + return ( + <> +

Claim

+
+

+ You're already connected! Navigate to the Portfolio page to manage your tokens. +

+
+ + ); + } + + return ( + <> +

Claim

+ +
+

{'//'}Trying to claim rewards for a ZC token someone else launched for you?

+

Claim your token rewards by connecting your X or GitHub account.

+

This will create an embedded wallet for you to receive tokens.

+

NOTE: If you launched a token, manage your token on the Portfolio page.

+

Only use this page if you were designated a token from someone else.

+ +
+ + + +
+
+ + ); +} diff --git a/ui/components/FileExplorer.tsx b/ui/components/FileExplorer.tsx new file mode 100644 index 0000000..f03736f --- /dev/null +++ b/ui/components/FileExplorer.tsx @@ -0,0 +1,162 @@ +'use client'; + +import { useState } from 'react'; + +export function FileExplorer({ className }: { className?: string }) { + const [isFileHovered, setIsFileHovered] = useState(false); + + return ( +
+ {/* Explorer Header */} +
+ EXPLORER +
+ + {/* File Tree */} +
+ {/* Project Folder - Expanded */} + +
{ e.currentTarget.style.backgroundColor = '#474748'; setIsFileHovered(true); }} onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; setIsFileHovered(false); }}> + + + + + + + z-combinator +
+
+ + {/* Nested Files */} +
+ {/* app folder */} + +
{ e.currentTarget.style.backgroundColor = '#474748'; setIsFileHovered(true); }} onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; setIsFileHovered(false); }}> + + + + + + + app +
+
+ + {/* Files inside app */} + + + {/* components folder */} + +
{ e.currentTarget.style.backgroundColor = '#474748'; setIsFileHovered(true); }} onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; setIsFileHovered(false); }}> + + + + + + + components +
+
+ + {/* public folder */} + +
{ e.currentTarget.style.backgroundColor = '#474748'; setIsFileHovered(true); }} onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; setIsFileHovered(false); }}> + + + + + + + public +
+
+ + {/* package.json */} + +
{ e.currentTarget.style.backgroundColor = '#474748'; setIsFileHovered(true); }} onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; setIsFileHovered(false); }}> + + + + + + + package.json +
+
+ + {/* README.md */} + +
{ e.currentTarget.style.backgroundColor = '#474748'; setIsFileHovered(true); }} onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; setIsFileHovered(false); }}> + + + + + + + README.md +
+
+
+
+ + {/* Hover Info Box */} + {isFileHovered && ( +
+ Z Combinator is open source! Click and submit a good quality PR to earn $ZC. +
+ )} +
+ ); +} diff --git a/ui/components/Footer.tsx b/ui/components/Footer.tsx new file mode 100644 index 0000000..3773b17 --- /dev/null +++ b/ui/components/Footer.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { useState } from 'react'; +import Image from 'next/image'; + +export function Footer() { + const [copied, setCopied] = useState(false); + + const handleCopyCA = async () => { + await navigator.clipboard.writeText('GVvPZpC6ymCoiHzYJ7CWZ8LhVn9tL2AUpRjSAsLh6jZC'); + setCopied(true); + setTimeout(() => setCopied(false), 1000); + }; + + return ( + + ); +} diff --git a/ui/components/Header.tsx b/ui/components/Header.tsx new file mode 100644 index 0000000..c4296b1 --- /dev/null +++ b/ui/components/Header.tsx @@ -0,0 +1,17 @@ +'use client'; + +import { TabBar } from './TabBar'; + +export function Header() { + return ( +
+ +
+ ); +} diff --git a/ui/components/HistoryContent.tsx b/ui/components/HistoryContent.tsx new file mode 100644 index 0000000..41d2963 --- /dev/null +++ b/ui/components/HistoryContent.tsx @@ -0,0 +1,626 @@ +'use client'; + +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useWallet } from '@/components/WalletProvider'; +import { useLaunchInfo, useTokenInfo, useDesignatedClaims, useTransactions } from '@/hooks/useTokenData'; + +interface Transaction { + signature: string; + timestamp: number; + type: 'transfer' | 'buy' | 'sell' | 'burn' | 'mint' | 'unknown'; + amount: string; + solAmount?: string; + fromWallet: string; + toWallet: string; + fromLabel: string; + toLabel: string; + memo?: string | null; + rawTransaction?: unknown; +} + +interface TokenInfo { + address: string; + symbol: string; + name: string; + totalSupply: string; + imageUri?: string; +} + +interface LaunchInfo { + creatorWallet: string; + creatorTwitter?: string; + creatorGithub?: string; + isCreatorDesignated: boolean; + verifiedWallet?: string; + verifiedEmbeddedWallet?: string; +} + +interface HistoryContentProps { + tokenAddress: string; + tokenSymbol?: string; +} + +export function HistoryContent({ tokenAddress, tokenSymbol = '' }: HistoryContentProps) { + const { wallet } = useWallet(); + + // Use SWR hooks for cached data + const { launchData, isLoading: launchLoading, mutate: mutateLaunch } = useLaunchInfo(tokenAddress); + const { tokenInfo: supplyData, isLoading: supplyLoading, mutate: mutateSupply } = useTokenInfo(tokenAddress); + const { designatedData, isLoading: designatedLoading, mutate: mutateDesignated } = useDesignatedClaims(tokenAddress); + + // State for UI + const [transactionPages, setTransactionPages] = useState([]); + const [currentPage, setCurrentPage] = useState(0); + const [lastSignature, setLastSignature] = useState(null); + const [expandedTransactions, setExpandedTransactions] = useState>(new Set()); + const [loadingPage, setLoadingPage] = useState(false); + const TRANSACTIONS_PER_PAGE = 10; + + // Compute combined data from cached responses + const [tokenImageUri, setTokenImageUri] = useState(); + + const tokenInfo: TokenInfo = useMemo(() => { + const launch = launchData?.launches?.[0]; + return { + address: tokenAddress, + symbol: launch?.token_symbol || tokenSymbol || '', + name: launch?.token_name || 'Unknown Token', + totalSupply: supplyData?.supply || '1000000000', + imageUri: tokenImageUri || launch?.image_uri + }; + }, [tokenAddress, tokenSymbol, launchData, supplyData, tokenImageUri]); + + // Fetch metadata image if not in DB + useEffect(() => { + const launch = launchData?.launches?.[0]; + if (!launch?.image_uri && launch?.token_metadata_url && !tokenImageUri) { + fetch(launch.token_metadata_url) + .then(res => res.json()) + .then(metadata => { + if (metadata.image) { + setTokenImageUri(metadata.image); + } + }) + .catch(() => { + // Failed to fetch metadata + }); + } else if (launch?.image_uri) { + setTokenImageUri(launch.image_uri); + } + }, [launchData, tokenImageUri]); + + const launchInfo: LaunchInfo | null = useMemo(() => { + const launch = launchData?.launches?.[0]; + const claim = designatedData?.claim; + + if (!launch) return null; + + const creatorWallet = claim?.original_launcher || launch.creator_wallet; + if (!creatorWallet) return null; + + return { + creatorWallet, + creatorTwitter: launch.creator_twitter, + creatorGithub: launch.creator_github, + isCreatorDesignated: !!(claim?.verified_at || launch.is_creator_designated), + verifiedWallet: claim?.verified_wallet, + verifiedEmbeddedWallet: claim?.verified_embedded_wallet + }; + }, [launchData, designatedData]); + + // Get creator wallet for transactions fetch + const creatorWallet = launchInfo?.creatorWallet || null; + const isUserDev = !!(wallet && creatorWallet && wallet.toBase58() === creatorWallet); + + // Fetch first page of transactions using SWR + const { + transactions: firstPageTransactions, + hasMore, + lastSignature: firstPageLastSig, + isLoading: transactionsLoading, + mutate: mutateTransactions + } = useTransactions(tokenAddress, creatorWallet, null, isUserDev); + + // Overall loading state + const loading = launchLoading || supplyLoading || designatedLoading || transactionsLoading; + + // Helper function to truncate addresses - memoized + const truncateAddress = useCallback((address: string) => { + if (!address || address.length < 12) return address; + return `${address.slice(0, 6)}...${address.slice(-6)}`; + }, []); + + // Helper function to replace user's wallet with "You" - memoized + const processLabel = useCallback((label: string, walletAddress: string) => { + // Check if the wallet address matches the user's wallet + if (wallet && walletAddress === wallet.toBase58()) { + return 'You'; + } + // If label is an address, truncate it + if (label === walletAddress || label.match(/^[A-Za-z0-9]{40,}$/)) { + return truncateAddress(label); + } + // Otherwise return the label as-is + return label; + }, [wallet, truncateAddress]); + + // Process first page transactions with labels + const currentTransactions = useMemo(() => { + if (currentPage === 0 && firstPageTransactions.length > 0) { + return firstPageTransactions.map((tx: Transaction) => ({ + ...tx, + fromLabel: processLabel(tx.fromLabel, tx.fromWallet), + toLabel: processLabel(tx.toLabel, tx.toWallet) + })); + } + return transactionPages[currentPage] || []; + }, [currentPage, firstPageTransactions, transactionPages, processLabel]); + + // Track hasMore for pagination + const hasMorePages = currentPage === 0 ? hasMore : (transactionPages[currentPage + 1] !== undefined || lastSignature !== null); + + // Update pagination state when first page loads + useEffect(() => { + if (currentPage === 0 && firstPageTransactions.length > 0 && !transactionsLoading) { + const processedTransactions = firstPageTransactions.map((tx: Transaction) => ({ + ...tx, + fromLabel: processLabel(tx.fromLabel, tx.fromWallet), + toLabel: processLabel(tx.toLabel, tx.toWallet) + })); + setTransactionPages([processedTransactions]); + setLastSignature(firstPageLastSig); + } + }, [firstPageTransactions, firstPageLastSig, currentPage, processLabel, transactionsLoading]); + + // Helper function to calculate percentage of supply + const calculateSupplyPercentage = (amount: string) => { + const amountNum = parseFloat(amount.replace(/,/g, '')); + const totalSupplyNum = parseFloat(tokenInfo.totalSupply.replace(/,/g, '')); + if (totalSupplyNum === 0) return '0.00'; + return ((amountNum / totalSupplyNum) * 100).toFixed(4); + }; + + // Helper function to format token amounts with K/M/B + const formatTokenAmount = (amount: string | undefined) => { + if (!amount) return '0'; + // Remove commas before parsing (amounts come formatted as "1,000,000") + const num = parseFloat(amount.replace(/,/g, '')); + if (num >= 1_000_000_000) { + const billions = num / 1_000_000_000; + return billions >= 10 ? `${Math.floor(billions)}B` : `${billions.toFixed(1)}B`; + } else if (num >= 1_000_000) { + const millions = num / 1_000_000; + return millions >= 10 ? `${Math.floor(millions)}M` : `${millions.toFixed(1)}M`; + } else if (num >= 1_000) { + const thousands = num / 1_000; + return thousands >= 10 ? `${Math.floor(thousands)}K` : `${thousands.toFixed(1)}K`; + } + return Math.floor(num).toString(); + }; + + // Handle page navigation - memoized + const navigateToPage = useCallback(async (newPage: number) => { + if (newPage < 0) return; + + if (!launchInfo?.creatorWallet) { + return; + } + + setCurrentPage(newPage); + + // Check if we already have this page cached + if (transactionPages[newPage]) { + setCurrentPage(newPage); + return; + } + + // Need to fetch this page + setLoadingPage(true); + + try { + const response = await fetch(`/api/transactions/${tokenAddress}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tokenAddress, + walletAddress: launchInfo.creatorWallet, + limit: TRANSACTIONS_PER_PAGE, + fetchLabels: isUserDev, + ...(lastSignature && { before: lastSignature }) + }) + }); + + if (!response.ok) { + throw new Error('Failed to fetch transactions'); + } + + const data = await response.json(); + const transactions = data.transactions || []; + const hasMore = data.hasMore || false; + const newLastSignature = data.lastSignature || null; + + if (transactions.length > 0) { + const processedTransactions = transactions.map((tx: Transaction) => ({ + ...tx, + fromLabel: processLabel(tx.fromLabel, tx.fromWallet), + toLabel: processLabel(tx.toLabel, tx.toWallet) + })); + + const newPages = [...transactionPages]; + newPages[newPage] = processedTransactions; + setTransactionPages(newPages); + setLastSignature(newLastSignature); + } + } catch (error) { + // Error fetching page + } finally { + setLoadingPage(false); + } + }, [tokenAddress, lastSignature, TRANSACTIONS_PER_PAGE, processLabel, transactionPages, launchInfo]); + + const formatDate = (timestamp: number) => { + const date = new Date(timestamp * 1000); + return date.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + }; + + + // Helper to check if label is a social label (not a wallet address) + const isSocialLabel = (label: string, wallet: string) => { + return label !== wallet && !label.match(/^[A-Za-z0-9]{6}\.\.\.[A-Za-z0-9]{6}$/); + }; + + const getTransactionDescription = (tx: Transaction) => { + switch (tx.type) { + case 'transfer': + return { + action: 'Reward', + description: `${formatTokenAmount(tx.amount)} to `, + toUser: tx.toLabel, + toUserIsSocial: isSocialLabel(tx.toLabel, tx.toWallet) + }; + case 'mint': + return { + action: 'Claim', + description: formatTokenAmount(tx.amount), + toUser: '', + toUserIsSocial: false + }; + case 'sell': + return { + action: 'Sell', + description: tx.solAmount ? `${tx.solAmount} SOL` : `${formatTokenAmount(tx.amount)} ${tokenInfo.symbol}`, + toUser: '', + toUserIsSocial: false + }; + case 'buy': + return { + action: 'Buy', + description: formatTokenAmount(tx.amount), + toUser: '', + toUserIsSocial: false + }; + case 'burn': + return { + action: 'Burn', + description: formatTokenAmount(tx.amount), + toUser: '', + toUserIsSocial: false + }; + default: + return { + action: 'Unknown', + description: 'transaction', + toUser: '', + toUserIsSocial: false + }; + } + }; + + const getTypeColor = (type: string) => { + switch (type) { + case 'transfer': return 'text-[#b2e9fe]'; + case 'buy': return 'text-[#b2e9fe]'; + case 'sell': return 'text-[#b2e9fe]'; + case 'burn': return 'text-[#b2e9fe]'; + case 'mint': return 'text-[#b2e9fe]'; + default: return 'text-gray-300'; + } + }; + + const getTypeIcon = (type: string) => { + switch (type) { + case 'transfer': + return ( + + + + ); + case 'mint': + return ( + + + + ); + case 'sell': + return ( + + + + ); + case 'buy': + return ( + + + + ); + case 'burn': + return ( + + + + ); + default: + return ( + + + + ); + } + }; + + return ( +
+ {/* Header */} +

Txn History

+

+ {'//'}Transaction history for ${tokenInfo.symbol} +

+ +
+
+ {tokenInfo.imageUri && ( + {tokenInfo.symbol} { + e.currentTarget.src = 'data:image/svg+xml,'; + }} + /> + )} + {tokenInfo.symbol} + {tokenInfo.name} + { + navigator.clipboard.writeText(tokenInfo.address); + }} + className="text-gray-300 cursor-pointer hover:text-[#b2e9fe] transition-colors" + title="Click to copy full address" + > + {tokenInfo.address.slice(0, 6)}...{tokenInfo.address.slice(-6)} + +
+
+ + {/* Refresh Button */} + {!loading && ( +
+ +
+ )} + + {/* Transactions */} + {loading ? ( +

+ [Loading...] +

+ ) : ( +
+ {currentTransactions.length === 0 ? ( +

+ No transactions found +

+ ) : ( + currentTransactions.map((tx: Transaction) => { + const isExpanded = expandedTransactions.has(tx.signature); + const hasMemo = tx.memo && tx.memo.trim().length > 0; + + return ( +
+ {/* Transaction Row - Desktop */} +
+
+ +
+ + {(() => { + const desc = getTransactionDescription(tx); + return ( + <> + {desc.action} + : {desc.description} + {desc.toUser && ( + + {desc.toUser} + + )} + + ); + })()} + + + ({calculateSupplyPercentage(tx.amount)}%) + +
+
+
+ + {formatDate(tx.timestamp)} + + + + + + +
+
+ + {/* Transaction Row - Mobile */} +
+ {/* First Row: Icon, Label with amount and "to who", Solscan link */} +
+
+ +
+ + {(() => { + const desc = getTransactionDescription(tx); + return ( + <> + {desc.action} + : {desc.description} + {desc.toUser && ( + + {desc.toUser} + + )} + + ); + })()} + + + ({parseFloat(calculateSupplyPercentage(tx.amount)).toFixed(1)}%) + +
+
+ + + + + +
+ {/* Second Row: Timestamp */} +
+ + {formatDate(tx.timestamp)} + +
+
+ {/* Memo Expansion */} + {hasMemo && isExpanded && ( +
+

+ {tx.memo} +

+
+ )} +
+ ); + }) + )} +
+ )} + + {/* Pagination */} + {!loading && (currentPage > 0 || hasMorePages) && ( +
+ + + Page {currentPage + 1} + + +
+ )} +
+ ); +} diff --git a/ui/components/HoldersContent.tsx b/ui/components/HoldersContent.tsx new file mode 100644 index 0000000..d6f1ea4 --- /dev/null +++ b/ui/components/HoldersContent.tsx @@ -0,0 +1,485 @@ +'use client'; + +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useWallet } from '@/components/WalletProvider'; +import { PublicKey } from '@solana/web3.js'; +import { useLaunchInfo, useHolders } from '@/hooks/useTokenData'; + +interface Holder { + id?: number; + wallet_address: string; + token_balance: string; + staked_balance: string; + telegram_username?: string | null; + x_username?: string | null; + discord_username?: string | null; + custom_label?: string | null; + created_at?: string; + updated_at?: string; + last_sync_at?: string; + percentage?: number; +} + +interface HolderStats { + totalHolders: number; + totalBalance: string; + lastSyncTime: string | null; +} + +interface HoldersContentProps { + tokenAddress: string; + tokenSymbol?: string; +} + +// Helper function to check if a wallet address is on curve (not a PDA) +function isOnCurve(address: string): boolean { + try { + const pubkey = new PublicKey(address); + return PublicKey.isOnCurve(pubkey.toBuffer()); + } catch { + return false; + } +} + +export function HoldersContent({ tokenAddress, tokenSymbol = '' }: HoldersContentProps) { + const { wallet } = useWallet(); + + // Use SWR hooks for cached data + const { launchData, isLoading: launchLoading, mutate: mutateLaunch } = useLaunchInfo(tokenAddress); + const { holders: rawHolders, stats, isLoading: holdersLoading, mutate: mutateHolders } = useHolders(tokenAddress); + + const [accessDenied, setAccessDenied] = useState(false); + const [editingHolder, setEditingHolder] = useState(null); + const [editForm, setEditForm] = useState({ + telegram_username: '', + discord_username: '', + x_username: '', + custom_label: '' + }); + const [syncing, setSyncing] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + + // Overall loading state + const loading = launchLoading || holdersLoading; + + const calculatePercentages = (holders: Holder[], totalBalance: string): Holder[] => { + const total = parseFloat(totalBalance); + if (total === 0) return holders; + + return holders.map(holder => ({ + ...holder, + percentage: (parseFloat(holder.token_balance) / total) * 100 + })); + }; + + // Get token symbol from launch data + const actualTokenSymbol = useMemo(() => { + const launch = launchData?.launches?.[0]; + return launch?.token_symbol || tokenSymbol || ''; + }, [launchData, tokenSymbol]); + + // Process holders: filter on-curve and add percentages + const allHolders = useMemo(() => { + const onCurveHolders = rawHolders.filter((holder: Holder) => + isOnCurve(holder.wallet_address) && parseFloat(holder.token_balance) > 0 + ); + return calculatePercentages(onCurveHolders, stats.totalBalance); + }, [rawHolders, stats.totalBalance]); + + const holderStats = useMemo(() => ({ + ...stats, + totalHolders: allHolders.length + }), [stats, allHolders.length]); + + const triggerSync = useCallback(async () => { + setSyncing(true); + try { + const response = await fetch(`/api/holders/${tokenAddress}/sync`, { + method: 'POST' + }); + + if (response.ok) { + // Revalidate all cached data + await mutateHolders(); + await mutateLaunch(); + } else { + console.error('Failed to sync holders:', response.status); + } + } catch (error) { + console.error('Error syncing holders:', error); + } finally { + setSyncing(false); + } + }, [tokenAddress, mutateHolders, mutateLaunch]); + + // Filter holders based on search query + const filteredHolders = allHolders.filter(holder => { + if (!searchQuery) return true; + + const query = searchQuery.toLowerCase(); + const walletMatch = holder.wallet_address.toLowerCase().includes(query); + const telegramMatch = holder.telegram_username?.toLowerCase().includes(query); + const discordMatch = holder.discord_username?.toLowerCase().includes(query); + const xMatch = holder.x_username?.toLowerCase().includes(query); + const customLabelMatch = holder.custom_label?.toLowerCase().includes(query); + + return walletMatch || telegramMatch || discordMatch || xMatch || customLabelMatch; + }); + + const [currentPage, setCurrentPage] = useState(0); + const holdersPerPage = 10; + const totalPages = Math.ceil(filteredHolders.length / holdersPerPage); + const holders = filteredHolders.slice( + currentPage * holdersPerPage, + (currentPage + 1) * holdersPerPage + ); + + // Reset to first page when search query changes + useEffect(() => { + setCurrentPage(0); + }, [searchQuery]); + + // Check access permissions + useEffect(() => { + if (!wallet) { + setAccessDenied(true); + return; + } + + if (!launchLoading && launchData) { + const launch = launchData.launches?.[0]; + if (launch) { + const walletAddress = wallet.toString(); + const creatorAddress = launch.creator_wallet; + setAccessDenied(walletAddress !== creatorAddress); + } else { + setAccessDenied(true); + } + } + }, [wallet, launchData, launchLoading]); + + const formatAddress = (address: string) => { + return `${address.slice(0, 6)}...${address.slice(-6)}`; + }; + + const formatAddressMobile = (address: string) => { + return address.slice(0, 6); + }; + + const formatNumberShort = (value: number) => { + if (value >= 1_000_000_000) { + return `${(value / 1_000_000_000).toFixed(2)}B`; + } else if (value >= 1_000_000) { + return `${(value / 1_000_000).toFixed(2)}M`; + } else if (value >= 1_000) { + return `${(value / 1_000).toFixed(2)}K`; + } + return value.toLocaleString(undefined, { maximumFractionDigits: 2 }); + }; + + const handleEditClick = (holder: Holder) => { + setEditingHolder(holder.wallet_address); + setEditForm({ + telegram_username: holder.telegram_username || '', + discord_username: holder.discord_username || '', + x_username: holder.x_username || '', + custom_label: holder.custom_label || '' + }); + }; + + const handleCancelEdit = () => { + setEditingHolder(null); + setEditForm({ + telegram_username: '', + discord_username: '', + x_username: '', + custom_label: '' + }); + }; + + const handleSaveEdit = async (holderAddress: string) => { + try { + const response = await fetch(`/api/holders/${tokenAddress}/${holderAddress}/labels`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(editForm) + }); + + if (response.ok) { + // Revalidate cached holders data + await mutateHolders(); + console.log('Social labels saved successfully'); + } else { + console.error('Failed to save social labels:', response.status); + } + } catch (error) { + console.error('Error saving social labels:', error); + } + + setEditingHolder(null); + setEditForm({ + telegram_username: '', + discord_username: '', + x_username: '', + custom_label: '' + }); + }; + + const handleInputChange = (field: 'telegram_username' | 'discord_username' | 'x_username' | 'custom_label', value: string) => { + setEditForm(prev => ({ ...prev, [field]: value })); + }; + + if (loading) { + return ( +

+ [Loading...] +

+ ); + } + + if (accessDenied) { + return ( +
+

Access Denied

+
+

+ {!wallet + ? "Please connect your wallet to view token holders." + : "You are not the creator of this token. Only token creators can view and manage holder information." + } +

+
+
+ ); + } + + return ( +
+ {/* Header */} +

Holders

+

+ {'//'}Manage holders for ${actualTokenSymbol} ({formatAddress(tokenAddress)}) +

+ +
+ {/* Last sync and Sync button */} +
+ {holderStats.lastSyncTime && ( + + Last sync: {new Date(holderStats.lastSyncTime).toLocaleString()} + + )} + {syncing && ( + Syncing holders... + )} + +
+ + {/* Total Holders and Filter */} +
+
+ Holders: + Total Holders: + {holderStats.totalHolders} +
+
+ Filter: + {'{'} + setSearchQuery(e.target.value)} + autoComplete="off" + className="bg-transparent border-0 focus:outline-none placeholder:text-gray-500 text-[#b2e9fe]" + style={{ + fontFamily: 'Monaco, Menlo, "Courier New", monospace', + width: searchQuery ? `${searchQuery.length}ch` : '15ch' + }} + /> + {'}'} +
+
+
+ +
+ {holders.map((holder, index) => ( +
+
+
+ + #{currentPage * holdersPerPage + index + 1} + + {/* Desktop wallet address */} + + {formatAddress(holder.wallet_address)} + + {/* Mobile wallet address */} + + {formatAddressMobile(holder.wallet_address)} + + {wallet && holder.wallet_address === wallet.toBase58() && ( + + You + + )} + {holder.custom_label && ( + + {holder.custom_label} + + )} +
+
+ {/* Desktop balance */} + + {parseFloat(holder.token_balance).toLocaleString(undefined, { maximumFractionDigits: 2 })} + + {/* Mobile balance */} + + {formatNumberShort(parseFloat(holder.token_balance))} + + + {holder.percentage?.toFixed(2)}% + + +
+
+ + {editingHolder === holder.wallet_address ? ( +
+
+ handleInputChange('telegram_username', e.target.value)} + className="px-2 py-1 bg-gray-900 border border-gray-700 rounded text-[14px] text-white placeholder:text-gray-300 focus:outline-none focus:border-[#b2e9fe]" + style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }} + /> + handleInputChange('discord_username', e.target.value)} + className="px-2 py-1 bg-gray-900 border border-gray-700 rounded text-[14px] text-white placeholder:text-gray-300 focus:outline-none focus:border-[#b2e9fe]" + style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }} + /> + handleInputChange('x_username', e.target.value)} + className="px-2 py-1 bg-gray-900 border border-gray-700 rounded text-[14px] text-white placeholder:text-gray-300 focus:outline-none focus:border-[#b2e9fe]" + style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }} + /> + handleInputChange('custom_label', e.target.value)} + className="px-2 py-1 bg-gray-900 border border-gray-700 rounded text-[14px] text-white placeholder:text-gray-300 focus:outline-none focus:border-[#b2e9fe]" + style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }} + /> +
+
+ + +
+
+ ) : ( + <> + {(holder.telegram_username || holder.discord_username || holder.x_username) && ( +
+ {holder.telegram_username && ( + + TG: {holder.telegram_username} + + )} + {holder.discord_username && ( + + DC: {holder.discord_username} + + )} + {holder.x_username && ( + + X: {holder.x_username} + + )} +
+ )} + + )} +
+ ))} +
+ + {totalPages > 1 && ( +
+ + + Page {currentPage + 1} of {totalPages} + + +
+ )} + + + {holders.length === 0 && ( +

No holders found for this token

+ )} +
+ ); +} diff --git a/ui/components/ImageUpload.tsx b/ui/components/ImageUpload.tsx index 4dc8e47..3a4220e 100644 --- a/ui/components/ImageUpload.tsx +++ b/ui/components/ImageUpload.tsx @@ -4,7 +4,7 @@ import { useState, useRef, DragEvent } from 'react'; import Image from 'next/image'; interface ImageUploadProps { - onImageUpload: (url: string) => void; + onImageUpload: (url: string, filename?: string) => void; currentImage?: string; name?: string; } @@ -13,6 +13,7 @@ export function ImageUpload({ onImageUpload, currentImage, name = 'token' }: Ima const [isDragging, setIsDragging] = useState(false); const [isUploading, setIsUploading] = useState(false); const [uploadedImage, setUploadedImage] = useState(currentImage || ''); + const [filename, setFilename] = useState(''); const fileInputRef = useRef(null); const dragCounter = useRef(0); @@ -170,7 +171,8 @@ export function ImageUpload({ onImageUpload, currentImage, name = 'token' }: Ima } setUploadedImage(data.url); - onImageUpload(data.url); + setFilename(file.name); + onImageUpload(data.url, file.name); // Reset file input if (fileInputRef.current) { @@ -189,70 +191,25 @@ export function ImageUpload({ onImageUpload, currentImage, name = 'token' }: Ima }; return ( -
-
+ + - - - {uploadedImage ? ( -
- Uploaded -
-

Click to replace

-
-
- ) : ( -
- {isUploading ? ( - <> -
-

Uploading...

- - ) : ( - <> - - - -

- {isDragging ? 'Token Image*' : 'Token Image*'} -

- - )} -
- )} -
-
+ {isUploading ? 'Uploading...' : uploadedImage ? filename : 'Click to select'} + + ); } \ No newline at end of file diff --git a/ui/app/launch/page.tsx b/ui/components/LaunchContent.tsx similarity index 50% rename from ui/app/launch/page.tsx rename to ui/components/LaunchContent.tsx index f22fb23..c4a9011 100644 --- a/ui/app/launch/page.tsx +++ b/ui/components/LaunchContent.tsx @@ -3,23 +3,34 @@ import { WalletButton } from '@/components/WalletButton'; import { ImageUpload } from '@/components/ImageUpload'; import { useWallet } from '@/components/WalletProvider'; -import { Navigation } from '@/components/Navigation'; -import { useState, useMemo, useRef } from 'react'; +import { useState, useMemo, useRef, useEffect } from 'react'; import { Keypair, Transaction, Connection } from '@solana/web3.js'; import { useSignTransaction } from '@privy-io/react-auth/solana'; import { useRouter } from 'next/navigation'; import bs58 from 'bs58'; +import { GoInfo, GoPlus } from 'react-icons/go'; -export default function LaunchPage() { +export function LaunchContent() { const { activeWallet, externalWallet } = useWallet(); const { signTransaction } = useSignTransaction(); const router = useRouter(); + // Detect mobile screen size for placeholder text + const [isMobile, setIsMobile] = useState(false); + + useEffect(() => { + const checkMobile = () => setIsMobile(window.innerWidth < 768); + checkMobile(); + window.addEventListener('resize', checkMobile); + return () => window.removeEventListener('resize', checkMobile); + }, []); + const [formData, setFormData] = useState({ name: '', ticker: '', caEnding: '', image: '', + imageFilename: '', website: '', twitter: '', description: '', @@ -406,300 +417,342 @@ export default function LaunchPage() { }; return ( -
-
-
-
-

𝓩 Launch

- -
- {/* Left side - Token Details */} -
- {/* Top row - Image and basic info */} -
-
- setFormData(prev => ({ ...prev, image: url }))} - currentImage={formData.image} - name={formData.name || 'token'} - /> -
+
+

Launch

+ +

{'//'}Launch a ZC token for your project here.

+ +

{'//'}Main token info

+ +
+ {/* Token Image */} +
+ Icon Image*: + {'{'} + setFormData(prev => ({ ...prev, image: url, imageFilename: filename || '' }))} + currentImage={formData.image} + name={formData.name || 'token'} + /> + {'}'} +
-
- - - - - -
-
+
+ Name*: + {'{'} + + {'}'} +
- {/* Website and Twitter */} -
- - - -
+
+ Ticker*: + {'{'} + + {'}'} +
- {/* Description - full width below */} -