- Next Claim: {timeRemaining}
+
+ [Next Claim: {timeRemaining}]
) : (
{claiming
- ? 'Claiming...'
+ ? '[Claiming...]'
: availableToClaim <= 0
- ? 'All Tokens Claimed'
- : `Claim ${amountUserReceives.toLocaleString()}`
+ ? '[All Tokens Claimed]'
+ : isMobile
+ ? `[Claim ${formatNumberShort(amountUserReceives)}]`
+ : `[Claim ${amountUserReceives.toLocaleString()}]`
}
)}
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.
+
+
+ handleSocialLogin('twitter')}
+ className="text-[14px] text-[#b2e9fe] hover:text-[#d0f2ff] transition-colors cursor-pointer text-left"
+ style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }}
+ >
+ [CLICK TO CONNECT X]
+
+
+ handleSocialLogin('github')}
+ className="text-[14px] text-[#b2e9fe] hover:text-[#d0f2ff] transition-colors cursor-pointer text-left"
+ style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }}
+ >
+ [CLICK TO CONNECT GITHUB]
+
+
+
+ >
+ );
+}
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 */}
+
+
+ {/* 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 (
+
+ {/* Logo on left side */}
+
+
+
+
+ {/* Links on right side */}
+
+
+ );
+}
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 && (
+
{
+ 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 && (
+
+ {
+ // Clear local pagination state
+ setTransactionPages([]);
+ setCurrentPage(0);
+ setLastSignature(null);
+ // Revalidate all cached data
+ mutateLaunch();
+ mutateSupply();
+ mutateDesignated();
+ mutateTransactions();
+ }}
+ className="text-[14px] text-gray-300 hover:text-[#b2e9fe] transition-colors cursor-pointer"
+ style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }}
+ >
+ [Refresh]
+
+
+ )}
+
+ {/* 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 */}
+
+
+
{
+ if (hasMemo) {
+ const newExpanded = new Set(expandedTransactions);
+ if (isExpanded) {
+ newExpanded.delete(tx.signature);
+ } else {
+ newExpanded.add(tx.signature);
+ }
+ setExpandedTransactions(newExpanded);
+ }
+ }}
+ className={`text-white ${hasMemo ? 'cursor-pointer hover:opacity-80' : 'cursor-default'} transition-opacity`}
+ aria-label={hasMemo ? (isExpanded ? "Collapse memo" : "Expand memo") : tx.type}
+ disabled={!hasMemo}
+ >
+ {getTypeIcon(tx.type)}
+
+
+
+ {(() => {
+ 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 */}
+
+
+
{
+ if (hasMemo) {
+ const newExpanded = new Set(expandedTransactions);
+ if (isExpanded) {
+ newExpanded.delete(tx.signature);
+ } else {
+ newExpanded.add(tx.signature);
+ }
+ setExpandedTransactions(newExpanded);
+ }
+ }}
+ className={`text-white ${hasMemo ? 'cursor-pointer hover:opacity-80' : 'cursor-default'} transition-opacity`}
+ aria-label={hasMemo ? (isExpanded ? "Collapse memo" : "Expand memo") : tx.type}
+ disabled={!hasMemo}
+ >
+ {getTypeIcon(tx.type)}
+
+
+
+ {(() => {
+ 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 && (
+
+ )}
+
+ );
+ })
+ )}
+
+ )}
+
+ {/* Pagination */}
+ {!loading && (currentPage > 0 || hasMorePages) && (
+
+ navigateToPage(currentPage - 1)}
+ disabled={currentPage === 0 || loadingPage}
+ className={`text-[14px] transition-colors cursor-pointer ${
+ currentPage === 0 || loadingPage
+ ? 'text-gray-300 opacity-50 cursor-not-allowed'
+ : 'text-gray-300 hover:text-[#b2e9fe]'
+ }`}
+ style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }}
+ >
+ {loadingPage ? '[Loading...]' : '[Previous]'}
+
+
+ Page {currentPage + 1}
+
+ navigateToPage(currentPage + 1)}
+ disabled={!hasMorePages || loadingPage}
+ className={`text-[14px] transition-colors cursor-pointer ${
+ !hasMorePages || loadingPage
+ ? 'text-gray-300 opacity-50 cursor-not-allowed'
+ : 'text-gray-300 hover:text-[#b2e9fe]'
+ }`}
+ style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }}
+ >
+ {loadingPage ? '[Loading...]' : '[Next]'}
+
+
+ )}
+
+ );
+}
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...
+ )}
+
+ {syncing ? '[Syncing...]' : '[Sync 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)}%
+
+ handleEditClick(holder)}
+ className="text-[14px] text-gray-300 hover:text-[#b2e9fe] transition-colors cursor-pointer"
+ style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }}
+ >
+ [Labels]
+
+
+
+
+ {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' }}
+ />
+
+
+ handleSaveEdit(holder.wallet_address)}
+ className="text-[14px] text-gray-300 hover:text-[#b2e9fe] transition-colors cursor-pointer"
+ style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }}
+ >
+ [Save]
+
+
+ [Cancel]
+
+
+
+ ) : (
+ <>
+ {(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 && (
+
+ setCurrentPage(Math.max(0, currentPage - 1))}
+ disabled={currentPage === 0}
+ className={`text-[14px] transition-colors cursor-pointer ${
+ currentPage === 0
+ ? 'text-gray-300 cursor-not-allowed opacity-50'
+ : 'text-gray-300 hover:text-[#b2e9fe]'
+ }`}
+ style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }}
+ >
+ [Previous]
+
+
+ Page {currentPage + 1} of {totalPages}
+
+ setCurrentPage(Math.min(totalPages - 1, currentPage + 1))}
+ disabled={currentPage === totalPages - 1}
+ className={`text-[14px] transition-colors cursor-pointer ${
+ currentPage === totalPages - 1
+ ? 'text-gray-300 cursor-not-allowed opacity-50'
+ : 'text-gray-300 hover:text-[#b2e9fe]'
+ }`}
+ style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }}
+ >
+ [Next]
+
+
+ )}
+
+
+ {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 ? (
-
- ) : (
-
- {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
+
+
+
+ Name*:
+ {'{'}
+
+ {'}'}
+
- {/* Website and Twitter */}
-
-
-
-
-
+
+ Ticker*:
+ {'{'}
+
+ {'}'}
+
- {/* Description - full width below */}
-
- {/* Right side - Creator Designation */}
-
-
Give rewards to... (optional, do not fill this out for yourself)
+
+ X URL:
+ {'{'}
+
+ {'}'}
+
+ {/* Description */}
+
+ Description:
+ {'{'}
+ {'}'}
+
+
+
{'//'}Advanced token settings
+ {/* CA Ending */}
+
+ CA Ending:
+ {'{'}
+ {'}'}
+
-
-
-
Quote Token
-
-
- setFormData(prev => ({ ...prev, quoteToken: e.target.value as 'SOL' | 'ZC' }))}
- className="w-5 h-5 cursor-pointer accent-[#b2e9fe]"
- />
- SOL
-
-
- setFormData(prev => ({ ...prev, quoteToken: e.target.value as 'SOL' | 'ZC' }))}
- className="w-5 h-5 cursor-pointer accent-[#b2e9fe]"
- />
- ZC
-
-
+ {/* Token Pairing */}
+
+
+ Token Pairing:
+ {'{'}
+ setFormData(prev => ({ ...prev, quoteToken: prev.quoteToken === 'SOL' ? 'ZC' : 'SOL' }))}
+ className="text-[#b2e9fe] cursor-pointer hover:underline"
+ style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }}
+ >
+ {formData.quoteToken}
+
+ {'}'}
+
+
+
+
+ Click to change your token pairing to either ZC or SOL.
+
+
-
-
setFormData(prev => ({ ...prev, presale: e.target.checked }))}
- className="w-5 h-5 cursor-pointer accent-[#b2e9fe]"
- />
-
- Presale
-
-
-
-
-
-
-
- Make the launch a presale. Only buyers holding the specified tokens will be allowed to buy in the pre-sale round. The size of their buys will be proportional to holdings.
-
-
+ {/* Presale */}
+
+
+ Presale:
+ {'{'}
+ setFormData(prev => ({ ...prev, presale: !prev.presale }))}
+ className="text-[#b2e9fe] cursor-pointer hover:underline"
+ style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }}
+ >
+ {formData.presale ? 'Enabled' : 'Disabled'}
+
+ {'}'}
+
+
+
+
+ Click to make the launch a presale. Only buyers holding the specified tokens will be allowed to buy in the pre-sale round. The size of their buys will be proportional to holdings.
+
- {formData.presale && (
-
-
Token Contract Addresses (optional)
- {formData.presaleTokens.map((token, index) => (
-
+ {/* Presale Whitelist */}
+ {formData.presale && (
+
+ {formData.presaleTokens.map((token, index) => (
+
+
+ {index === 0 ? 'Presale Whitelist CAs: ' : ' '}
+ {'{'}
handlePresaleTokenChange(index, e.target.value)}
- placeholder="Token CA"
+ placeholder={isMobile ? "Enter here" : "Enter token CA here"}
autoComplete="off"
- className={`flex-grow py-2 bg-transparent border-0 border-b focus:outline-none transition-colors text-lg placeholder:text-gray-300 ${
+ className={`bg-transparent border-0 focus:outline-none placeholder:text-gray-500 ${
token && !validateSolanaAddress(token)
- ? 'border-red-500 text-red-400 focus:border-red-500 placeholder:text-red-400'
- : token
- ? 'border-gray-800 text-[#b2e9fe] focus:border-white'
- : 'border-gray-800 text-gray-300 focus:border-white'
+ ? 'text-red-400'
+ : 'text-[#b2e9fe]'
}`}
+ style={{
+ fontFamily: 'Monaco, Menlo, "Courier New", monospace',
+ width: token ? `${token.length}ch` : (isMobile ? '10ch' : '19ch')
+ }}
/>
- {(formData.presaleTokens.length > 1 || (formData.presaleTokens.length === 1 && token.trim())) && (
- handleRemovePresaleToken(index)}
- className="text-gray-400 hover:text-red-400 transition-colors text-xl"
- >
- ×
-
- )}
+ {'}'}
- ))}
- {formData.presaleTokens.length < 5 && (
-
- + Add Token
-
- )}
-
- )}
-
-
-
-
-
-
- {isGeneratingCA && (
-
- Cancel
-
+ {(formData.presaleTokens.length > 1 || (formData.presaleTokens.length === 1 && token.trim())) && (
+ handleRemovePresaleToken(index)}
+ className="text-gray-500 hover:text-red-400 transition-colors text-sm"
+ >
+ ×
+
+ )}
+ {index === formData.presaleTokens.length - 1 && formData.presaleTokens.length < 5 && (
+
+
+
+ )}
+
+ ))}
+
)}
-
- {transactionSignature && (
-
-
- Success!{' '}
-
- Transaction
-
-
+ {/* Creator Designation */}
+
{'//'}Launching for someone else?
+
+
+ Dev X Profile:
+ {'{'}
+
+ {'}'}
- )}
-
+
+ Dev GitHub:
+ {'{'}
+
+ {'}'}
+
+
+
+
+
+ {isGeneratingCA && (
+
+ Cancel
+
+ )}
+
+
+ {transactionSignature && (
+
-
+ )}
);
-}
\ No newline at end of file
+}
diff --git a/ui/components/LineNumbers.tsx b/ui/components/LineNumbers.tsx
new file mode 100644
index 0000000..a5b0533
--- /dev/null
+++ b/ui/components/LineNumbers.tsx
@@ -0,0 +1,38 @@
+'use client';
+
+interface LineNumbersProps {
+ lineCount: number;
+}
+
+export function LineNumbers({ lineCount }: LineNumbersProps) {
+ return (
+
+ {Array.from({ length: lineCount }, (_, i) => (
+
+ {i + 1}
+
+ ))}
+
+ );
+}
diff --git a/ui/components/Navigation.tsx b/ui/components/Navigation.tsx
index 85aa036..005ad0a 100644
--- a/ui/components/Navigation.tsx
+++ b/ui/components/Navigation.tsx
@@ -135,14 +135,6 @@ export function Navigation() {
>
Discord
-
- GitHub
-
diff --git a/ui/components/PresaleBuyModal.tsx b/ui/components/PresaleBuyModal.tsx
index c6e91bc..ea4e82c 100644
--- a/ui/components/PresaleBuyModal.tsx
+++ b/ui/components/PresaleBuyModal.tsx
@@ -3,14 +3,11 @@
import { useState } from 'react';
import { useWallet } from '@/components/WalletProvider';
import { usePrivy } from '@privy-io/react-auth';
-import { InfoTooltip } from '@/components/InfoTooltip';
import { Transaction, PublicKey, Connection } from '@solana/web3.js';
import {
getAssociatedTokenAddress,
createAssociatedTokenAccountInstruction,
- createTransferInstruction,
- TOKEN_PROGRAM_ID,
- ASSOCIATED_TOKEN_PROGRAM_ID
+ createTransferInstruction
} from '@solana/spl-token';
import { useParams } from 'next/navigation';
@@ -281,24 +278,33 @@ export function PresaleBuyModal({ tokenSymbol, status, maxContribution = 10, use
const isUnlimited = maxContribution === Infinity;
return (
-
-
-
Buy
-
-
- Your max contribution: {isUnlimited ? 'Unlimited' : `${maxContribution.toFixed(0)} $ZC`}
-
-
-
- Your contribution: {userContribution.toFixed(0)} $ZC
-
+
+
{'//'}Enter the presale
+
+
+ {/* Info Section */}
+
+
+ Max contribution
+
+ {isUnlimited ? 'Unlimited' : `${maxContribution.toFixed(0)} $ZC`}
+
+
+
+ Your contribution
+
+ {userContribution.toFixed(0)} $ZC
+
-
-
-
Amount ($ZC)
-
+ {/* Amount Input */}
+
+
+ Amount
+
+
+
{!isUnlimited && (
@@ -315,93 +322,109 @@ export function PresaleBuyModal({ tokenSymbol, status, maxContribution = 10, use
type="button"
onClick={() => setPercentage(100)}
disabled={isDisabled}
- className="absolute right-2 top-2 text-lg text-gray-300 hover:text-white transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-xs font-semibold text-[#F7FCFE] bg-[#1E1E1E] hover:bg-[#141414] px-2 py-1 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }}
tabIndex={-1}
>
MAX
)}
-
- {errors.amount && (
-
- {errors.amount}
-
- )}
+
+ $ZC
+ {errors.amount && (
+
+ {errors.amount}
+
+ )}
+
+ {/* Quick Select Buttons */}
+
{isUnlimited ? (
-
+ <>
setFixedAmount(0.1)}
disabled={isDisabled}
- className="py-2 text-lg text-gray-300 hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ className="bg-[#2B2B2A] hover:bg-[#333333] rounded-lg px-3 py-2 text-sm font-semibold transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }}
>
0.1
setFixedAmount(0.2)}
disabled={isDisabled}
- className="py-2 text-lg text-gray-300 hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ className="bg-[#2B2B2A] hover:bg-[#333333] rounded-lg px-3 py-2 text-sm font-semibold transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }}
>
0.2
setFixedAmount(0.5)}
disabled={isDisabled}
- className="py-2 text-lg text-gray-300 hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ className="bg-[#2B2B2A] hover:bg-[#333333] rounded-lg px-3 py-2 text-sm font-semibold transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }}
>
0.5
setFixedAmount(1)}
disabled={isDisabled}
- className="py-2 text-lg text-gray-300 hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ className="bg-[#2B2B2A] hover:bg-[#333333] rounded-lg px-3 py-2 text-sm font-semibold transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }}
>
1
-
+ >
) : (
-
+ <>
setPercentage(10)}
disabled={isDisabled}
- className="py-2 text-lg text-gray-300 hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ className="bg-[#2B2B2A] hover:bg-[#333333] rounded-lg px-3 py-2 text-sm font-semibold transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }}
>
10%
setPercentage(25)}
disabled={isDisabled}
- className="py-2 text-lg text-gray-300 hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ className="bg-[#2B2B2A] hover:bg-[#333333] rounded-lg px-3 py-2 text-sm font-semibold transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }}
>
25%
setPercentage(50)}
disabled={isDisabled}
- className="py-2 text-lg text-gray-300 hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ className="bg-[#2B2B2A] hover:bg-[#333333] rounded-lg px-3 py-2 text-sm font-semibold transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }}
>
50%
setPercentage(75)}
disabled={isDisabled}
- className="py-2 text-lg text-gray-300 hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ className="bg-[#2B2B2A] hover:bg-[#333333] rounded-lg px-3 py-2 text-sm font-semibold transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }}
>
75%
-
+ >
)}
+
-
- {connecting ? 'Connecting...' : isContributing ? 'Processing...' : !wallet ? 'Connect Wallet' : status !== 'pending' ? 'Presale Closed' : 'Buy'}
-
+ {/* Buy Button */}
+
+ {connecting ? 'Connecting...' : isContributing ? 'Processing...' : !wallet ? 'Connect Wallet' : status !== 'pending' ? 'Presale Closed' : 'Buy'}
+
);
diff --git a/ui/app/presale/[tokenAddress]/page.tsx b/ui/components/PresaleContent.tsx
similarity index 52%
rename from ui/app/presale/[tokenAddress]/page.tsx
rename to ui/components/PresaleContent.tsx
index 1945dc4..76bdbcb 100644
--- a/ui/app/presale/[tokenAddress]/page.tsx
+++ b/ui/components/PresaleContent.tsx
@@ -1,15 +1,12 @@
'use client';
-import { Navigation } from '@/components/Navigation';
-import { TokenCard } from '@/components/TokenCard';
import { PresaleBuyModal } from '@/components/PresaleBuyModal';
import { VestingModal } from '@/components/VestingModal';
-import { InfoTooltip } from '@/components/InfoTooltip';
-import { useParams, useRouter } from 'next/navigation';
+import { useParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useWallet } from '@/components/WalletProvider';
import { usePrivy } from '@privy-io/react-auth';
-import { Transaction, Connection } from '@solana/web3.js';
+import { Transaction } from '@solana/web3.js';
import { useSignTransaction } from '@privy-io/react-auth/solana';
import bs58 from 'bs58';
@@ -63,12 +60,10 @@ interface BidsData {
contributions: Contribution[];
}
-export default function PresalePage() {
+export function PresaleContent() {
const params = useParams();
- const router = useRouter();
const tokenAddress = params.tokenAddress as string;
const { wallet, externalWallet, activeWallet } = useWallet();
- const { login, authenticated, linkWallet } = usePrivy();
const { signTransaction } = useSignTransaction();
const [presale, setPresale] = useState
(null);
const [metadata, setMetadata] = useState(null);
@@ -219,7 +214,6 @@ export default function PresalePage() {
// If presale has launched, you might want to trigger additional actions
if (presaleData.status === 'launched' && presale?.status === 'pending') {
console.log('Presale has launched!');
- // Could trigger a notification or redirect here if needed
}
}
} catch (err) {
@@ -302,7 +296,6 @@ export default function PresalePage() {
}
};
-
const handleLaunch = async () => {
if (!wallet || !externalWallet || !activeWallet || !presale) {
console.error('Wallet or presale not available');
@@ -382,9 +375,6 @@ export default function PresalePage() {
swapSignature: null // Combined into single transaction now
});
- // Optionally redirect to the token page
- // router.push(`/history/${tokenAddress}`);
-
} catch (error) {
console.error('Launch error:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
@@ -398,228 +388,256 @@ export default function PresalePage() {
return `${address.slice(0, 4)}....${address.slice(-4)}`;
};
+ const formatWalletAddressMobile = (address: string) => {
+ return `${address.slice(0, 6)}...${address.slice(-6)}`;
+ };
+
const copyToClipboard = (address: string) => {
navigator.clipboard.writeText(address);
setCopiedAddress(address);
setTimeout(() => setCopiedAddress(null), 2000);
};
+ if (loading) {
+ return (
+
+
Presale
+
Loading presale...
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
Presale
+
Error: {error}
+
+ );
+ }
+
+ if (!presale) {
+ return (
+
+
Presale
+
Presale not found
+
+ );
+ }
+
return (
-
-
-
-
-
-
𝓩 Presale
- {isCreator && presale?.status === 'pending' && (
-
+
+
Presale
+ {isCreator && presale.status === 'pending' && (
+
+ {isLaunching ? '[LAUNCHING...]' : '[LAUNCH]'}
+
+ )}
+
+
+ {launchError && (
+
+ Launch Error: {launchError}
+
+ )}
+
+ {launchSuccess && (
+
+ Success! Presale launched.{' '}
+
+ View Transaction
+
+
+ )}
+
+ {'//'}${presale.token_symbol} presale
+
+ {/* Token Info - Desktop */}
+
+ {metadata?.image && (
+
+ )}
+
+
+ ${presale.token_symbol} {presale.token_name} Status: {presale.status.toUpperCase()}
+
+ {metadata?.description && (
+
+ {metadata.description}
+
+ )}
+
+
+
+ {/* Token Info - Mobile */}
+
+ {metadata?.image && (
+
+ )}
+
+
+ ${presale.token_symbol} {presale.token_name}
+
+
+ Status: {presale.status.toUpperCase()}
+
+ {metadata?.description && (
+
+ {metadata.description}
+
+ )}
+
+
+
+ {/* Presale Requirements Section */}
+
+
{'//'}Presale requirements
+ {presale.presale_tokens && presale.presale_tokens.length > 0 ? (
+ <>
+
+ Only holders of the following tokens at the time of snapshot are allowed to participate:
+
+
+ {presale.presale_tokens.map((token, index) => (
+
- {isLaunching ? 'LAUNCHING...' : 'LAUNCH'}
-
- )}
+ {token}
+ {formatWalletAddressMobile(token)}
+
+ ))}
-
- {loading && (
-
Loading presale...
- )}
-
- {error && (
-
Error: {error}
- )}
-
- {launchError && (
-
Launch Error: {launchError}
- )}
-
- {launchSuccess && (
-
-
- Success! Presale launched.{' '}
-
+
+ [View Docs]
+
+
+ >
+ ) : (
+
+ No token requirements - this presale is open to everyone!
+
+ )}
+
+
+ {/* Buy or Vesting Section */}
+
+ {presale.status === 'launched' ? (
+
{
+ // Refresh vesting info after successful claim
+ if (wallet) {
+ try {
+ const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
+ const response = await fetch(
+ `${apiUrl}/presale/${tokenAddress}/claims/${wallet.toBase58()}`
+ );
+ if (response.ok) {
+ const data = await response.json();
+ setVestingInfo(data);
+ }
+ } catch (err) {
+ console.error('Error refreshing vesting info:', err);
+ }
+ }
+ }}
+ />
+ ) : (
+
+ )}
+
+
+ {/* Total Raised Section */}
+
+
+ {'//'}Total raised
+
+
+ {bidsData ? bidsData.totalRaised.toFixed(0) : '0'} $ZC{' '}
+ {presale.escrow_pub_key && (
+
+ [View Escrow]
+
+ )}
+
+
+
+ {/* All Contributions Section */}
+
+
{'//'}All contributions
+ {bidsData && bidsData.contributions.length > 0 ? (
+
+
+ {bidsData.contributions.map((contribution) => (
+
+ copyToClipboard(contribution.wallet)}
+ className="text-[14px] text-gray-300 hover:text-[#b2e9fe] transition-colors"
>
- View Transaction
-
-
-
- )}
-
- {presale && (
-
- {/* Token Card */}
-
-
- {/* Presale Tokens & Buy Section */}
-
-
-
Presale Token Requirements
- {presale.presale_tokens && presale.presale_tokens.length > 0 ? (
+ {copiedAddress === contribution.wallet ? '✓ Copied' : (
<>
-
- Only holders of the following tokens at the time of snapshot are allowed to participate in the presale:
-
-
- {presale.presale_tokens.map((token, index) => (
-
- {token}
-
- ))}
-
- {/* Docs Link */}
-
-
+
{formatWalletAddress(contribution.wallet)}
+
{formatWalletAddressMobile(contribution.wallet)}
>
- ) : (
-
- No token requirements - this presale is open to everyone!
-
- )}
-
-
- {presale.status === 'launched' ? (
-
{
- // Refresh vesting info after successful claim
- if (wallet) {
- try {
- const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
- const response = await fetch(
- `${apiUrl}/presale/${tokenAddress}/claims/${wallet.toBase58()}`
- );
- if (response.ok) {
- const data = await response.json();
- setVestingInfo(data);
- }
- } catch (err) {
- console.error('Error refreshing vesting info:', err);
- }
- }
- }}
- />
- ) : (
-
- )}
-
-
- {/* Total Raised Section */}
-
-
-
-
-
Total Raised
- {presale?.escrow_pub_key && (
-
-
-
-
-
-
-
- )}
-
-
-
- {bidsData ? bidsData.totalRaised.toFixed(0) : '0'} $ZC
-
-
-
-
- {/* All Contributions Section */}
-
-
All Contributions
- {bidsData && bidsData.contributions.length > 0 ? (
-
-
- {bidsData.contributions.map((contribution, index) => (
-
-
-
copyToClipboard(contribution.wallet)}
- className={`transition-colors ${
- copiedAddress === contribution.wallet ? 'text-green-500' : 'text-gray-300 hover:text-white'
- }`}
- >
- {copiedAddress === contribution.wallet ? (
-
-
-
- ) : (
-
-
-
-
- )}
-
-
{formatWalletAddress(contribution.wallet)}
-
-
{contribution.amount.toFixed(0)} $ZC
-
- ))}
-
-
- ) : (
-
No contributions yet
)}
-
+
+
{contribution.amount.toFixed(0)} $ZC
-
- )}
-
-
+ ))}
+
-
-
+ ) : (
+
+ No contributions yet
+
+ )}
+
);
}
diff --git a/ui/components/Sidebar.tsx b/ui/components/Sidebar.tsx
new file mode 100644
index 0000000..727e9b2
--- /dev/null
+++ b/ui/components/Sidebar.tsx
@@ -0,0 +1,18 @@
+'use client';
+
+import { ActivityBar } from './ActivityBar';
+import { FileExplorer } from './FileExplorer';
+
+export function Sidebar() {
+ return (
+
+ );
+}
diff --git a/ui/app/stake/page.tsx b/ui/components/StakeContent.tsx
similarity index 51%
rename from ui/app/stake/page.tsx
rename to ui/components/StakeContent.tsx
index 50aa370..5e0b44b 100644
--- a/ui/app/stake/page.tsx
+++ b/ui/components/StakeContent.tsx
@@ -5,12 +5,10 @@ import { PublicKey, Connection, Transaction } from "@solana/web3.js";
import { getAssociatedTokenAddress, getAccount, createAssociatedTokenAccountInstruction, TOKEN_PROGRAM_ID } from "@solana/spl-token";
import { Program, AnchorProvider, BN } from "@coral-xyz/anchor";
import { useWallet } from '@/components/WalletProvider';
-import { Navigation } from '@/components/Navigation';
import { showToast } from '@/components/Toast';
import VaultIDL from '@/lib/vault-idl.json';
import { usePrivy } from '@privy-io/react-auth';
-// ZC Token address - using the one from your codebase
const ZC_TOKEN_MINT = new PublicKey("GVvPZpC6ymCoiHzYJ7CWZ8LhVn9tL2AUpRjSAsLh6jZC");
const PROGRAM_ID = new PublicKey("6CETAFdgoMZgNHCcjnnQLN2pu5pJgUz8QQd7JzcynHmD");
@@ -23,20 +21,16 @@ interface WindowWithWallets extends Window {
solflare?: SolanaWalletProvider;
}
-
-export default function StakePage() {
+export function StakeContent() {
const { wallet, isPrivyAuthenticated } = useWallet();
const { login, authenticated, linkWallet } = usePrivy();
const [loading, setLoading] = useState(false);
- const [modalMode, setModalMode] = useState<"deposit" | "redeem">("deposit"); // Keep internal state as deposit for consistency with function names
+ const [modalMode, setModalMode] = useState<"deposit" | "redeem">("deposit");
const [amount, setAmount] = useState
("");
const [redeemPercent, setRedeemPercent] = useState("");
- // ZC token balance
const [zcBalance, setZcBalance] = useState(0);
-
- // Vault state
const [vaultBalance, setVaultBalance] = useState(0);
const [userShareBalance, setUserShareBalance] = useState(0);
const [userShareValue, setUserShareValue] = useState(0);
@@ -47,7 +41,6 @@ export default function StakePage() {
const [withdrawalsEnabled, setWithdrawalsEnabled] = useState(true);
const [copiedWallet, setCopiedWallet] = useState(false);
-
const connection = useMemo(() => new Connection(process.env.NEXT_PUBLIC_RPC_URL || "https://api.mainnet-beta.solana.com"), []);
const getProvider = useCallback(() => {
@@ -77,17 +70,14 @@ export default function StakePage() {
const program = useMemo(() => getProgram(), [getProgram]);
- // Calculate APY based on vault metrics
const calculateAPY = useCallback((): number => {
if (vaultBalance === 0) return 0;
- // Simple APY calculation - you can adjust this based on your reward mechanism
- const REWARD_TOKENS = 15000000; // 15M tokens
+ const REWARD_TOKENS = 15000000;
const rewardPerToken = REWARD_TOKENS / vaultBalance;
const compoundingPeriodsPerYear = 52;
return 100 * (Math.pow(1 + rewardPerToken, compoundingPeriodsPerYear) - 1);
}, [vaultBalance]);
- // Fetch ZC token balance and total supply
const fetchZcBalance = useCallback(async () => {
if (!wallet) {
setZcBalance(0);
@@ -95,13 +85,11 @@ export default function StakePage() {
}
try {
- // Get user balance
const userTokenAccount = await getAssociatedTokenAddress(ZC_TOKEN_MINT, wallet);
const userTokenAccountInfo = await getAccount(connection, userTokenAccount);
const balance = Number(userTokenAccountInfo.amount) / 1_000_000;
setZcBalance(balance);
- // Get total ZC supply
const mintInfo = await connection.getParsedAccountInfo(ZC_TOKEN_MINT);
if (mintInfo.value?.data && 'parsed' in mintInfo.value.data) {
const supply = Number(mintInfo.value.data.parsed.info.supply) / 1_000_000;
@@ -121,7 +109,6 @@ export default function StakePage() {
return;
}
- // Derive PDAs
const [vaultState] = PublicKey.findProgramAddressSync(
[Buffer.from("vault_state")],
PROGRAM_ID
@@ -135,15 +122,12 @@ export default function StakePage() {
PROGRAM_ID
);
- // Fetch vault state using program account deserializer
try {
const vaultStateAccountInfo = await connection.getAccountInfo(vaultState);
if (vaultStateAccountInfo && vaultStateAccountInfo.data) {
const vaultStateAccount = program.coder.accounts.decode("vaultState", vaultStateAccountInfo.data);
setWithdrawalsEnabled(vaultStateAccount.operationsEnabled);
- console.log("Vault operations enabled:", vaultStateAccount.operationsEnabled);
} else {
- console.log("VaultState account not found");
setWithdrawalsEnabled(false);
}
} catch (error) {
@@ -151,9 +135,7 @@ export default function StakePage() {
setWithdrawalsEnabled(false);
}
- // Use program methods for data fetching
try {
- // Get total assets using program method
const totalAssets = await program.methods
.totalAssets()
.accounts({
@@ -162,15 +144,13 @@ export default function StakePage() {
})
.view();
setVaultBalance(Number(totalAssets) / 1_000_000);
-
} catch (error) {
console.error("Failed to fetch vault metrics:", error);
setVaultBalance(0);
}
- // Calculate exchange rate (1 sZC = ? ZC)
try {
- const oneShare = new BN(1_000_000); // 1 sZC with 6 decimals
+ const oneShare = new BN(1_000_000);
const assetsForOneShare = await program.methods
.previewRedeem(oneShare)
.accounts({
@@ -182,17 +162,15 @@ export default function StakePage() {
setExchangeRate(Number(assetsForOneShare) / 1_000_000);
} catch (error) {
console.error("Failed to fetch exchange rate:", error);
- setExchangeRate(1); // Default to 1:1 if calculation fails
+ setExchangeRate(1);
}
- // Fetch user share balance
try {
const userShareAccount = await getAssociatedTokenAddress(shareMint, wallet);
const userShareAccountInfo = await getAccount(connection, userShareAccount);
const shareBalance = Number(userShareAccountInfo.amount) / 1_000_000;
setUserShareBalance(shareBalance);
- // Use preview redeem to get exact value
if (shareBalance > 0) {
const assets = await program.methods
.previewRedeem(new BN(userShareAccountInfo.amount.toString()))
@@ -209,7 +187,6 @@ export default function StakePage() {
} catch {
console.log("User share account not found");
if (retryCount < maxRetries) {
- console.log(`Retrying fetchVaultData (${retryCount + 1}/${maxRetries})`);
const delay = Math.pow(2, retryCount) * 1000;
setTimeout(() => {
fetchVaultData(retryCount + 1, maxRetries);
@@ -226,7 +203,6 @@ export default function StakePage() {
}
}, [wallet, connection, program]);
-
useEffect(() => {
if (wallet) {
fetchZcBalance();
@@ -253,7 +229,6 @@ export default function StakePage() {
const depositAmountBN = new BN(depositAmount * 1_000_000);
- // Derive PDAs
const [vaultState] = PublicKey.findProgramAddressSync(
[Buffer.from("vault_state")],
PROGRAM_ID
@@ -271,16 +246,13 @@ export default function StakePage() {
PROGRAM_ID
);
- // Get user token accounts
const senderTokenAccount = await getAssociatedTokenAddress(ZC_TOKEN_MINT, wallet);
const senderShareAccount = await getAssociatedTokenAddress(shareMint, wallet);
- // Check if share account exists, create if not
const transaction = new Transaction();
try {
await getAccount(connection, senderShareAccount);
} catch {
- console.log("Creating share token account");
const createATAIx = createAssociatedTokenAccountInstruction(
wallet,
senderShareAccount,
@@ -291,7 +263,6 @@ export default function StakePage() {
transaction.add(createATAIx);
}
- // Add deposit instruction
const depositIx = await program.methods
.deposit(depositAmountBN)
.accounts({
@@ -309,7 +280,6 @@ export default function StakePage() {
transaction.add(depositIx);
- // Send transaction
const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash();
transaction.recentBlockhash = blockhash;
transaction.feePayer = wallet;
@@ -324,7 +294,6 @@ export default function StakePage() {
showToast('success', `Staked ${depositAmount} ZC to the vault`);
setAmount("");
- // Wait for blockchain state to propagate, then refresh
setPostTransactionRefreshing(true);
setTimeout(async () => {
await Promise.all([fetchVaultData(), fetchZcBalance()]);
@@ -355,7 +324,6 @@ export default function StakePage() {
setLoading(true);
if (!program) throw new Error("Program not available");
- // Calculate shares to redeem
const [shareMint] = PublicKey.findProgramAddressSync(
[Buffer.from("share_mint")],
PROGRAM_ID
@@ -365,7 +333,6 @@ export default function StakePage() {
const totalShares = userShareAccountInfo.amount;
const sharesToRedeem = (totalShares * BigInt(Math.floor(redeemPercentNum * 100))) / BigInt(10000);
- // Derive PDAs
const [vaultState] = PublicKey.findProgramAddressSync(
[Buffer.from("vault_state")],
PROGRAM_ID
@@ -382,14 +349,11 @@ export default function StakePage() {
const senderTokenAccount = await getAssociatedTokenAddress(ZC_TOKEN_MINT, wallet);
const senderShareAccount = userShareAccount;
- // Build redeem transaction
const transaction = new Transaction();
- // Check if the user's token account exists, create if not
try {
await getAccount(connection, senderTokenAccount);
} catch {
- console.log("Creating user token account for redemption");
const createATAIx = createAssociatedTokenAccountInstruction(
wallet,
senderTokenAccount,
@@ -400,7 +364,6 @@ export default function StakePage() {
transaction.add(createATAIx);
}
- // Add redeem instruction
const redeemIx = await program.methods
.redeem(new BN(sharesToRedeem.toString()))
.accounts({
@@ -418,7 +381,6 @@ export default function StakePage() {
transaction.add(redeemIx);
- // Send transaction
const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash();
transaction.recentBlockhash = blockhash;
transaction.feePayer = wallet;
@@ -433,7 +395,6 @@ export default function StakePage() {
showToast('success', `Redeemed ${redeemPercentNum}% of your vault shares for ZC`);
setRedeemPercent("");
- // Wait for blockchain state to propagate, then refresh
setPostTransactionRefreshing(true);
setTimeout(async () => {
await Promise.all([fetchVaultData(), fetchZcBalance()]);
@@ -482,303 +443,279 @@ export default function StakePage() {
}
};
-
return (
-
-
-
-
-
𝓩 Stake
-
-
- {/* Left Panel - Wallet */}
-
- {/* Vault Description */}
-
-
- Stake to earn yield and be rewarded more for your contributions. Staking for other platform launches will be live soon. Once you stake, funds are locked . The next unlock will be November 7th. Staking earlier in each period leads to higher rewards.
-
-
-
- {/* Wallet Section */}
-
-
Wallet
+
+
Stake
+
+
+ {/* Wallet Section */}
+
+ {/* Vault Description */}
+
+
{'//'}Stake to earn yield and get rewarded more for your contributions
+
{'//'}Staking for other ZC launches will be live soon
+
Once you stake, funds are locked . The next unlock will be Nov 7th.
+
Staking earlier in each period leads to higher rewards.
+
- {!isPrivyAuthenticated ? (
-
-
- CONNECT WALLET
-
-
- ) : !wallet ? (
+ {/* Wallet Section */}
+
+ {!isPrivyAuthenticated ? (
+
- CONNECT WALLET
+ [CLICK TO CONNECT WALLET]
- ) : (
-
- {/* Wallet Address Section */}
-
-
{formatAddress(wallet.toString())}
-
-
copyAddress(wallet.toString())}
- className="flex items-center gap-1 hover:opacity-80 transition-opacity cursor-pointer"
- title="Copy wallet address"
- >
- {copiedWallet ? (
-
-
-
- ) : (
-
-
-
- )}
-
-
-
-
-
-
-
+
+ ) : !wallet ? (
+
+ [CLICK TO CONNECT WALLET]
+
+ ) : (
+
+ {/* Vault Stats */}
+
+
{'//'}ZC staked vaults stats
+
+
+ {calculateAPY().toFixed(0)}% APY Yield
+
+
+ {formatCompactNumber(vaultBalance)} TVL
+
- {/* Balance Section */}
-
-
-
Your Position
- {postTransactionRefreshing && (
-
+ {/* Your Position */}
+
+
{'//'}Your staked and unstaked ZC positions
+
+
+
{formatAddress(wallet.toString())}
+
copyAddress(wallet.toString())}
+ className="flex items-center gap-1 hover:opacity-80 transition-opacity cursor-pointer"
+ title="Copy wallet address"
+ >
+ {copiedWallet ? (
+
+
+
+ ) : (
+
+
+
)}
+
+
+
+
+
+
+
+
+ {postTransactionRefreshing && (
+
-
-
Held:
-
-
- {zcBalance.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
- {zcTotalSupply > 0 && (
-
- ({((zcBalance / zcTotalSupply) * 100).toFixed(3)}%)
-
- )}
-
-
+ )}
+
+
+
Held:
+
+ {zcBalance.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+ {zcTotalSupply > 0 && (
+
+ ({((zcBalance / zcTotalSupply) * 100).toFixed(3)}%)
+
+ )}
-
-
Staked:
-
-
- {userShareValue.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
- {zcTotalSupply > 0 && (
-
- ({((userShareValue / zcTotalSupply) * 100).toFixed(3)}%)
-
- )}
-
-
+
+
+
Staked:
+
+ {userShareValue.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+ {zcTotalSupply > 0 && (
+
+ ({((userShareValue / zcTotalSupply) * 100).toFixed(3)}%)
+
+ )}
-
-
Exchange Rate:
-
-
- 1 sZC : {exchangeRate.toLocaleString('en-US', { minimumFractionDigits: 6, maximumFractionDigits: 6 })} ZC
-
-
+
+
+
Exchange Rate:
+
+ 1 sZC : {exchangeRate.toLocaleString('en-US', { minimumFractionDigits: 6, maximumFractionDigits: 6 })} ZC
- {/* Refresh Button */}
Promise.all([fetchVaultData(), fetchZcBalance()])}
disabled={refreshing || postTransactionRefreshing}
- className="w-full py-2 text-lg text-gray-300 hover:text-white transition-colors cursor-pointer disabled:opacity-50"
+ className="text-[14px] text-gray-300 hover:text-[#b2e9fe] transition-colors cursor-pointer disabled:opacity-50"
+ style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }}
>
-
- {refreshing || postTransactionRefreshing ? (
- postTransactionRefreshing ? "Updating balances..." : "Refreshing..."
- ) : (
- <>
-
-
-
- Refresh
- >
- )}
-
+ {refreshing || postTransactionRefreshing ? (
+ postTransactionRefreshing ? '[Updating balances...]' : '[Refreshing...]'
+ ) : (
+ '[Refresh]'
+ )}
- )}
-
-
- {/* Right Panel - Vault Operations */}
- {wallet && (
-
-
-
-
Vault
-
-
-
{calculateAPY().toFixed(0)}% APY
-
Yield
-
-
-
- {formatCompactNumber(vaultBalance)}
- {zcTotalSupply > 0 && (
-
- ({((vaultBalance / zcTotalSupply) * 100).toFixed(1)}%)
-
- )}
-
-
TVL
+ {/* Vault Operations */}
+
+
+
{'//'}Stake your ZC and redeem your staked ZC below
+
+ setModalMode("deposit")}
+ className={`text-[14px] transition-colors cursor-pointer ${
+ modalMode === "deposit" ? "text-[#b2e9fe]" : "text-gray-300 hover:text-[#b2e9fe]"
+ }`}
+ style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }}
+ >
+ [Stake]
+
+ setModalMode("redeem")}
+ className={`text-[14px] transition-colors cursor-pointer ${
+ modalMode === "redeem" ? "text-[#b2e9fe]" : "text-gray-300 hover:text-[#b2e9fe]"
+ }`}
+ style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }}
+ >
+ [Redeem]
+
-
-
-
- {/* Tabs */}
-
-
- setModalMode("deposit")}
- className={`pb-2 text-xl transition-colors cursor-pointer ${
- modalMode === "deposit" ? "text-white border-b-2 border-white" : "text-gray-300 hover:text-white"
- }`}
- >
- Stake
-
- setModalMode("redeem")}
- className={`pb-2 text-xl transition-colors cursor-pointer ${
- modalMode === "redeem" ? "text-white border-b-2 border-white" : "text-gray-300 hover:text-white"
- }`}
- >
- Redeem
-
-
+ {modalMode === "deposit" && (
+
+
+
+ Amount
+
+ Available: {zcBalance.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ZC
+
+
+
+
+ {
+ const value = e.target.value;
+ if (value === "" || /^\d*\.?\d*$/.test(value)) {
+ setAmount(value);
+ }
+ }}
+ 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={false}
+ autoComplete="off"
+ />
+ {
+ if (zcBalance) {
+ setAmount(zcBalance.toString());
+ }
+ }}
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-xs font-semibold text-[#F7FCFE] bg-[#1E1E1E] hover:bg-[#141414] px-2 py-1 rounded transition-colors"
+ style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }}
+ tabIndex={-1}
+ >
+ MAX
+
+
+
+
- {modalMode === "deposit" && (
-
-
-
Amount
-
- {
- const value = e.target.value;
- if (value === "" || /^\d*\.?\d*$/.test(value)) {
- setAmount(value);
- }
- }}
- className="w-full py-3 bg-transparent border-0 border-b border-gray-800 focus:outline-none focus:border-white transition-colors text-xl placeholder:text-gray-300"
- disabled={false}
- autoComplete="off"
- />
{
- if (zcBalance) {
- setAmount(zcBalance.toString());
- }
- }}
- className="absolute right-2 top-2 text-lg text-gray-300 hover:text-white transition-colors cursor-pointer"
- tabIndex={-1}
+ onClick={handleDeposit}
+ className="w-full py-3 text-[14px] font-bold bg-white text-black hover:bg-gray-200 rounded-xl transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }}
+ disabled={loading || !amount || parseFloat(amount) <= 0}
>
- MAX
+ {loading ? "Processing..." : "Stake"}
-
- Available to stake: {zcBalance.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ZC
-
-
-
-
- {loading ? "Processing..." : "Stake"}
-
-
- )}
-
- {modalMode === "redeem" && (
-
-
-
Percentage of Shares to Redeem
-
- {
- const value = e.target.value;
- if (value === "" || (/^\d*\.?\d*$/.test(value) && parseFloat(value) <= 100)) {
- setRedeemPercent(value);
- }
- }}
- className="w-full py-3 bg-transparent border-0 border-b border-gray-800 focus:outline-none focus:border-white transition-colors text-xl placeholder:text-gray-300"
- disabled={!withdrawalsEnabled}
- autoComplete="off"
- />
- %
-
-
- Available: {userShareBalance.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} shares ({userShareValue.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ZC)
-
-
+ )}
- {parseFloat(redeemPercent) > 0 && (
-
-
-
You will receive:
-
-
- {((userShareValue * parseFloat(redeemPercent)) / 100).toLocaleString('en-US', { minimumFractionDigits: 4, maximumFractionDigits: 4 })} ZC
+ {modalMode === "redeem" && (
+
+
+
+ Percentage to Redeem
+
+ Available: {userShareBalance.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} shares ({userShareValue.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ZC)
+
+
+
+
+ {parseFloat(redeemPercent) > 0 && (
+
+
+ You will receive
+
+ {((userShareValue * parseFloat(redeemPercent)) / 100).toLocaleString('en-US', { minimumFractionDigits: 4, maximumFractionDigits: 4 })} ZC
+
+
+
+ )}
+
+
+ {loading ? "Processing..." : !withdrawalsEnabled ? "Redemptions Disabled" : userShareBalance === 0 ? "No Shares to Redeem" : "Redeem"}
+
)}
-
-
- {loading ? "Processing..." : !withdrawalsEnabled ? "Redemptions Disabled" : userShareBalance === 0 ? "No Shares to Redeem" : "Redeem"}
-
- )}
+
-
- )}
-
-
-
+ )}
-
+
);
-}
\ No newline at end of file
+}
diff --git a/ui/components/SwapContent.tsx b/ui/components/SwapContent.tsx
new file mode 100644
index 0000000..9d3615f
--- /dev/null
+++ b/ui/components/SwapContent.tsx
@@ -0,0 +1,795 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { useWallet } from '@/components/WalletProvider';
+import { usePrivy } from '@privy-io/react-auth';
+import { Connection, PublicKey, Transaction, LAMPORTS_PER_SOL } from '@solana/web3.js';
+import { getAccount, getAssociatedTokenAddress } from '@solana/spl-token';
+import { showToast } from '@/components/Toast';
+
+// Import refactored services
+import { getQuote } from '@/app/(vscode)/swap/services/quoteService';
+import { executeSwap } from '@/app/(vscode)/swap/services/swapService';
+
+const RPC_URL = process.env.NEXT_PUBLIC_RPC_URL || 'https://api.mainnet-beta.solana.com';
+
+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 function SwapContent() {
+ 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';
+ if (from === 'TEST' && to === 'SOL') return 'double';
+ if (from === 'SOL' && to === 'SHIRTLESS') return 'double';
+ if (from === 'SHIRTLESS' && to === 'SOL') return 'double';
+ if (from === 'ZC' && to === 'GITPOST') return 'double';
+ if (from === 'GITPOST' && to === 'ZC') return 'double';
+ if (from === 'SOL' && to === 'PERC') return 'double';
+ if (from === 'PERC' && to === 'SOL') return 'double';
+
+ // Triple swaps (3 hops)
+ if (from === 'TEST' && to === 'SHIRTLESS') return 'triple';
+ if (from === 'SHIRTLESS' && to === 'TEST') return 'triple';
+ if (from === 'TEST' && to === 'GITPOST') return 'triple';
+ if (from === 'GITPOST' && to === 'TEST') return 'triple';
+ if (from === 'SOL' && to === 'GITPOST') return 'triple';
+ if (from === 'GITPOST' && to === 'SOL') return 'triple';
+
+ return 'invalid';
+ };
+
+ 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
+
{'//'}Swap ZC tokens
+
Balances refresh 10 seconds after swap. Gas fees apply.
+
+
+ {/* Wallet Info */}
+ {isPrivyAuthenticated && wallet && (
+
+
+
+
Connected Wallet
+
+ {wallet.toBase58().slice(0, 4)}...{wallet.toBase58().slice(-4)}
+ {copiedWallet ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+ {refreshingBalancesAfterSwap && (
+
+
+
+
+ )}
+
+ {isLoadingBalances && (
+
+
+
+
+
+
Refreshing...
+
+ )}
+
+
+ {(['SOL', 'ZC', 'SHIRTLESS', 'GITPOST', 'PERC'] as Token[]).map((token) => (
+
+ {getTokenIcon(token).startsWith('/') ? (
+
+ ) : (
+
+ {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';
+ return parseFloat(balance.toFixed(4)).toString();
+ })()}
+
+
{getTokenSymbol(token)}
+
+
+ ))}
+
+
+ )}
+
+ {/* Swap Container */}
+
+ {/* From Token */}
+
+
+
You pay
+
+
+ Bal:
+ Balance:
+
+ {getTokenIcon(fromToken).startsWith('/') ? (
+
+ ) : (
+
+ {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"
+ style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }}
+ step="any"
+ />
+ {
+ setAmount(balances[fromToken]);
+ setIsMaxAmount(true);
+ }}
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-xs font-semibold text-[#F7FCFE] bg-[#1E1E1E] hover:bg-[#141414] px-2 py-1 rounded transition-colors"
+ style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }}
+ >
+ MAX
+
+
+
+
+
+
{
+ e.stopPropagation();
+ setShowFromSelector(!showFromSelector);
+ setShowToSelector(false);
+ }}
+ className="flex items-center gap-2 bg-[#1E1E1E] rounded-xl px-4 py-2 hover:bg-[#141414] transition-colors"
+ >
+ {getTokenIcon(fromToken).startsWith('/') ? (
+
+ ) : (
+
+ {getTokenIcon(fromToken)}
+
+ )}
+ {getTokenSymbol(fromToken)}
+
+
+
+
+ {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) => (
+
{
+ e.stopPropagation();
+ setFromToken(token);
+ setShowFromSelector(false);
+ }}
+ className="w-full flex items-center gap-3 px-4 py-3 hover:bg-[#2B2B2A] transition-colors"
+ >
+ {getTokenIcon(token).startsWith('/') ? (
+
+ ) : (
+
+ {getTokenIcon(token)}
+
+ )}
+ {getTokenSymbol(token)}
+
+ ))}
+
+ )}
+
+
+
+
+
+ {/* Switch Button */}
+
+
+ {/* To Token */}
+
+
+
You receive
+
+
+ Bal:
+ Balance:
+
+ {getTokenIcon(toToken).startsWith('/') ? (
+
+ ) : (
+
+ {getTokenIcon(toToken)}
+
+ )}
+
{formatBalance(balances[toToken])}
+
+
+
+
+
+
+
+
{
+ e.stopPropagation();
+ setShowToSelector(!showToSelector);
+ setShowFromSelector(false);
+ }}
+ className="flex items-center gap-2 bg-[#1E1E1E] rounded-xl px-4 py-2 hover:bg-[#141414] transition-colors"
+ >
+ {getTokenIcon(toToken).startsWith('/') ? (
+
+ ) : (
+
+ {getTokenIcon(toToken)}
+
+ )}
+ {getTokenSymbol(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) => (
+
{
+ e.stopPropagation();
+ setToToken(token);
+ setShowToSelector(false);
+ }}
+ className="w-full flex items-center gap-3 px-4 py-3 hover:bg-[#2B2B2A] transition-colors"
+ >
+ {getTokenIcon(token).startsWith('/') ? (
+
+ ) : (
+
+ {getTokenIcon(token)}
+
+ )}
+ {getTokenSymbol(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('/') ? (
+
+ ) : (
+
+ {getTokenIcon(fromToken)}
+
+ )}
+ →
+ {getTokenIcon(toToken).startsWith('/') ? (
+
+ ) : (
+
+ {getTokenIcon(toToken)}
+
+ )}
+ >
+ )}
+ {getSwapRoute(fromToken, toToken) === 'direct-dbc' && (
+ <>
+ {getTokenIcon(fromToken).startsWith('/') ? (
+
+ ) : (
+
+ {getTokenIcon(fromToken)}
+
+ )}
+ →
+ {getTokenIcon(toToken).startsWith('/') ? (
+
+ ) : (
+
+ {getTokenIcon(toToken)}
+
+ )}
+ >
+ )}
+ {getSwapRoute(fromToken, toToken) === 'double' && (() => {
+ 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('/') ? (
+
+ ) : (
+
+ {getTokenIcon(fromToken)}
+
+ )}
+ →
+ {getTokenIcon(middleToken).startsWith('/') ? (
+
+ ) : (
+
+ {getTokenIcon(middleToken)}
+
+ )}
+ →
+ {getTokenIcon(toToken).startsWith('/') ? (
+
+ ) : (
+
+ {getTokenIcon(toToken)}
+
+ )}
+ >
+ );
+ })()}
+ {getSwapRoute(fromToken, toToken) === 'triple' && (
+ <>
+ {getTokenIcon(fromToken).startsWith('/') ? (
+
+ ) : (
+
+ {getTokenIcon(fromToken)}
+
+ )}
+ →
+ {getTokenIcon('ZC').startsWith('/') ? (
+
+ ) : (
+
+ {getTokenIcon('ZC')}
+
+ )}
+ →
+ {getTokenIcon('SHIRTLESS').startsWith('/') ? (
+
+ ) : (
+
+ {getTokenIcon('SHIRTLESS')}
+
+ )}
+ →
+ {getTokenIcon(toToken).startsWith('/') ? (
+
+ ) : (
+
+ {getTokenIcon(toToken)}
+
+ )}
+ >
+ )}
+
+
+
+ )}
+
+ {/* Swap Button */}
+
parseFloat(balances[fromToken]))
+ }
+ className={`w-full font-bold py-4 rounded-xl transition-opacity disabled:cursor-not-allowed ${
+ !wallet
+ ? 'text-[14px] text-[#b2e9fe] hover:text-[#d0f2ff] bg-transparent'
+ : (wallet && amount && parseFloat(amount) > 0 && parseFloat(amount) <= parseFloat(balances[fromToken]) && estimatedOutput !== 'Error')
+ ? 'bg-[#F7FCFE] text-black hover:opacity-90'
+ : 'bg-gray-600 text-gray-300 opacity-50'
+ }`}
+ style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }}
+ >
+ {!wallet
+ ? '[CLICK TO CONNECT WALLET]'
+ : isSwapping
+ ? 'Swapping...'
+ : wallet && amount && parseFloat(amount) > parseFloat(balances[fromToken])
+ ? <>Insufficient Bal Insufficient Balance >
+ : 'Swap'}
+
+
+
+
+ );
+}
diff --git a/ui/components/TabBar.tsx b/ui/components/TabBar.tsx
new file mode 100644
index 0000000..b1d9339
--- /dev/null
+++ b/ui/components/TabBar.tsx
@@ -0,0 +1,118 @@
+'use client';
+
+import { usePathname, useRouter } from 'next/navigation';
+import { useWallet } from './WalletProvider';
+import { useTabContext } from '@/contexts/TabContext';
+
+const BASE_TABS = [
+ { name: 'landing-page.zc', href: '/' },
+ { name: 'faq.zc', href: '/faq' },
+ { name: 'projects.zc', href: '/projects' },
+ { name: 'launch.zc', href: '/launch' },
+ { name: 'swap.zc', href: '/swap' },
+ { name: 'stake.zc', href: '/stake' },
+ { name: 'claim.zc', href: '/claim' }
+];
+
+const PORTFOLIO_TAB = { name: 'portfolio.zc', href: '/portfolio' };
+
+export function TabBar() {
+ const pathname = usePathname();
+ const router = useRouter();
+ const { isPrivyAuthenticated } = useWallet();
+ const { dynamicTabs, closeTab } = useTabContext();
+
+ // When authenticated: add portfolio tab and remove claim tab
+ // When not authenticated: show all BASE_TABS including claim
+ const tabs = isPrivyAuthenticated
+ ? [...BASE_TABS.slice(0, 4), PORTFOLIO_TAB, ...BASE_TABS.slice(4, 6)] // excludes claim (index 6)
+ : BASE_TABS;
+
+ const handleStaticTabClick = (href: string) => {
+ router.push(href);
+ };
+
+ const handleDynamicTabClick = (type: 'history' | 'holders' | 'burn' | 'transfer' | 'presale' | 'vesting', tokenAddress: string) => {
+ // Both presale and vesting tabs navigate to the same /presale/ route
+ const route = type === 'vesting' ? 'presale' : type;
+ router.push(`/${route}/${tokenAddress}`);
+ };
+
+ const handleCloseTab = (e: React.MouseEvent, id: string, tab: any) => {
+ e.stopPropagation();
+ closeTab(id);
+
+ // If closing the current tab, navigate to origin route
+ const currentPath = `/${tab.type}/${tab.tokenAddress}`;
+ if (pathname === currentPath) {
+ router.push(tab.originRoute || '/portfolio');
+ }
+ };
+
+ return (
+
+ {/* Static tabs */}
+ {tabs.map((tab) => {
+ const isActive = pathname === tab.href;
+ return (
+
handleStaticTabClick(tab.href)}
+ className="px-4 py-2 whitespace-nowrap transition-colors cursor-pointer"
+ style={{
+ backgroundColor: isActive ? '#474748' : 'transparent',
+ color: isActive ? '#E9E9E3' : '#858585',
+ borderBottom: isActive ? '2px solid #FFFFFF' : 'none',
+ }}
+ >
+ {tab.name}
+
+ );
+ })}
+
+ {/* Dynamic tabs */}
+ {dynamicTabs.map((tab) => {
+ const tabPath = `/${tab.type}/${tab.tokenAddress}`;
+ const isActive = pathname === tabPath;
+ const tabName = `${tab.tokenSymbol}-${tab.type}.zc`;
+
+ return (
+
+
handleDynamicTabClick(tab.type, tab.tokenAddress)}
+ className="mr-2"
+ >
+ {tabName}
+
+
handleCloseTab(e, tab.id, tab)}
+ className="transition-opacity hover:text-white"
+ title="Close tab"
+ >
+
+
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/ui/components/TokenCardVSCode.tsx b/ui/components/TokenCardVSCode.tsx
new file mode 100644
index 0000000..9f0ccd7
--- /dev/null
+++ b/ui/components/TokenCardVSCode.tsx
@@ -0,0 +1,231 @@
+import { useState } from 'react';
+import Link from 'next/link';
+
+interface TokenCardVSCodeProps {
+ tokenName: string | null;
+ tokenSymbol: string | null;
+ tokenAddress: string;
+ creatorWallet: string;
+ creatorTwitter?: string | null;
+ creatorGithub?: string | null;
+ metadata?: {
+ name: string;
+ symbol: string;
+ image: string;
+ website?: string;
+ twitter?: string;
+ description?: string;
+ } | null;
+ launchTime?: string;
+ marketCap?: number;
+ onClick?: () => void;
+ isCreator?: boolean;
+}
+
+export function TokenCardVSCode({
+ tokenName,
+ tokenSymbol,
+ tokenAddress,
+ creatorTwitter,
+ creatorGithub,
+ metadata,
+ launchTime,
+ marketCap,
+ onClick,
+ isCreator = false,
+}: TokenCardVSCodeProps) {
+ const [copiedAddress, setCopiedAddress] = useState(false);
+
+ const formatAddress = (address: string) => {
+ if (!address) return '';
+ return `${address.slice(0, 6)}...${address.slice(-6)}`;
+ };
+
+ const formatTime = (timestamp: string, includeSuffix = true) => {
+ const date = new Date(timestamp);
+ const now = new Date();
+ const diffMs = now.getTime() - date.getTime();
+ const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
+ const diffDays = Math.floor(diffHours / 24);
+ const suffix = includeSuffix ? ' ago' : '';
+
+ if (diffDays > 0) {
+ return `${diffDays}d${suffix}`;
+ } else if (diffHours > 0) {
+ return `${diffHours}h${suffix}`;
+ } else {
+ const diffMinutes = Math.floor(diffMs / (1000 * 60));
+ return diffMinutes > 0 ? `${diffMinutes}m${suffix}` : 'just now';
+ }
+ };
+
+ const formatMarketCap = (marketCap: number | undefined) => {
+ if (!marketCap) 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)}`;
+ };
+
+ const handleCopyAddress = (e?: React.MouseEvent) => {
+ if (e) e.stopPropagation();
+ navigator.clipboard.writeText(tokenAddress);
+ setCopiedAddress(true);
+ setTimeout(() => setCopiedAddress(false), 2000);
+ };
+
+ const formatSocials = (twitter: string | null | undefined, github: string | null | undefined) => {
+ const socials: string[] = [];
+
+ if (twitter) {
+ const twitterMatch = twitter.match(/(?:twitter\.com|x\.com)\/([A-Za-z0-9_]+)/);
+ const username = twitterMatch ? twitterMatch[1] : twitter;
+ socials.push(`@${username}`);
+ }
+
+ if (github) {
+ const githubMatch = github.match(/github\.com\/([A-Za-z0-9-]+)/);
+ const username = githubMatch ? githubMatch[1] : github;
+ socials.push(`gh:${username}`);
+ }
+
+ return socials.length > 0 ? socials.join(', ') : '-';
+ };
+
+ return (
+
+
+ {/* Token Image */}
+ {metadata?.image && (
+
+
+
+ )}
+
+ {/* Token Info */}
+
+ {/* Name, Symbol, Market Cap, Time */}
+
+ {/* Mobile: Show only symbol in white */}
+
+ {tokenSymbol || '-'}
+
+ {/* Mobile: CA inline with symbol */}
+
handleCopyAddress(e)}
+ className="md:hidden text-gray-300 hover:text-[#b2e9fe] transition-colors flex items-center gap-1 text-[14px]"
+ style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }}
+ >
+ {tokenAddress.slice(0, 6)}
+ {copiedAddress ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+ {/* Desktop: Show name and symbol */}
+
+ {tokenName || '-'}
+
+
+ ({tokenSymbol || '-'})
+
+ {marketCap !== undefined && (
+
+ {formatMarketCap(marketCap)}
+
+ )}
+ {launchTime && (
+
+ {formatTime(launchTime, false)}
+ {formatTime(launchTime, true)}
+
+ )}
+ {isCreator && (
+
e.stopPropagation()}
+ className="hidden md:inline text-[14px] text-gray-300 hover:text-[#b2e9fe] transition-colors"
+ style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }}
+ >
+ [Manage]
+
+ )}
+
+
+ {/* Description */}
+ {metadata?.description && (
+
+ {metadata.description}
+
+ )}
+
+ {/* CA, Creator, Links */}
+
+ {/* CA - Desktop only (mobile shows inline with symbol) */}
+
handleCopyAddress(e)}
+ className="hidden md:flex text-gray-300 hover:text-[#b2e9fe] transition-colors items-center gap-1"
+ >
+ CA: {formatAddress(tokenAddress)}
+ {copiedAddress ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+ {/* Creator Socials */}
+
+ {formatSocials(creatorTwitter, creatorGithub)}
+ Creator: {formatSocials(creatorTwitter, creatorGithub)}
+
+
+ {/* Links */}
+
e.stopPropagation()}>
+ {metadata?.website && (
+
+ [web]
+
+ )}
+ {metadata?.twitter && (
+
+ [x]
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/ui/components/TransferContent.tsx b/ui/components/TransferContent.tsx
new file mode 100644
index 0000000..188983a
--- /dev/null
+++ b/ui/components/TransferContent.tsx
@@ -0,0 +1,443 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { useWallet } from '@/components/WalletProvider';
+import { PublicKey, Transaction } from '@solana/web3.js';
+import {
+ createTransferInstruction,
+ getAssociatedTokenAddress,
+ TOKEN_PROGRAM_ID,
+ createAssociatedTokenAccountInstruction,
+ getMint,
+ getAccount
+} from '@solana/spl-token';
+import { useSignTransaction } from '@privy-io/react-auth/solana';
+import { showToast } from '@/components/Toast';
+import { createMemoInstruction } from '@solana/spl-memo';
+import { useLaunchInfo } from '@/hooks/useTokenData';
+
+interface TransferContentProps {
+ tokenAddress: string;
+ tokenSymbol: string;
+ userBalance: string;
+}
+
+export function TransferContent({ tokenAddress, tokenSymbol: initialSymbol, userBalance: initialBalance }: TransferContentProps) {
+ const { wallet, activeWallet } = useWallet();
+ const { signTransaction } = useSignTransaction();
+ const [amount, setAmount] = useState('');
+ const [toAddress, setToAddress] = useState('');
+ const [description, setDescription] = useState('');
+ const [isTransferring, setIsTransferring] = useState(false);
+ const [errors, setErrors] = useState<{ amount?: string; address?: string; description?: string }>({});
+ const [transferProgress, setTransferProgress] = 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 => {
+ if (balance === '--') return 0;
+ const parsed = parseFloat(balance);
+ return isNaN(parsed) ? 0 : parsed;
+ };
+
+ const validateInputs = () => {
+ const newErrors: { amount?: string; address?: string; description?: string } = {};
+
+ // Validate amount
+ if (!amount || amount.trim() === '') {
+ newErrors.amount = 'Amount is required';
+ } else {
+ const numAmount = parseFloat(amount);
+ if (isNaN(numAmount) || numAmount <= 0) {
+ newErrors.amount = 'Amount must be a positive number';
+ } else if (numAmount > parseUserBalance(userBalance)) {
+ newErrors.amount = 'Amount exceeds available balance';
+ }
+ }
+
+ // Validate Solana address
+ if (!toAddress || toAddress.trim() === '') {
+ newErrors.address = 'Recipient address is required';
+ } else {
+ try {
+ new PublicKey(toAddress);
+ } catch {
+ newErrors.address = 'Invalid Solana address';
+ }
+ }
+
+ // Validate description (required)
+ if (!description || description.trim() === '') {
+ newErrors.description = 'Description is required';
+ } else if (description.trim().length > 200) {
+ newErrors.description = 'Description must be less than 200 characters';
+ }
+
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ // Live validation on input change
+ const handleAmountChange = (value: string) => {
+ setAmount(value);
+ if (errors.amount) {
+ const numAmount = parseFloat(value);
+ if (!isNaN(numAmount) && numAmount > 0 && numAmount <= parseUserBalance(userBalance)) {
+ setErrors(prev => ({ ...prev, amount: undefined }));
+ }
+ }
+ };
+
+ const handleAddressChange = (value: string) => {
+ setToAddress(value);
+ if (errors.address && value) {
+ try {
+ new PublicKey(value);
+ setErrors(prev => ({ ...prev, address: undefined }));
+ } catch {}
+ }
+ };
+
+ const handleDescriptionChange = (value: string) => {
+ setDescription(value);
+ if (errors.description && value.trim() && value.trim().length <= 200) {
+ setErrors(prev => ({ ...prev, description: undefined }));
+ }
+ };
+
+ const handleTransfer = async () => {
+ if (!validateInputs() || !wallet || !activeWallet) return;
+
+ setIsTransferring(true);
+ setTransferProgress('Preparing transfer...');
+
+ 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 fromPublicKey = wallet;
+ const toPublicKey = new PublicKey(toAddress);
+ const mintPublicKey = new PublicKey(tokenAddress);
+
+ // Get actual token decimals
+ const mintInfo = await getMint(connection, mintPublicKey);
+ const decimals = mintInfo.decimals;
+
+ // Convert amount to token units using actual decimals
+ const amountInTokens = BigInt(Math.floor(parseFloat(amount) * Math.pow(10, decimals)));
+
+ // Get associated token accounts
+ const fromTokenAccount = await getAssociatedTokenAddress(mintPublicKey, fromPublicKey);
+ const toTokenAccount = await getAssociatedTokenAddress(mintPublicKey, toPublicKey, true);
+
+ // Create transaction
+ const transaction = new Transaction();
+
+ // Check if recipient's associated token account exists
+ const toTokenAccountInfo = await connection.getAccountInfo(toTokenAccount);
+
+ if (!toTokenAccountInfo) {
+ // If account doesn't exist, add instruction to create it
+ const createATAInstruction = createAssociatedTokenAccountInstruction(
+ fromPublicKey, // payer
+ toTokenAccount, // ata
+ toPublicKey, // owner
+ mintPublicKey // mint
+ );
+ transaction.add(createATAInstruction);
+ }
+
+ // Create transfer instruction
+ const transferInstruction = createTransferInstruction(
+ fromTokenAccount,
+ toTokenAccount,
+ fromPublicKey,
+ amountInTokens,
+ [],
+ TOKEN_PROGRAM_ID
+ );
+
+ transaction.add(transferInstruction);
+
+ // Add memo instruction with the description
+ if (description.trim()) {
+ const memoInstruction = createMemoInstruction(description.trim(), [fromPublicKey]);
+ transaction.add(memoInstruction);
+ }
+
+ // Get recent blockhash and set transaction properties
+ const { blockhash } = await connection.getLatestBlockhash();
+ transaction.recentBlockhash = blockhash;
+ transaction.feePayer = fromPublicKey;
+
+ // Sign and send transaction with modern approach
+ setTransferProgress('Please approve transaction in your wallet...');
+ const serializedTransaction = transaction.serialize({
+ requireAllSignatures: false,
+ verifySignatures: false
+ });
+
+ const { signedTransaction: signedTxBytes } = await signTransaction({
+ transaction: serializedTransaction,
+ wallet: activeWallet!
+ });
+
+ const signedTransaction = Transaction.from(signedTxBytes);
+ const signature = await connection.sendRawTransaction(
+ signedTransaction.serialize(),
+ {
+ skipPreflight: false,
+ preflightCommitment: 'confirmed'
+ }
+ );
+
+ setTransferProgress('Confirming transaction...');
+ // Simple confirmation polling like other parts of the app
+ let confirmed = false;
+ let attempts = 0;
+ const maxAttempts = 30; // 30 seconds timeout
+
+ while (!confirmed && attempts < maxAttempts) {
+ await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1 second
+ attempts++;
+
+ try {
+ const status = await connection.getSignatureStatus(signature, {
+ searchTransactionHistory: false
+ });
+
+ if (status.value) {
+ if (status.value.err) {
+ throw new Error(`Transaction failed: ${JSON.stringify(status.value.err)}`);
+ }
+
+ if (status.value.confirmationStatus === 'confirmed' || status.value.confirmationStatus === 'finalized') {
+ confirmed = true;
+ break;
+ }
+ }
+ } catch (pollError) {
+ console.warn('Error polling transaction status:', pollError);
+ }
+ }
+
+ if (!confirmed) {
+ throw new Error('Transaction confirmation timeout');
+ }
+
+ // Show success toast
+ showToast('success', `Successfully transferred ${amount} ${tokenSymbol}`);
+
+ // Reset form and refresh balance
+ setAmount('');
+ setToAddress('');
+ setDescription('');
+
+ // Refresh balance
+ const accountInfo = await getAccount(connection, fromTokenAccount);
+ const balance = Number(accountInfo.amount) / Math.pow(10, decimals);
+ setUserBalance(balance.toString());
+
+ } catch (error) {
+ console.error('Transfer error:', error);
+ // Better error handling
+ let errorMessage = 'Transfer failed';
+ if (error instanceof Error) {
+ if (error.message.includes('User rejected')) {
+ errorMessage = 'Transaction cancelled';
+ } else if (error.message.includes('insufficient')) {
+ errorMessage = 'Insufficient SOL for transaction fee';
+ } else {
+ errorMessage = error.message;
+ }
+ }
+ showToast('error', errorMessage);
+ setErrors({ amount: errorMessage });
+ } finally {
+ setIsTransferring(false);
+ setTransferProgress('');
+ }
+ };
+
+ return (
+
+ {/* Header */}
+
Transfer Tokens
+
+ {'//'}Send ${tokenSymbol} tokens to another wallet
+
+
+ {/* Token Info */}
+
+
+ {tokenImageUri && (
+
{
+ 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)}
+
+
+
+
+ {/* Transfer Form */}
+
+
+ {/* Amount Input */}
+
+
+ Amount to send
+
+ Available: {userBalance === '--' ? '--' : parseUserBalance(userBalance).toLocaleString()} {tokenSymbol}
+
+
+
+
+ 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={isTransferring}
+ />
+ setAmount(userBalance === '--' ? '0' : userBalance)}
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-xs font-semibold text-[#F7FCFE] bg-[#1E1E1E] hover:bg-[#141414] px-2 py-1 rounded transition-colors"
+ style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }}
+ disabled={isTransferring}
+ >
+ MAX
+
+
+
+ {errors.amount &&
{errors.amount}
}
+
+
+ {/* Recipient Address Input */}
+
+
+ Recipient address
+
+
handleAddressChange(e.target.value)}
+ placeholder="Enter Solana wallet address"
+ className="w-full bg-transparent text-sm font-semibold focus:outline-none"
+ style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }}
+ disabled={isTransferring}
+ />
+ {errors.address &&
{errors.address}
}
+
+
+ {/* Description Input */}
+
+
+ Description (Required)
+
+ {description.length}/200
+
+
+
+
+ {transferProgress && (
+
+ )}
+
+
+ {isTransferring ? 'Transferring...' : 'Transfer Tokens'}
+
+
+
+
+ );
+}
diff --git a/ui/components/VestingModal.tsx b/ui/components/VestingModal.tsx
index ad0ad10..1a47010 100644
--- a/ui/components/VestingModal.tsx
+++ b/ui/components/VestingModal.tsx
@@ -4,7 +4,6 @@ import { useState } from 'react';
import { useWallet } from './WalletProvider';
import { usePrivy } from '@privy-io/react-auth';
import { Transaction } from '@solana/web3.js';
-import { InfoTooltip } from '@/components/InfoTooltip';
import bs58 from 'bs58';
interface VestingModalProps {
@@ -153,23 +152,21 @@ export function VestingModal({
if (!vestingInfo) {
return (
-
-
-
Vesting
- {!wallet ? (
-
Connect your wallet to view vesting information
- ) : (
-
No vesting allocation found for your wallet
- )}
-
-
- {!wallet && (
-
- Connect Wallet
-
+
+
{'//'}Vesting
+ {!wallet ? (
+ <>
+
Connect your wallet to view vesting information
+
+ [CONNECT WALLET]
+
+ >
+ ) : (
+
No vesting allocation found for your wallet
)}
);
@@ -198,84 +195,74 @@ export function VestingModal({
const timeUntilUnlock = getTimeUntilUnlock();
return (
-
-
-
Vesting
-
-
- Your allocation: {formatTokenAmount(vestingInfo.totalAllocated)} {tokenSymbol}
-
-
-
- Already claimed: {formatTokenAmount(vestingInfo.totalClaimed)} {tokenSymbol}
-
-
- Vesting progress: {vestingInfo.vestingProgress.toFixed(1)}%
- {vestingInfo.isFullyVested ? (
- ✓ Fully Vested
- ) : (
- ({hoursRemaining} hours left)
- )}
-
-
+
+
{'//'}Vesting
+
+
+
+ Your allocation: {formatTokenAmount(vestingInfo.totalAllocated)} {tokenSymbol}
+
+
+ Already claimed: {formatTokenAmount(vestingInfo.totalClaimed)} {tokenSymbol}
+
+
+ Vesting progress: {vestingInfo.vestingProgress.toFixed(1)}%
+ {vestingInfo.isFullyVested ? (
+ ✓ Fully Vested
+ ) : (
+ ({hoursRemaining} hours left)
+ )}
+
-
-
-
- Available to Claim
- {isInCooldown && (Cooldown active) }
-
-
-
-
- {formatTokenAmount(vestingInfo.claimableAmount)}
-
- {tokenSymbol}
-
-
-
- {claimError && (
-
- {claimError}
-
- )}
- {claimSuccess && (
-
- Tokens claimed successfully! ✓
-
- )}
-
+
+
+
+ Available to Claim:
+ {formatTokenAmount(vestingInfo.claimableAmount)} {tokenSymbol}
+
+ {' '}
+ {isInCooldown && (Cooldown active) }
+
+ {claimError && (
+
+ {claimError}
+
+ )}
+ {claimSuccess && (
+
+ Tokens claimed successfully! ✓
+
+ )}
-
-
- Remaining to vest:
- {formatTokenAmount(remainingTokens.toString())} {tokenSymbol}
-
+
+
+ Remaining to vest: {formatTokenAmount(remainingTokens.toString())} {tokenSymbol}
+
{!vestingInfo.isFullyVested && vestingInfo.nextUnlockTime && (
-
-
Next unlock:
-
+
+ Next unlock:
{isInCooldown ? (
<>In {timeUntilUnlock} ({new Date(vestingInfo.nextUnlockTime).toLocaleTimeString()})>
) : (
new Date(vestingInfo.nextUnlockTime).toLocaleTimeString()
)}
-
+
)}
- {isClaiming ? 'Processing...' :
- isInCooldown ? `Claim available in ${timeUntilUnlock}` :
- hasTokensToClaimNow ? `Claim ${formatTokenAmount(vestingInfo.claimableAmount)} ${tokenSymbol}` :
- 'No Tokens Available'}
+ {isClaiming ? '[PROCESSING...]' :
+ isInCooldown ? `[CLAIM AVAILABLE IN ${timeUntilUnlock}]` :
+ hasTokensToClaimNow ? `[CLAIM ${formatTokenAmount(vestingInfo.claimableAmount)} ${tokenSymbol}]` :
+ '[NO TOKENS AVAILABLE]'}
diff --git a/ui/components/WalletButton.tsx b/ui/components/WalletButton.tsx
index 6e0e135..27113d5 100644
--- a/ui/components/WalletButton.tsx
+++ b/ui/components/WalletButton.tsx
@@ -46,9 +46,10 @@ export const WalletButton = ({ onLaunch, disabled = false, isLaunching = false,
return (
- Connecting...
+ [CONNECTING...]
);
}
@@ -60,9 +61,10 @@ export const WalletButton = ({ onLaunch, disabled = false, isLaunching = false,
{error}
setError(null)}
- className="text-xl text-gray-300 hover:text-white transition-colors cursor-pointer"
+ className="text-[14px] text-[#b2e9fe] hover:text-[#d0f2ff] transition-colors cursor-pointer"
+ style={{ fontFamily: 'Monaco, Menlo, "Courier New", monospace' }}
>
- Try Again
+ [TRY AGAIN]
);
@@ -73,17 +75,18 @@ export const WalletButton = ({ onLaunch, disabled = false, isLaunching = false,
{isGeneratingCA
- ? 'Generating CA...'
+ ? '[GENERATING CA...]'
: isLaunching
- ? 'Launching...'
+ ? '[LAUNCHING...]'
: externalWallet
- ? isPresale ? 'LAUNCH PRESALE' : 'LAUNCH'
- : 'CONNECT WALLET'}
+ ? disabled
+ ? '[FILL OUT REQUIRED FIELDS TO LAUNCH]'
+ : isPresale ? '[CLICK TO LAUNCH PRESALE]' : '[CLICK TO LAUNCH]'
+ : '[CLICK TO CONNECT WALLET]'}
);
};
\ No newline at end of file
diff --git a/ui/contexts/TabContext.tsx b/ui/contexts/TabContext.tsx
new file mode 100644
index 0000000..36fefa4
--- /dev/null
+++ b/ui/contexts/TabContext.tsx
@@ -0,0 +1,107 @@
+'use client';
+
+import { createContext, useContext, useState, ReactNode, useEffect, useMemo, useCallback } from 'react';
+
+export interface DynamicTab {
+ id: string;
+ type: 'history' | 'holders' | 'burn' | 'transfer' | 'presale' | 'vesting';
+ tokenAddress: string;
+ tokenSymbol: string;
+ originRoute: string;
+}
+
+interface TabContextType {
+ dynamicTabs: DynamicTab[];
+ addTab: (type: 'history' | 'holders' | 'burn' | 'transfer' | 'presale' | 'vesting', tokenAddress: string, tokenSymbol: string, originRoute: string) => void;
+ closeTab: (id: string) => void;
+}
+
+const TabContext = createContext
(undefined);
+
+const STORAGE_KEY = 'zc-dynamic-tabs';
+
+export function TabProvider({ children }: { children: ReactNode }) {
+ const [dynamicTabs, setDynamicTabs] = useState([]);
+ const [isInitialized, setIsInitialized] = useState(false);
+
+ // Restore tabs from localStorage on mount
+ useEffect(() => {
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored) {
+ const parsed = JSON.parse(stored);
+ if (parsed.dynamicTabs && Array.isArray(parsed.dynamicTabs)) {
+ // Migrate old tabs without originRoute to have default /portfolio origin
+ const migratedTabs = parsed.dynamicTabs.map((tab: any) => ({
+ ...tab,
+ originRoute: tab.originRoute || '/portfolio'
+ }));
+ setDynamicTabs(migratedTabs);
+ }
+ }
+ } catch (error) {
+ console.error('Failed to restore tabs from localStorage:', error);
+ } finally {
+ setIsInitialized(true);
+ }
+ }, []);
+
+ // Save tabs to localStorage (debounced via setTimeout)
+ useEffect(() => {
+ if (!isInitialized) return;
+
+ const timeoutId = setTimeout(() => {
+ try {
+ const data = { dynamicTabs };
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
+ } catch (error) {
+ console.error('Failed to save tabs to localStorage:', error);
+ }
+ }, 500); // Debounce 500ms
+
+ return () => clearTimeout(timeoutId);
+ }, [dynamicTabs, isInitialized]);
+
+ const addTab = useCallback((type: 'history' | 'holders' | 'burn' | 'transfer' | 'presale' | 'vesting', tokenAddress: string, tokenSymbol: string, originRoute: string) => {
+ const id = `${type}-${tokenAddress}`;
+
+ setDynamicTabs(prev => {
+ // Check if tab already exists
+ const existingTab = prev.find(tab => tab.id === id);
+
+ if (existingTab) {
+ // Tab already exists, just return current state
+ // Navigation will be handled by the caller
+ return prev;
+ } else {
+ // Create new tab
+ const newTab: DynamicTab = {
+ id,
+ type,
+ tokenAddress,
+ tokenSymbol,
+ originRoute
+ };
+ return [...prev, newTab];
+ }
+ });
+ }, []);
+
+ const closeTab = useCallback((id: string) => {
+ setDynamicTabs(prev => prev.filter(tab => tab.id !== id));
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useTabContext() {
+ const context = useContext(TabContext);
+ if (context === undefined) {
+ throw new Error('useTabContext must be used within a TabProvider');
+ }
+ return context;
+}
diff --git a/ui/hooks/useTokenData.ts b/ui/hooks/useTokenData.ts
new file mode 100644
index 0000000..1e60b16
--- /dev/null
+++ b/ui/hooks/useTokenData.ts
@@ -0,0 +1,143 @@
+import useSWR from 'swr';
+
+// Fetcher functions
+const fetcher = async (url: string, options?: RequestInit) => {
+ const response = await fetch(url, options);
+ if (!response.ok) {
+ throw new Error('Failed to fetch');
+ }
+ return response.json();
+};
+
+// Hook for token info (supply and metadata)
+export function useTokenInfo(tokenAddress: string) {
+ const { data, error, isLoading, mutate } = useSWR(
+ tokenAddress ? [`/api/token-info/${tokenAddress}`, tokenAddress] : null,
+ ([url, address]) => fetcher(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ tokenAddress: address })
+ }),
+ {
+ revalidateOnFocus: false, // Don't refetch when window regains focus
+ dedupingInterval: 60000, // Dedupe requests within 60 seconds
+ }
+ );
+
+ return {
+ tokenInfo: data,
+ isLoading,
+ error,
+ mutate
+ };
+}
+
+// Hook for launch info
+export function useLaunchInfo(tokenAddress: string) {
+ const { data, error, isLoading, mutate } = useSWR(
+ tokenAddress ? [`/api/launches`, tokenAddress] : null,
+ ([url, address]) => fetcher(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ token: address })
+ }),
+ {
+ revalidateOnFocus: false,
+ dedupingInterval: 60000,
+ }
+ );
+
+ return {
+ launchData: data,
+ isLoading,
+ error,
+ mutate
+ };
+}
+
+// Hook for designated claims
+export function useDesignatedClaims(tokenAddress: string) {
+ const { data, error, isLoading, mutate } = useSWR(
+ tokenAddress ? [`/api/designated-claims/${tokenAddress}`, tokenAddress] : null,
+ ([url, address]) => fetcher(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ tokenAddress: address })
+ }),
+ {
+ revalidateOnFocus: false,
+ dedupingInterval: 60000,
+ }
+ );
+
+ return {
+ designatedData: data,
+ isLoading,
+ error,
+ mutate
+ };
+}
+
+// Hook for transactions
+export function useTransactions(
+ tokenAddress: string,
+ creatorWallet: string | null,
+ before?: string | null,
+ fetchLabels?: boolean
+) {
+ const TRANSACTIONS_PER_PAGE = 10;
+
+ const { data, error, isLoading, mutate } = useSWR(
+ tokenAddress && creatorWallet
+ ? [`/api/transactions/${tokenAddress}`, tokenAddress, creatorWallet, before, fetchLabels]
+ : null,
+ ([url]) => fetcher(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ tokenAddress,
+ walletAddress: creatorWallet,
+ limit: TRANSACTIONS_PER_PAGE,
+ fetchLabels,
+ ...(before && { before })
+ })
+ }),
+ {
+ revalidateOnFocus: false,
+ dedupingInterval: 30000, // 30 seconds for transactions (more dynamic data)
+ }
+ );
+
+ return {
+ transactions: data?.transactions || [],
+ hasMore: data?.hasMore || false,
+ lastSignature: data?.lastSignature || null,
+ isLoading,
+ error,
+ mutate
+ };
+}
+
+// Hook for holders
+export function useHolders(tokenAddress: string) {
+ const { data, error, isLoading, mutate } = useSWR(
+ tokenAddress ? [`/api/holders/${tokenAddress}`, tokenAddress] : null,
+ ([url, address]) => fetcher(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ tokenAddress: address })
+ }),
+ {
+ revalidateOnFocus: false,
+ dedupingInterval: 60000,
+ }
+ );
+
+ return {
+ holders: data?.holders || [],
+ stats: data?.stats || { totalHolders: 0, totalBalance: '0', lastSyncTime: null },
+ isLoading,
+ error,
+ mutate
+ };
+}
diff --git a/ui/package.json b/ui/package.json
index a222f43..3013ade 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -39,6 +39,8 @@
"pg": "^8.16.3",
"react": "19.1.0",
"react-dom": "19.1.0",
+ "react-icons": "^5.5.0",
+ "swr": "^2.3.6",
"tweetnacl": "^1.0.3"
},
"devDependencies": {
diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml
index dcc33f0..c4be961 100644
--- a/ui/pnpm-lock.yaml
+++ b/ui/pnpm-lock.yaml
@@ -89,6 +89,12 @@ importers:
react-dom:
specifier: 19.1.0
version: 19.1.0(react@19.1.0)
+ react-icons:
+ specifier: ^5.5.0
+ version: 5.5.0(react@19.1.0)
+ swr:
+ specifier: ^2.3.6
+ version: 2.3.6(react@19.1.0)
tweetnacl:
specifier: ^1.0.3
version: 1.0.3
@@ -2856,6 +2862,10 @@ packages:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
+ dequal@2.0.3:
+ resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
+ engines: {node: '>=6'}
+
derive-valtio@0.1.0:
resolution: {integrity: sha512-OCg2UsLbXK7GmmpzMXhYkdO64vhJ1ROUUGaTFyHjVwEdMEcTTRj7W1TxLbSBxdY8QLBPCcp66MTyaSy0RpO17A==}
peerDependencies:
@@ -4633,6 +4643,11 @@ packages:
peerDependencies:
react: ^19.1.0
+ react-icons@5.5.0:
+ resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==}
+ peerDependencies:
+ react: '*'
+
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -5091,6 +5106,11 @@ packages:
svix@1.76.1:
resolution: {integrity: sha512-CRuDWBTgYfDnBLRaZdKp9VuoPcNUq9An14c/k+4YJ15Qc5Grvf66vp0jvTltd4t7OIRj+8lM1DAgvSgvf7hdLw==}
+ swr@2.3.6:
+ resolution: {integrity: sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw==}
+ peerDependencies:
+ react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
tabbable@6.2.0:
resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==}
@@ -9626,6 +9646,8 @@ snapshots:
depd@2.0.0: {}
+ dequal@2.0.3: {}
+
derive-valtio@0.1.0(valtio@1.13.2(@types/react@19.1.13)(react@19.1.0)):
dependencies:
valtio: 1.13.2(@types/react@19.1.13)(react@19.1.0)
@@ -11847,6 +11869,10 @@ snapshots:
react: 19.1.0
scheduler: 0.26.0
+ react-icons@5.5.0(react@19.1.0):
+ dependencies:
+ react: 19.1.0
+
react-is@16.13.1: {}
react-is@18.3.1:
@@ -12449,6 +12475,12 @@ snapshots:
url-parse: 1.5.10
uuid: 10.0.0
+ swr@2.3.6(react@19.1.0):
+ dependencies:
+ dequal: 2.0.3
+ react: 19.1.0
+ use-sync-external-store: 1.5.0(react@19.1.0)
+
tabbable@6.2.0: {}
tailwindcss@4.1.13: {}
diff --git a/ui/public/logos/z-logo-black.png b/ui/public/logos/z-logo-black.png
new file mode 100644
index 0000000..83e0ddc
Binary files /dev/null and b/ui/public/logos/z-logo-black.png differ
diff --git a/ui/public/logos/z-logo-white.png b/ui/public/logos/z-logo-white.png
index 4823a9f..2966caa 100644
Binary files a/ui/public/logos/z-logo-white.png and b/ui/public/logos/z-logo-white.png differ