diff --git a/public/basescan-icon.svg b/public/basescan-icon.svg new file mode 100644 index 00000000..a9a7d48b --- /dev/null +++ b/public/basescan-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/hunt-token.svg b/public/hunt-token.svg new file mode 100644 index 00000000..e379f616 --- /dev/null +++ b/public/hunt-token.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/mc-icon-light.svg b/public/mc-icon-light.svg new file mode 100644 index 00000000..e5a42c45 --- /dev/null +++ b/public/mc-icon-light.svg @@ -0,0 +1,12 @@ + + + mc-icon-light + + \ No newline at end of file diff --git a/src/app/token/page.tsx b/src/app/token/page.tsx index be076ede..a1463f36 100644 --- a/src/app/token/page.tsx +++ b/src/app/token/page.tsx @@ -3,9 +3,12 @@ import { useAccount, useReadContract } from "wagmi"; import { formatUnits, erc20Abi } from "viem"; import { useState } from "react"; +import Image from "next/image"; import { PLOT_TOKEN, EXPLORER_URL, } from "../../../lib/contracts/constants"; +import { SwapInterface } from "../../components/token/SwapInterface"; +import { useTokenInfo, formatPrice, formatNumber } from "../../hooks/useTokenInfo"; const BASESCAN_URL = `${EXPLORER_URL}/token/${PLOT_TOKEN}`; const MINT_CLUB_URL = "https://mint.club/token/base/PLOT"; @@ -23,14 +26,9 @@ export default function TokenPage() { query: { enabled: !!address }, }); - const { data: totalSupply, isLoading: supplyLoading } = useReadContract({ - address: PLOT_TOKEN, - abi: erc20Abi, - functionName: "totalSupply", - }); + const { data: tokenInfo, isLoading: tokenInfoLoading } = useTokenInfo(); const formattedBalance = balance ? formatUnits(balance, 18) : "0"; - const formattedSupply = totalSupply ? formatUnits(totalSupply, 18) : "0"; const handleCopyAddress = async () => { try { @@ -106,6 +104,9 @@ export default function TokenPage() { + {/* Swap Interface */} + + {/* How to Get PLOT */}

How to Get PLOT

@@ -133,65 +134,111 @@ export default function TokenPage() { {/* Token Information */}
-

Token Information

+

Token Information

- {/* Stats */} + {/* Stats Grid — Price + Market Cap */}
-
-
Total Supply
- {supplyLoading ? ( +
+
Price
+ {tokenInfoLoading ? (
+ ) : tokenInfo ? ( +
+
+ {formatPrice(tokenInfo.price)} +
+ {tokenInfo.priceChange24h !== null && ( +
= 0 ? "text-green-600" : "text-red-600"}`}> + {tokenInfo.priceChange24h >= 0 ? "+" : ""} + {tokenInfo.priceChange24h.toFixed(2)}% +
+ )} +
) : ( +
+ )} +
+
+
Market Cap
+ {tokenInfoLoading ? ( +
+ ) : tokenInfo ? (
- {parseFloat(formattedSupply).toLocaleString(undefined, { - minimumFractionDigits: 0, - maximumFractionDigits: 0, - })} PLOT + ${formatNumber(tokenInfo.marketCap)}
+ ) : ( +
)}
-
-
Network
-
- - Base Mainnet -
-
- {/* Links */} + {/* External Links */} diff --git a/src/components/token/SwapInterface.tsx b/src/components/token/SwapInterface.tsx new file mode 100644 index 00000000..808928ce --- /dev/null +++ b/src/components/token/SwapInterface.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useState } from "react"; +import { usePlatformDetection } from "../../hooks/usePlatformDetection"; +import { PLOT_TOKEN } from "../../../lib/contracts/constants"; + +const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; +const UNISWAP_URL = `https://app.uniswap.org/swap?outputCurrency=${PLOT_TOKEN}&chain=base`; + +export function SwapInterface() { + const { platform, isLoading } = usePlatformDetection(); + const [swapLoading, setSwapLoading] = useState(false); + const [error, setError] = useState(null); + + const handleNativeSwap = async () => { + try { + setSwapLoading(true); + setError(null); + + const { sdk } = await import("@farcaster/miniapp-sdk"); + const result = await sdk.actions.swapToken({ + sellToken: `eip155:8453/erc20:${USDC_ADDRESS}`, + buyToken: `eip155:8453/erc20:${PLOT_TOKEN}`, + }); + + if (!result.success) { + if (result.reason === "rejected_by_user") { + setError("Swap cancelled by user"); + } else { + setError("Swap failed. Please try again."); + } + } + } catch { + setError("Failed to open swap interface"); + } finally { + setSwapLoading(false); + } + }; + + if (isLoading) { + return ( +
+
+
+
+
+ ); + } + + // Farcaster / Base App — native swap + if (platform === "farcaster" || platform === "base") { + const platformName = platform === "base" ? "Base App" : "Farcaster"; + + return ( +
+
+

Swap to $PLOT

+ + {platformName} + +
+ + {error && ( +
+ {error} +
+ )} + + + +

+ Opens {platformName}'s native swap interface +

+
+ ); + } + + // Web browser — Uniswap link + return ( +
+
+

Swap to $PLOT

+ + Web + +
+ + + {/* ArrowUpDown icon */} + + + + Buy PLOT on Uniswap + {/* ExternalLink icon */} + + + + + +

+ Opens Uniswap in a new tab +

+
+ ); +} diff --git a/src/hooks/usePlatformDetection.ts b/src/hooks/usePlatformDetection.ts new file mode 100644 index 00000000..3f470ebf --- /dev/null +++ b/src/hooks/usePlatformDetection.ts @@ -0,0 +1,42 @@ +"use client"; + +import { useState, useEffect } from "react"; + +export type Platform = "farcaster" | "base" | "web"; + +/** Base App's client FID in the Farcaster protocol */ +const BASE_APP_CLIENT_FID = 309857; + +export function usePlatformDetection() { + const [platform, setPlatform] = useState("web"); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + + import("@farcaster/miniapp-sdk") + .then(async ({ sdk }) => { + if (cancelled) return; + const context = await sdk.context; + if (!context?.client || cancelled) return; + + if (context.client.clientFid === BASE_APP_CLIENT_FID) { + setPlatform("base"); + } else { + setPlatform("farcaster"); + } + }) + .catch(() => { + // SDK not available = web browser + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + + return () => { + cancelled = true; + }; + }, []); + + return { platform, isLoading, isMiniApp: platform !== "web" }; +} diff --git a/src/hooks/useTokenInfo.ts b/src/hooks/useTokenInfo.ts new file mode 100644 index 00000000..901e541e --- /dev/null +++ b/src/hooks/useTokenInfo.ts @@ -0,0 +1,158 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { formatEther } from "viem"; +import { browserClient } from "../../lib/rpc"; +import { PLOT_TOKEN, MCV2_BOND, HUNT } from "../../lib/contracts/constants"; + +const USDC_ADDRESS = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" as const; +const ONEINCH_SPOT_PRICE_AGGREGATOR = "0x00000000000D6FFc74A8feb35aF5827bf57f6786" as const; + +const SPOT_PRICE_ABI = [ + { + inputs: [ + { name: "srcToken", type: "address" }, + { name: "dstToken", type: "address" }, + { name: "useWrappers", type: "bool" }, + ], + name: "getRate", + outputs: [{ name: "weightedRate", type: "uint256" }], + stateMutability: "view", + type: "function", + }, +] as const; + +const MCV2_BOND_ABI = [ + { + inputs: [{ name: "token", type: "address" }], + name: "priceForNextMint", + outputs: [{ name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, +] as const; + +const ERC20_SUPPLY_ABI = [ + { + inputs: [], + name: "totalSupply", + outputs: [{ name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, +] as const; + +export interface TokenInfo { + price: number; + marketCap: number; + totalSupply: number; + priceChange24h: number | null; +} + +async function getHuntPriceUSD(): Promise { + const weightedRate = await browserClient.readContract({ + address: ONEINCH_SPOT_PRICE_AGGREGATOR, + abi: SPOT_PRICE_ABI, + functionName: "getRate", + args: [HUNT, USDC_ADDRESS, false], + }); + // USDC has 6 decimals, HUNT has 18 → rate is scaled to 1e18 + // weightedRate = how many USDC (6 dec) per 1 HUNT (18 dec) scaled by 1e18 + // USD price = weightedRate / 1e6 + return Number(weightedRate) / 1_000_000; +} + +async function getPlotPriceUSD(): Promise { + const priceInHuntWei = await browserClient.readContract({ + address: MCV2_BOND, + abi: MCV2_BOND_ABI, + functionName: "priceForNextMint", + args: [PLOT_TOKEN], + }); + const priceInHunt = Number(formatEther(priceInHuntWei)); + const huntPriceUSD = await getHuntPriceUSD(); + return priceInHunt * huntPriceUSD; +} + +export function useTokenInfo() { + return useQuery({ + queryKey: ["plot-token-info"], + queryFn: async () => { + const [price, supply] = await Promise.all([ + getPlotPriceUSD(), + browserClient.readContract({ + address: PLOT_TOKEN, + abi: ERC20_SUPPLY_ABI, + functionName: "totalSupply", + }), + ]); + + const totalSupply = Number(formatEther(supply)); + const marketCap = price * totalSupply; + + // 24h price change via block diff (~43200 blocks = 1 day on Base @ 2s) + let priceChange24h: number | null = null; + try { + const currentBlock = await browserClient.getBlockNumber(); + const pastBlock = currentBlock - BigInt(43200); + + const [pastPriceInHuntWei, huntPriceUSD] = await Promise.all([ + browserClient.readContract({ + address: MCV2_BOND, + abi: MCV2_BOND_ABI, + functionName: "priceForNextMint", + args: [PLOT_TOKEN], + blockNumber: pastBlock, + }), + getHuntPriceUSD(), + ]); + + const pastPriceUSD = Number(formatEther(pastPriceInHuntWei)) * huntPriceUSD; + if (pastPriceUSD > 0) { + priceChange24h = ((price - pastPriceUSD) / pastPriceUSD) * 100; + } + } catch { + // Token may not have existed 24h ago + } + + return { price, marketCap, totalSupply, priceChange24h } satisfies TokenInfo; + }, + staleTime: 5 * 60 * 1000, + refetchInterval: 5 * 60 * 1000, + retry: 2, + }); +} + +export function formatPrice(price: number): string { + if (price === 0) return "$0"; + if (price >= 1) return `$${price.toFixed(2)}`; + + const str = price.toString(); + const match = str.match(/^0\.0+/); + + if (match) { + const leadingZeros = match[0].length - 2; + if (leadingZeros >= 4) { + const significantPart = str.slice(match[0].length); + const displayDigits = significantPart.slice(0, 4); + const subscriptMap: Record = { + "0": "\u2080", "1": "\u2081", "2": "\u2082", "3": "\u2083", + "4": "\u2084", "5": "\u2085", "6": "\u2086", "7": "\u2087", + "8": "\u2088", "9": "\u2089", + }; + const subscriptZeros = leadingZeros.toString().split("").map((d) => subscriptMap[d]).join(""); + return `$0.0${subscriptZeros}${displayDigits}`; + } + } + + return `$${price.toFixed(6).replace(/\.?0+$/, "")}`; +} + +export function formatNumber(num: number): string { + if (num === 0) return "0"; + const abs = Math.abs(num); + if (abs >= 1e9) return `${(num / 1e9).toFixed(2)}B`; + if (abs >= 1e6) return `${(num / 1e6).toFixed(2)}M`; + if (abs >= 1e3) return `${(num / 1e3).toFixed(2)}K`; + return num.toFixed(2); +}