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 ( +
Fetching token data...
+{message}
+ +{emptyMessage}
++ Soroban-native tokens require an indexer for full holder enumeration. +
+| 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)}%
+
+ |
+
+ View this token in your dashboard with full management capabilities +
+ + Open in Dashboard +