From e5520f4a41a3f5c56853bab964d60093ffbdd4c0 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Mon, 23 Mar 2026 11:39:17 +0000 Subject: [PATCH 1/3] [#462] Add token swap interface and enhanced token info to /token page - Add SwapInterface component: web links to Uniswap, Farcaster/Base uses native sdk.actions.swapToken() - Add usePlatformDetection hook for Farcaster/Base/web detection - Enhance Token Information section with icon-based card layout: Mint Club, Hunt Town, Basescan links with proper icons Copy button with check/copy icon states Network badge (Base Mainnet ERC-20) - Place swap interface between token utility and how-to sections Fixes #462 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/token/page.tsx | 98 ++++++++++++++----- src/components/token/SwapInterface.tsx | 126 +++++++++++++++++++++++++ src/hooks/usePlatformDetection.ts | 39 ++++++++ 3 files changed, 241 insertions(+), 22 deletions(-) create mode 100644 src/components/token/SwapInterface.tsx create mode 100644 src/hooks/usePlatformDetection.ts diff --git a/src/app/token/page.tsx b/src/app/token/page.tsx index be076ede..7dcb258c 100644 --- a/src/app/token/page.tsx +++ b/src/app/token/page.tsx @@ -3,9 +3,11 @@ 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"; const BASESCAN_URL = `${EXPLORER_URL}/token/${PLOT_TOKEN}`; const MINT_CLUB_URL = "https://mint.club/token/base/PLOT"; @@ -106,6 +108,9 @@ export default function TokenPage() { + {/* Swap Interface */} + + {/* How to Get PLOT */}

How to Get PLOT

@@ -133,11 +138,11 @@ export default function TokenPage() { {/* Token Information */}
-

Token Information

+

Token Information

- {/* Stats */} + {/* Stats Grid — Price + Total Supply */}
-
+
Total Supply
{supplyLoading ? (
@@ -150,48 +155,79 @@ export default function TokenPage() {
)}
-
-
Network
-
- - Base Mainnet -
+
+
Market Cap
+
- {/* 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..51100a80 --- /dev/null +++ b/src/hooks/usePlatformDetection.ts @@ -0,0 +1,39 @@ +"use client"; + +import { useState, useEffect } from "react"; + +export type Platform = "farcaster" | "base" | "web"; + +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 === 309857) { + 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" }; +} From e56d977c708e508b4cea7c69708788f596a4a392 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Mon, 23 Mar 2026 11:42:12 +0000 Subject: [PATCH 2/3] [#462] Address review feedback: live price, icons, named constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add live Price field via MCV2_Bond.priceForNextMint on-chain read - Add Market Cap calculation (supply × price) with loading states - Commit SVG icons to git (mc-icon-light, hunt-token, basescan-icon) - Extract Base App clientFid to named constant BASE_APP_CLIENT_FID Co-Authored-By: Claude Opus 4.6 (1M context) --- public/basescan-icon.svg | 4 +++ public/hunt-token.svg | 11 +++++++ public/mc-icon-light.svg | 12 ++++++++ src/app/token/page.tsx | 48 ++++++++++++++++++++++++------- src/hooks/usePlatformDetection.ts | 5 +++- 5 files changed, 69 insertions(+), 11 deletions(-) create mode 100644 public/basescan-icon.svg create mode 100644 public/hunt-token.svg create mode 100644 public/mc-icon-light.svg 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 7dcb258c..75978135 100644 --- a/src/app/token/page.tsx +++ b/src/app/token/page.tsx @@ -5,8 +5,9 @@ import { formatUnits, erc20Abi } from "viem"; import { useState } from "react"; import Image from "next/image"; import { - PLOT_TOKEN, EXPLORER_URL, + PLOT_TOKEN, EXPLORER_URL, MCV2_BOND, } from "../../../lib/contracts/constants"; +import { priceForNextMintFunction } from "../../../lib/contracts/abi"; import { SwapInterface } from "../../components/token/SwapInterface"; const BASESCAN_URL = `${EXPLORER_URL}/token/${PLOT_TOKEN}`; @@ -31,8 +32,20 @@ export default function TokenPage() { functionName: "totalSupply", }); + const { data: priceRaw, isLoading: priceLoading } = useReadContract({ + address: MCV2_BOND, + abi: [priceForNextMintFunction], + functionName: "priceForNextMint", + args: [PLOT_TOKEN], + }); + const formattedBalance = balance ? formatUnits(balance, 18) : "0"; const formattedSupply = totalSupply ? formatUnits(totalSupply, 18) : "0"; + const formattedPrice = priceRaw ? formatUnits(priceRaw, 18) : null; + const marketCap = + formattedPrice && totalSupply + ? parseFloat(formattedPrice) * parseFloat(formattedSupply) + : null; const handleCopyAddress = async () => { try { @@ -140,24 +153,39 @@ export default function TokenPage() {

Token Information

- {/* Stats Grid — Price + Total Supply */} + {/* Stats Grid — Price + Market Cap */}
-
Total Supply
- {supplyLoading ? ( +
Price
+ {priceLoading ? (
- ) : ( + ) : formattedPrice ? (
- {parseFloat(formattedSupply).toLocaleString(undefined, { - minimumFractionDigits: 0, - maximumFractionDigits: 0, - })} PLOT + {parseFloat(formattedPrice).toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 6, + })}{" "} + PLOT
+ ) : ( +
)}
Market Cap
-
+ {priceLoading || supplyLoading ? ( +
+ ) : marketCap !== null ? ( +
+ {marketCap.toLocaleString(undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: 0, + })}{" "} + PLOT +
+ ) : ( +
+ )}
diff --git a/src/hooks/usePlatformDetection.ts b/src/hooks/usePlatformDetection.ts index 51100a80..3f470ebf 100644 --- a/src/hooks/usePlatformDetection.ts +++ b/src/hooks/usePlatformDetection.ts @@ -4,6 +4,9 @@ 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); @@ -17,7 +20,7 @@ export function usePlatformDetection() { const context = await sdk.context; if (!context?.client || cancelled) return; - if (context.client.clientFid === 309857) { + if (context.client.clientFid === BASE_APP_CLIENT_FID) { setPlatform("base"); } else { setPlatform("farcaster"); From e9dfe8bbc725936cfbb89793c9062c7f8905a8e2 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Mon, 23 Mar 2026 11:45:30 +0000 Subject: [PATCH 3/3] [#462] Add USD-denominated price and market cap via 1inch spot aggregator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces raw MCV2_Bond reserve-token price with proper USD conversion: - useTokenInfo hook: PLOT price in HUNT (via priceForNextMint) × HUNT/USD rate (via 1inch Spot Price Aggregator on Base) - Market cap = USD price × total supply - 24h price change via block diff (~43200 blocks) - formatPrice helper with subscript notation for tiny prices Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/token/page.tsx | 53 +++++-------- src/hooks/useTokenInfo.ts | 158 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+), 35 deletions(-) create mode 100644 src/hooks/useTokenInfo.ts diff --git a/src/app/token/page.tsx b/src/app/token/page.tsx index 75978135..a1463f36 100644 --- a/src/app/token/page.tsx +++ b/src/app/token/page.tsx @@ -5,10 +5,10 @@ import { formatUnits, erc20Abi } from "viem"; import { useState } from "react"; import Image from "next/image"; import { - PLOT_TOKEN, EXPLORER_URL, MCV2_BOND, + PLOT_TOKEN, EXPLORER_URL, } from "../../../lib/contracts/constants"; -import { priceForNextMintFunction } from "../../../lib/contracts/abi"; 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"; @@ -26,26 +26,9 @@ export default function TokenPage() { query: { enabled: !!address }, }); - const { data: totalSupply, isLoading: supplyLoading } = useReadContract({ - address: PLOT_TOKEN, - abi: erc20Abi, - functionName: "totalSupply", - }); - - const { data: priceRaw, isLoading: priceLoading } = useReadContract({ - address: MCV2_BOND, - abi: [priceForNextMintFunction], - functionName: "priceForNextMint", - args: [PLOT_TOKEN], - }); + const { data: tokenInfo, isLoading: tokenInfoLoading } = useTokenInfo(); const formattedBalance = balance ? formatUnits(balance, 18) : "0"; - const formattedSupply = totalSupply ? formatUnits(totalSupply, 18) : "0"; - const formattedPrice = priceRaw ? formatUnits(priceRaw, 18) : null; - const marketCap = - formattedPrice && totalSupply - ? parseFloat(formattedPrice) * parseFloat(formattedSupply) - : null; const handleCopyAddress = async () => { try { @@ -157,15 +140,19 @@ export default function TokenPage() {
Price
- {priceLoading ? ( + {tokenInfoLoading ? (
- ) : formattedPrice ? ( -
- {parseFloat(formattedPrice).toLocaleString(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 6, - })}{" "} - PLOT + ) : tokenInfo ? ( +
+
+ {formatPrice(tokenInfo.price)} +
+ {tokenInfo.priceChange24h !== null && ( +
= 0 ? "text-green-600" : "text-red-600"}`}> + {tokenInfo.priceChange24h >= 0 ? "+" : ""} + {tokenInfo.priceChange24h.toFixed(2)}% +
+ )}
) : (
@@ -173,15 +160,11 @@ export default function TokenPage() {
Market Cap
- {priceLoading || supplyLoading ? ( + {tokenInfoLoading ? (
- ) : marketCap !== null ? ( + ) : tokenInfo ? (
- {marketCap.toLocaleString(undefined, { - minimumFractionDigits: 0, - maximumFractionDigits: 0, - })}{" "} - PLOT + ${formatNumber(tokenInfo.marketCap)}
) : (
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); +}