diff --git a/frontend/app/api/og/token/[contractId]/route.tsx b/frontend/app/api/og/token/[network]/[contractId]/route.tsx similarity index 90% rename from frontend/app/api/og/token/[contractId]/route.tsx rename to frontend/app/api/og/token/[network]/[contractId]/route.tsx index f0f763ba..8d945eb0 100644 --- a/frontend/app/api/og/token/[contractId]/route.tsx +++ b/frontend/app/api/og/token/[network]/[contractId]/route.tsx @@ -1,16 +1,21 @@ import { ImageResponse } from "next/og"; import { fetchTokenInfo } from "@/lib/stellar"; -import { NETWORKS } from "@/types/network"; +import { NETWORKS, type NetworkType } from "@/types/network"; export const runtime = "edge"; +function resolveNetwork(network: string): NetworkType { + return network === "mainnet" ? "mainnet" : "testnet"; +} + export async function GET( request: Request, - { params }: { params: Promise<{ contractId: string }> } + { params }: { params: Promise<{ network: string; contractId: string }> } ) { try { - const { contractId } = await params; - const tokenInfo = await fetchTokenInfo(contractId, NETWORKS.testnet); + const { network, contractId } = await params; + const resolvedNetwork = resolveNetwork(network); + const tokenInfo = await fetchTokenInfo(contractId, NETWORKS[resolvedNetwork]); return new ImageResponse( ( diff --git a/frontend/app/api/token/[contractId]/metadata/route.ts b/frontend/app/api/token/[contractId]/metadata/route.ts deleted file mode 100644 index 2937132a..00000000 --- a/frontend/app/api/token/[contractId]/metadata/route.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { fetchTokenInfo } from "@/lib/stellar"; -import { NETWORKS } from "@/types/network"; - -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ contractId: string }> } -) { - try { - const { contractId } = await params; - const tokenInfo = await fetchTokenInfo(contractId, NETWORKS.testnet); - - return NextResponse.json(tokenInfo); - } catch (error) { - console.error("Error fetching token metadata:", error); - return NextResponse.json( - { error: "Failed to fetch token metadata" }, - { status: 404 } - ); - } -} \ No newline at end of file diff --git a/frontend/app/api/token/[network]/[contractId]/metadata/route.ts b/frontend/app/api/token/[network]/[contractId]/metadata/route.ts new file mode 100644 index 00000000..60f7de95 --- /dev/null +++ b/frontend/app/api/token/[network]/[contractId]/metadata/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from "next/server"; +import { fetchTokenInfo } from "@/lib/stellar"; +import { NETWORKS, type NetworkType } from "@/types/network"; + +function resolveNetwork(network: string): NetworkType { + return network === "mainnet" ? "mainnet" : "testnet"; +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ network: string; contractId: string }> } +) { + try { + const { network, contractId } = await params; + const resolvedNetwork = resolveNetwork(network); + const tokenInfo = await fetchTokenInfo(contractId, NETWORKS[resolvedNetwork]); + + return NextResponse.json(tokenInfo); + } catch (error) { + console.error("Error fetching token metadata:", error); + return NextResponse.json( + { error: "Failed to fetch token metadata" }, + { status: 404 } + ); + } +} \ No newline at end of file diff --git a/frontend/app/token/[network]/[contractId]/PublicTokenPage.tsx b/frontend/app/token/[network]/[contractId]/PublicTokenPage.tsx new file mode 100644 index 00000000..47f57823 --- /dev/null +++ b/frontend/app/token/[network]/[contractId]/PublicTokenPage.tsx @@ -0,0 +1,505 @@ +"use client"; + +import { useEffect, useState, useMemo, useCallback } from "react"; +import { + Copy, + Check, + ArrowUpDown, + AlertCircle, + Loader2, + Share2, + ExternalLink, +} from "lucide-react"; +import { + truncateAddress, + fetchWalletTokenState, + type TokenInfo, + type TokenHolder, + type WalletTokenState, +} from "@/lib/stellar"; +import { useSoroban } from "@/hooks/useSoroban"; +import { useNetwork } from "@/app/providers/NetworkProvider"; +import { useWallet } from "@/app/hooks/useWallet"; +import { useToast } from "@/app/providers/ToastProvider"; +import { TokenStatusBanner } from "@/components/TokenStatusBanner"; +import InvalidTokenContract from "../../../components/InvalidTokenContract"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- +type SortField = "address" | "balance" | "sharePercent"; +type SortDir = "asc" | "desc"; + +// --------------------------------------------------------------------------- +// Sub-components +// --------------------------------------------------------------------------- + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Clipboard API may be unavailable (e.g. non-HTTPS) + } + }, [text]); + + return ( + + ); +} + +function ShareButton({ + // contractId, + tokenInfo, +}: { + contractId: string; + tokenInfo: TokenInfo | null; +}) { + const [copied, setCopied] = useState(false); + const toast = useToast(); + + const handleShare = useCallback(async () => { + const url = window.location.href; + const shareText = tokenInfo + ? `Check out ${tokenInfo.name} (${tokenInfo.symbol}) token on SoroPad! Total Supply: ${tokenInfo.totalSupply}` + : `Check out this token on SoroPad!`; + + if (navigator.share) { + try { + await navigator.share({ + title: tokenInfo + ? `${tokenInfo.name} (${tokenInfo.symbol})` + : "Token Details", + text: shareText, + url: url, + }); + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") { + return; + } + toast.show({ + title: "Share failed", + message: err instanceof Error ? err.message : "Could not open share dialog", + variant: "error", + }); + } + } else { + // Fallback to copying URL + try { + await navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Clipboard API may be unavailable + } + } + }, [tokenInfo, toast]); + + return ( + + ); +} + +function InfoCard({ label, value }: { label: string; value: string }) { + return ( +
+ + {label} + + {value} +
+ ); +} + +function LoadingState() { + return ( +
+ +

Fetching token data...

+
+ ); +} + +function ErrorState({ + message, + onRetry, +}: { + message: string; + onRetry: () => void; +}) { + return ( +
+ +

{message}

+ +
+ ); +} + +// --------------------------------------------------------------------------- +// Holders table +// --------------------------------------------------------------------------- + +function HoldersTable({ + holders, + emptyMessage = "No holder data available.", +}: { + holders: TokenHolder[]; + emptyMessage?: string; +}) { + const [sortField, setSortField] = useState("balance"); + const [sortDir, setSortDir] = useState("desc"); + + const toggleSort = (field: SortField) => { + if (sortField === field) { + setSortDir((d) => (d === "asc" ? "desc" : "asc")); + } else { + setSortField(field); + setSortDir("desc"); + } + }; + + const sorted = useMemo(() => { + return [...holders].sort((a, b) => { + let cmp = 0; + switch (sortField) { + case "address": + cmp = a.address.localeCompare(b.address); + break; + case "balance": + cmp = parseFloat(a.balance) - parseFloat(b.balance); + break; + case "sharePercent": + cmp = a.sharePercent - b.sharePercent; + break; + } + return sortDir === "asc" ? cmp : -cmp; + }); + }, [holders, sortField, sortDir]); + + if (holders.length === 0) { + return ( +
+

{emptyMessage}

+

+ Soroban-native tokens require an indexer for full holder enumeration. +

+
+ ); + } + + const thClass = + "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 cursor-pointer select-none hover:text-gray-300 transition-colors"; + + return ( +
+
+ + + + + + + + + + {sorted.map((holder, i) => ( + + + + + + ))} + +
toggleSort("address")} + aria-sort={ + sortField === "address" + ? sortDir === "asc" + ? "ascending" + : "descending" + : "none" + } + > + + Address + + + toggleSort("balance")} + aria-sort={ + sortField === "balance" + ? sortDir === "asc" + ? "ascending" + : "descending" + : "none" + } + > + + Balance + + + toggleSort("sharePercent")} + aria-sort={ + sortField === "sharePercent" + ? sortDir === "asc" + ? "ascending" + : "descending" + : "none" + } + > + + % Share + + +
+ {holder.address} + + {truncateAddress(holder.address, 6)} + + + + {holder.balance} + +
+
+
+
+ + {holder.sharePercent.toFixed(2)}% + +
+
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export default function PublicTokenPage({ + contractId, + network, +}: { + contractId: string; + network: string; +}) { + const { fetchTokenInfo, fetchTopHolders, validateTokenContract } = useSoroban(); + const { networkConfig, setNetwork } = useNetwork(); + const { publicKey, connected } = useWallet(); + const [tokenInfo, setTokenInfo] = useState(null); + const [holders, setHolders] = useState([]); + const [walletState, setWalletState] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [isValidToken, setIsValidToken] = useState(null); + + useEffect(() => { + setNetwork(network === "mainnet" ? "mainnet" : "testnet"); + }, [network, setNetwork]); + + const loadData = useCallback(async () => { + setLoading(true); + setError(null); + setIsValidToken(null); + + try { + // First validate the token contract + const validation = await validateTokenContract(contractId); + + if (!validation.isValid) { + setIsValidToken(false); + setError(validation.error || "Invalid token contract"); + setLoading(false); + return; + } + + setIsValidToken(true); + + // If validation passes, fetch token info + const info = await fetchTokenInfo(contractId); + setTokenInfo(info); + + // Attempt to load holders (best-effort for classic-wrapped assets) + const holderData = await fetchTopHolders(contractId); + setHolders(holderData); + } catch (err) { + // If fetchTokenInfo fails, it might be a validation issue + if (err instanceof Error && err.message.includes("Invalid token contract")) { + setIsValidToken(false); + setError(err.message); + } else { + setError( + err instanceof Error + ? err.message + : "Failed to fetch token data. Please check the contract ID and try again.", + ); + } + } finally { + setLoading(false); + } + }, [contractId, fetchTokenInfo, fetchTopHolders, validateTokenContract]); + + useEffect(() => { + loadData(); + }, [loadData]); + + useEffect(() => { + if (!connected || !publicKey) { + setWalletState(null); + return; + } + let cancelled = false; + fetchWalletTokenState(contractId, publicKey, networkConfig) + .then((state) => { + if (!cancelled) setWalletState(state); + }) + .catch(() => { + if (!cancelled) setWalletState(null); + }); + return () => { + cancelled = true; + }; + }, [contractId, publicKey, connected, networkConfig]); + + if (loading) return ; + + // Show invalid token component if validation failed + if (isValidToken === false) { + return ; + } + + if (error) return ; + if (!tokenInfo) return null; + + return ( +
+ {/* Header */} +
+
+
+

+ {tokenInfo.name}{" "} + ({tokenInfo.symbol}) +

+
+ + {contractId} + + {truncateAddress(contractId, 8)} + + + + + + Explorer + +
+
+ +
+
+ + + + {/* Token info grid */} +
+

+ Token Details +

+
+ + + + + + +
+
+ + {/* Top holders */} +
+

+ Top Holders +

+ +
+ + {/* Footer */} +
+
+

+ View this token in your dashboard with full management capabilities +

+ + Open in Dashboard + + +
+
+
+ ); +} diff --git a/frontend/app/token/[contractId]/page.tsx b/frontend/app/token/[network]/[contractId]/page.tsx similarity index 78% rename from frontend/app/token/[contractId]/page.tsx rename to frontend/app/token/[network]/[contractId]/page.tsx index cff7becb..1fb625f3 100644 --- a/frontend/app/token/[contractId]/page.tsx +++ b/frontend/app/token/[network]/[contractId]/page.tsx @@ -1,20 +1,25 @@ import type { Metadata } from "next"; import { fetchTokenInfo, validateTokenContract } from "@/lib/stellar"; -import { NETWORKS } from "@/types/network"; +import { NETWORKS, type NetworkType } from "@/types/network"; import PublicTokenPage from "./PublicTokenPage"; interface PageProps { - params: Promise<{ contractId: string }>; + params: Promise<{ network: string; contractId: string }>; +} + +function resolveNetwork(network: string): NetworkType { + return network === "mainnet" ? "mainnet" : "testnet"; } export async function generateMetadata({ params, }: PageProps): Promise { - const { contractId } = await params; + const { network, contractId } = await params; + const resolvedNetwork = resolveNetwork(network); try { // First validate the token contract - const validation = await validateTokenContract(contractId, NETWORKS.testnet); + const validation = await validateTokenContract(contractId, NETWORKS[resolvedNetwork]); if (!validation.isValid) { // Return fallback metadata for invalid contracts @@ -29,7 +34,7 @@ export async function generateMetadata({ }; } - const tokenInfo = await fetchTokenInfo(contractId, NETWORKS.testnet); + const tokenInfo = await fetchTokenInfo(contractId, NETWORKS[resolvedNetwork]); return { title: `${tokenInfo.name} (${tokenInfo.symbol}) — ${tokenInfo.totalSupply} Supply | SoroPad`, @@ -41,7 +46,7 @@ export async function generateMetadata({ images: [ { // Relative URL — resolved to absolute by metadataBase in root layout - url: `/api/og/token/${contractId}`, + url: `/api/og/token/${network}/${contractId}`, width: 1200, height: 630, alt: `${tokenInfo.name} (${tokenInfo.symbol}) Token`, @@ -52,7 +57,7 @@ export async function generateMetadata({ card: "summary_large_image", title: `${tokenInfo.name} (${tokenInfo.symbol})`, description: `Total Supply: ${tokenInfo.totalSupply} • View token details on Stellar Soroban`, - images: [`/api/og/token/${contractId}`], + images: [`/api/og/token/${network}/${contractId}`], }, }; } catch (error) { @@ -72,7 +77,7 @@ export async function generateMetadata({ } export default async function PublicTokenPageRoute({ params }: PageProps) { - const { contractId } = await params; + const { network, contractId } = await params; - return ; + return ; } \ No newline at end of file